PHP7-编程秘籍(全)

PHP7 编程秘籍(全)

原文:zh.annas-archive.org/md5/2ddf943a2c311275def462dcde4895fb

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

PHP 7 已经席卷了开源社区,打破了速度记录,这在比喻上引起了人们的关注。从最基本的意义上讲,核心工程团队对语言进行了重大改写,但仍然成功地保持了很高程度的向后兼容性。这些内部变化的影响在速度上表现出来,速度几乎增加了 200%,内存使用也有了显著的节省。从开发的角度来看,命令解析方式的改变以及统一的变量语法引入了在早期版本的 PHP 中根本不可能的编写代码的新方法。同样,任何不了解 PHP 7 中命令解释方式的开发人员可能会陷入看不见的陷阱,导致代码出现故障。因此,本书的任务是阐明编写代码的新方法,并指出与以前版本的 PHP 不兼容的任何领域。还需要注意的是,本书涵盖了 PHP 7.0 和 7.1。

本书内容包括

第一章,打下基础,帮助您开始设置和配置 PHP 7 开发环境。我们还将介绍一些强有力的初始示例,展示 PHP 7 的新功能。

第二章,使用 PHP 7 高性能功能,深入探讨了语言的新功能。您将了解抽象语法树和统一变量语法等概念,以及这些如何影响日常编程。接着是利用 PHP 7 性能改进的示例,包括foreach()循环处理中的重大新变化。

第三章,使用 PHP 函数式编程,强调 PHP 一直具有使用程序员定义的函数库而不是类的能力,PHP 7 也不例外。在本章中,我们将更仔细地研究函数处理的改进,包括提供涉及基本数据类型(如整数、浮点数、布尔值和字符串)的“类型提示”,用于输入和输出。我们还将广泛介绍标准 PHP 库中的迭代器,以及如何利用生成器的改进处理编写自己的迭代器。

第四章,使用 PHP 面向对象编程,探讨了 PHP 面向对象编程的基础知识。迅速超越基础知识,您将学习如何使用 PHP 命名空间和特征。还将涵盖架构考虑因素,包括如何最好地使用接口。最后,将讨论一个令人兴奋的新功能 PHP 7,即匿名类,并提供其实际用例。

第五章,与数据库交互,探讨了应用程序从数据库中读取和写入数据的能力,这是任何现代网站的关键部分。然而,广泛误解的是正确使用 PHP 数据对象(PDO)扩展。本章将全面介绍 PDO,从而使您的应用程序能够与大多数主要数据库交互,包括 MySQL、Oracle、PostgreSQL、IBM DB2 和 Microsoft SQL Server,而无需学习任何其他一套命令。此外,我们还将涵盖高级技术,如使用领域模型实体、执行嵌入式次要查找以及使用 PHP 7 实现 jQuery DataTable 查找。

第六章,构建可扩展的网站,深入探讨了 PHP 开发人员在构建交互式网站时面临的经典问题之一——硬编码 HTML 表单,然后需要进行维护。本章介绍了一种简洁高效的面向对象方法,只需很少的代码,就可以生成整个 HTML 表单,并且可以在初始配置中轻松更改。另一个同样棘手的问题是如何过滤和验证从表单提交的数据。在本章中,您将学习如何开发一个易于配置的过滤和验证工厂,然后可以应用于任何传入的提交数据。

第七章,访问 Web 服务,涵盖了对 Web 开发越来越重要的内容——发布或消费 Web 服务的能力。本章涵盖了两种关键方法:SOAP 和 REST。您将学习如何实现 SOAP 和 REST 服务器和客户端。此外,所呈现的示例使用了适配器设计模式,这允许相当大程度的定制,这意味着您不会被锁定在特定的设计范式中。

第八章,处理日期/时间和国际化方面,帮助您应对由于万维网(WWW)的增长而导致的激烈竞争,从而导致越来越多的客户希望将业务拓展到国际市场。本章将使您了解国际化的各个方面,包括使用表情符号、复杂字符和翻译。此外,您将学习如何获取和处理区域信息,包括语言设置、数字和货币格式化,以及日期和时间。此外,我们还将介绍如何创建国际化日历的配方,这些日历可以处理重复事件。

第九章,开发中间件,涉及了当前开源社区中最热门的话题——中间件。顾名思义,中间件是可以“插入”到现有应用程序中,为该应用程序增加价值而无需修改该应用程序源代码的软件。在本章中,您将看到一系列配方,实现为符合 PSR-7 标准的中间件(有关更多详细信息,请参见附录,定义 PSR-7 类),执行身份验证、访问控制、缓存和路由。

第十章,深入了解高级算法,帮助您了解作为开发人员,鉴于大量的程序员和公司竞争同一业务,掌握关键的高级算法非常重要。在本章中,您将使用 PHP 7 学习获取器和设置器、链表、冒泡排序、栈和二分查找的理论和应用。此外,本章还探讨了如何使用这些技术来实现搜索引擎,以及如何处理多维数组。

第十一章,实现软件设计模式,涉及面向对象编程的一个重要方面,即理解关键的软件设计模式。如果没有这些知识,在申请新职位或吸引新客户时,作为开发人员,您将处于严重劣势。本章涵盖了几个非常重要的模式,包括 Hydration、Strategy、Mapper、Object Relational Mapping 和 Pub/Sub。

第十二章,提高 Web 安全性,解决了当今互联网的普遍性带来的问题。我们看到网络攻击的频率越来越高,往往造成严重的财务和个人损失。在本章中,我们将提供实用的实用食谱,如果实施,将大大提高您的网站的安全性。涵盖的主题包括过滤和验证、会话保护、安全表单提交、安全密码生成以及使用 CAPTCHA。此外,还介绍了一种食谱,将向您展示如何在不使用 PHP mcrypt 扩展的情况下加密和解密数据,该扩展在 PHP 7.1 中已被弃用(最终将从语言中删除)。

第十三章,最佳实践、测试和调试,涵盖了编写良好的代码以使其正常工作的最佳实践和调试。在本章中,您还将学习如何设置和创建单元测试,处理意外错误和异常以及生成测试数据。介绍了几个新的 PHP 7 功能,包括 PHP 7 如何“抛出”错误。重要的是要注意,最佳实践在整本书中都有提到,不仅仅是在本章中!

附录,定义 PSR-7 类,介绍了最近接受的 PHP 标准建议 7,该标准定义了与中间件一起使用的接口。在本附录中,您将看到 PSR-7 类的实际实现,其中包括 URI、正文和文件上传等值对象,以及请求和响应对象。

本书所需的内容

要成功实施本书中提出的食谱,您需要一台计算机、额外 100MB 的磁盘空间以及一个文本或代码编辑器(而不是文字处理软件!)。第一章将介绍如何设置 PHP 7 开发环境。拥有 Web 服务器是可选的,因为 PHP 7 包含开发 Web 服务器。不需要互联网连接,但可能有用以下载代码(例如 PSR-7 接口集),并查看 PHP 7.x 文档。

本书适合谁

本书适用于软件架构师、技术经理、中级到高级开发人员,或者只是好奇的人。您需要对 PHP 编程有基本的了解,特别是面向对象编程。

章节

在本书中,您将找到一些经常出现的标题(准备工作、如何做、它是如何工作的、还有更多以及另请参阅)。

为了清晰地说明如何完成食谱,我们使用以下各节:

准备工作

本节告诉您可以在食谱中期待什么,并描述了如何设置食谱所需的任何软件或任何初步设置。

如何做...

本节包含了遵循食谱所需的步骤。

它是如何工作的...

本节通常包括对上一节中发生的事情的详细解释。

还有更多...

本节包括有关食谱的其他信息,以使读者更加了解食谱。

另请参阅

本节提供了有关该食谱的其他有用信息的链接。

约定

在本书中,您将找到一些文本样式,用于区分不同类型的信息。以下是这些样式的一些示例以及它们的含义解释。

文本中的代码词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 句柄显示如下:“最后,取出第三个项目中定义的LotsProps类,并将其放入一个单独的文件中,chap_10_oop_using_getters_and_setters_magic_call.php。”

代码块设置如下:

protected static function loadFile($file)
{
    if (file_exists($file)) {
        require_once $file;
        return TRUE;
    }
    return FALSE;
}

当我们希望引起您对代码块的特定部分的注意时,相关的行或项目会以粗体显示:

$params = [
  'db'   => __DIR__ . '/../data/db/php7cookbook.db.sqlite'
];
$dsn  = sprintf(**'sqlite:' . $params['db']**);

任何命令行输入或输出都是这样写的:

**cd /path/to/recipes**
**php -S localhost:8080**

新术语重要单词以粗体显示。屏幕上看到的单词,例如菜单或对话框中的单词,会在文本中以这种方式出现:“当点击购买按钮时,初始购买信息会出现。”

注意

警告或重要说明会出现在这样的框中。

提示

提示和技巧看起来像这样。

第一章:打下基础

在本章中,我们将涵盖以下主题:

  • PHP 7 安装注意事项

  • 使用内置的 PHP Web 服务器

  • 定义一个测试 MySQL 数据库

  • 安装 PHPUnit

  • 实现类自动加载

  • 悬停在网站上

  • 构建深网扫描器

  • 创建一个 PHP 5 到 PHP 7 代码转换器

介绍

本章旨在作为一个快速入门,让您立即开始在 PHP 7 上运行并实施配方。本书的基本假设是您已经对 PHP 和编程有很好的了解。虽然本书不会详细介绍 PHP 的实际安装,但考虑到 PHP 7 相对较新,我们将尽力指出您在 PHP 7 安装过程中可能遇到的怪癖和陷阱

PHP 7 安装注意事项

有三种主要获取 PHP 7 的方法:

  • 直接从源代码下载和安装

  • 安装预编译二进制文件

  • 安装*AMP 包(即 XAMPP,WAMP,LAMP,MAMP 等)

如何做...

这三种方法按难度顺序列出。然而,第一种方法虽然繁琐,但可以让您对扩展和选项有最精细的控制。

直接从源代码安装

为了使用这种方法,您需要有一个 C 编译器。如果您使用 Windows,MinGW是一个广受欢迎的免费编译器。它基于GNU项目提供的GNU Compiler CollectionGCC)编译器。非免费的编译器包括 Borland 的经典Turbo C编译器,当然,Windows 开发人员首选的编译器是Visual Studio。然而,后者主要设计用于 C++开发,因此在编译 PHP 时,您需要指定 C 模式。

在苹果 Mac 上工作时,最好的解决方案是安装Apple Developer Tools。您可以使用Xcode IDE编译 PHP 7,或者从终端窗口运行gcc。在 Linux 环境中,从终端窗口运行gcc

在终端窗口或命令行编译时,正常的程序如下:

  • configure

  • 制作

  • make test

  • make install

有关配置选项(即运行configure时)的信息,请使用help选项:

**configure --help**

在配置阶段可能遇到的错误在下表中提到:

错误 修复
configure: error: xml2-config not found. Please check your libxml2 installation 您只需要安装libxml2。有关此错误,请参阅以下链接:superuser.com/questions/740399/how-to-fix-php-installation-when-xml2-config-is-missing
configure: error: Please reinstall readline - I cannot find readline.h 安装libreadline-dev
configure: WARNING: unrecognized options: --enable-spl, --enable-reflection, --with-libxml 没关系。这些选项是默认的,不需要包含在内。有关更多详细信息,请参阅以下链接:jcutrer.com/howto/linux/how-to-compile-php7-on-ubuntu-14-04

从预编译的二进制文件安装 PHP 7

正如标题所示,预编译二进制文件是一组由他人从 PHP 7 源代码编译而成并提供的二进制文件。

在 Windows 的情况下,转到windows.php.net/。您将在左栏找到一些关于选择哪个版本、线程安全非线程安全等的提示。然后您可以点击Downloads并查找适用于您环境的 ZIP 文件。下载 ZIP 文件后,将文件解压到您选择的文件夹中,将php.exe添加到您的路径,并使用php.ini文件配置 PHP 7。

要在 Mac OS X 系统上安装预编译的二进制文件,最好使用包管理系统。PHP 推荐的包括以下内容:

  • MacPorts

  • Liip

  • Fink

  • Homebrew

在 Linux 的情况下,使用的打包系统取决于您使用的 Linux 发行版。以下表格按 Linux 发行版组织,总结了查找 PHP 7 包的位置。

分发 PHP 7 在哪里找到 注释

| Debian | packages.debian.org/stable/php``repos-source.zend.com/zend-server/early-access/php7/php-7*DEB* | 使用此命令:

**sudo apt-get install php7**

或者,您可以使用图形包管理工具,如Synaptic。确保选择php7(而不是 php5)。 |

Ubuntu packages.ubuntu.com``repos-source.zend.com/zend-server/early-access/php7/php-7*DEB* 使用此命令:sudo apt-get install php7确保选择正确的 Ubuntu 版本。或者,您可以使用图形包管理工具,如Synaptic

| Fedora / Red Hat | admin.fedoraproject.org/pkgdb/packages``repos-source.zend.com/zend-server/early-access/php7/php-7*RHEL* | 确保您是 root 用户:

**su**

使用此命令:dnf install php7或者,您可以使用图形包管理工具,如 GNOME 包管理器。 |

| OpenSUSE | software.opensuse.org/package/php7 | 使用此命令:

**yast -i php7**

或者,您可以运行zypper,或者使用YaST作为图形工具。 |

安装*AMP 包

AMP指的是ApacheMySQLPHP(还包括PerlPython)。*****指的是 Linux、Windows、Mac 等(即 LAMP、WAMP 和 MAMP)。这种方法通常是最简单的,但是您对初始 PHP 安装的控制较少。另一方面,您可以随时修改php.ini文件并安装其他扩展来自定义您的安装。以下表格总结了一些流行的*AMP 包:

Package 找到它在哪里 免费? 支持*
XAMPP www.apachefriends.org/download.html Y WML
AMPPS www.ampps.com/downloads Y WML
MAMP www.mamp.info/en Y WM
WampServer sourceforge.net/projects/wampserver Y W
EasyPHP www.easyphp.org Y W
Zend Server www.zend.com/en/products/zend_server N WML

在上表中,我们列出了W替换为W的*AMP 包,M替换为 Mac OS X,L替换为 Linux。

还有更多...

当您从软件包安装预编译的二进制文件时,只安装了core扩展。非核心 PHP 扩展必须单独安装。

值得注意的是,云计算平台上的 PHP 7 安装通常会遵循预编译二进制文件的安装过程。了解您的云环境是否使用 Linux、Mac 或 Windows 虚拟机,然后按照本文中提到的适当过程进行操作。

可能 PHP 7 尚未到达您喜欢的预编译二进制文件存储库。您可以始终从源代码安装,或者考虑安装其中一个*AMP 包(请参阅下一节)。对于基于 Linux 的系统,另一种选择是使用个人软件包存档PPA)方法。但是,由于 PPA 尚未经过严格的筛选过程,安全性可能是一个问题。有关 PPA 安全考虑的良好讨论可在askubuntu.com/questions/35629/are-ppas-safe-to-add-to-my-system-and-what-are-some-red-flags-to-watch-out-fo找到。

另请参阅

可以在php.net/manual/en/install.general.php找到一般安装注意事项,以及针对三个主要操作系统平台(Windows、Mac OS X 和 Linux)的说明。

MinGW 的网站是www.mingw.org/

有关如何使用 Visual Studio 编译 C 程序的说明,请访问msdn.microsoft.com/en-us/library/bb384838

测试 PHP 7 的另一种可能的方法是使用虚拟机。以下是一些工具及其链接,可能会有用:

使用内置的 PHP Web 服务器

除了单元测试和直接从命令行运行 PHP 之外,测试应用程序的明显方法是使用 Web 服务器。对于长期项目,为了开发与客户使用的 Web 服务器最接近的虚拟主机定义将是有益的。为各种 Web 服务器(如 Apache、NGINX 等)创建这样的定义超出了本书的范围。另一个快速且易于使用的替代方法(我们在这里有讨论的空间)是使用内置的 PHP 7 Web 服务器。

如何做...

  1. 要激活 PHP Web 服务器,首先切换到将用作代码基础的目录。

  2. 然后,您需要提供主机名或 IP 地址,以及可选的端口。以下是您可以使用来运行本书提供的示例的示例:

cd /path/to/recipes
php -S localhost:8080

您将在屏幕上看到类似以下内容的输出:

如何做...

  1. 随着内置的 Web 服务器继续服务请求,您还将看到访问信息、HTTP 状态代码和请求信息。

  2. 如果您需要将 Web 服务器文档根目录设置为当前目录以外的目录,可以使用-t标志。然后,该标志必须跟随有效的目录路径。内置的 Web 服务器将把这个目录视为 Web 文档根目录,这对安全原因很有用。出于安全原因,一些框架(如 Zend Framework)要求 Web 文档根目录与实际源代码所在的位置不同。

以下是使用-t标志的示例:

**php -S localhost:8080 -t source/chapter01**

以下是输出的示例:

如何做...

定义一个测试 MySQL 数据库

为了测试目的,除了本书的源代码,我们还提供了一个带有示例数据的 SQL 文件,位于github.com/dbierer/php7cookbook。本书中用于示例的数据库名称是php7cookbook

如何做...

  1. 定义一个 MySQL 数据库,php7cookbook。还将新数据库的权限分配给名为cook的用户,密码为book。以下表总结了这些设置:
项目 注释
数据库名称 php7cookbook
数据库用户 cook
数据库用户密码 book
  1. 以下是创建数据库所需的 SQL 示例:
CREATE DATABASE IF NOT EXISTS dbname DEFAULT CHARACTER SET utf8 COLLATE utf8_general_ci;
CREATE USER 'user'@'%' IDENTIFIED WITH mysql_native_password;
SET PASSWORD FOR 'user'@'%' = PASSWORD('userPassword');
GRANT ALL PRIVILEGES ON dbname.* to 'user'@'%';
GRANT ALL PRIVILEGES ON dbname.* to 'user'@'localhost';
FLUSH PRIVILEGES;
  1. 将示例值导入新数据库。导入文件php7cookbook.sql位于github.com/dbierer/php7cookbook/blob/master/php7cookbook.sql

安装 PHPUnit

单元测试可以说是测试 PHP 代码的最流行方式。大多数开发人员都会同意,一个完善的测试套件是任何正确开发项目的必备条件。但是很少有开发人员实际编写这些测试。幸运的是,有一些独立的测试组为他们编写测试!然而,经过数月与测试组的战斗后,幸运的人往往会抱怨和抱怨。无论如何,任何一本关于 PHP 的书都不会完整,如果没有至少对测试的一点点提及。

找到PHPUnit的最新版本的地方是phpunit.de/。PHPUnit5.1 及以上版本支持 PHP 7。单击所需版本的链接,然后下载phpunit.phar文件。然后可以使用存档执行命令,如下所示:

**php phpunit.phar <command>**

提示

phar命令代表PHP Archive。这项技术基于tartar本身是在 UNIX 中使用的。phar文件是一组 PHP 文件,它们被打包到一个单个文件中以方便使用。

实现类自动加载

在使用面向对象编程OOP)方法开发 PHP 时,建议将每个类放在自己的文件中。遵循这个建议的好处是长期维护和提高可读性的便利。缺点是每个类定义文件必须被包含(即使用include或其变体)。为了解决这个问题,PHP 语言内置了一个机制,可以自动加载任何尚未被特别包含的类。

准备工作

PHP 自动加载的最低要求是定义一个全局的__autoload()函数。这是一个魔术函数,当 PHP 引擎自动调用时,会请求一个类,但该类尚未被包含。请求的类的名称将在调用__autoload()时作为参数出现(假设您已经定义了它!)。如果您使用 PHP 命名空间,将传递类的完整命名空间名称。因为__autoload()是一个函数,它必须在全局命名空间中;但是,对其使用有限制。因此,在本篇中,我们将使用spl_autoload_register()函数,这给了我们更多的灵活性。

操作方法...

  1. 我们将在本篇中介绍的类是Application\Autoload\Loader。为了利用 PHP 命名空间和自动加载之间的关系,我们将文件命名为Loader.php,并将其放置在/path/to/cookbook/files/Application/Autoload文件夹中。

  2. 我们将介绍的第一种方法是简单地加载一个文件。我们使用file_exists()在运行require_once()之前进行检查。这样做的原因是,如果文件未找到,require_once()将生成一个无法使用 PHP 7 的新错误处理功能捕获的致命错误:

protected static function loadFile($file)
{
    if (file_exists($file)) {
        require_once $file;
        return TRUE;
    }
    return FALSE;
}
  1. 然后我们可以在调用程序中测试loadFile()的返回值,并在无法加载文件时抛出Exception之前循环遍历备用目录列表。

提示

您会注意到这个类中的方法和属性都是静态的。这使我们在注册自动加载方法时更加灵活,并且还可以将Loader类视为单例

  1. 接下来,我们定义调用loadFile()并实际执行基于命名空间类名定位文件的逻辑的方法。该方法通过将 PHP 命名空间分隔符\转换为适合该服务器的目录分隔符并附加.php来派生文件名:
public static function autoLoad($class)
{
    $success = FALSE;
    $fn = str_replace('\\', DIRECTORY_SEPARATOR, $class) 
          . '.php';
    foreach (self::$dirs as $start) {
        $file = $start . DIRECTORY_SEPARATOR . $fn;
        if (self::loadFile($file)) {
            $success = TRUE;
            break;
        }
    }
    if (!$success) {
        if (!self::loadFile(__DIR__ 
            . DIRECTORY_SEPARATOR . $fn)) {
            throw new \Exception(
                self::UNABLE_TO_LOAD . ' ' . $class);
        }
    }
    return $success;
}
  1. 接下来,该方法循环遍历我们称之为self::$dirs的目录数组,使用每个目录作为派生文件名的起点。如果不成功,作为最后的手段,该方法尝试从当前目录加载文件。如果甚至这样也不成功,就会抛出一个Exception

  2. 接下来,我们需要一个可以将更多目录添加到我们要测试的目录列表中的方法。请注意,如果提供的值是一个数组,则使用array_merge()。否则,我们只需将目录字符串添加到self::$dirs数组中:

public static function addDirs($dirs)
{
    if (is_array($dirs)) {
        self::$dirs = array_merge(self::$dirs, $dirs);
    } else {
        self::$dirs[] = $dirs;
    }
}  
  1. 然后,我们来到最重要的部分;我们需要将我们的autoload()方法注册为标准 PHP 库SPL)自动加载程序。这是使用spl_autoload_register()init()方法来实现的:
public static function init($dirs = array())
{
    if ($dirs) {
        self::addDirs($dirs);
    }
    if (self::$registered == 0) {
        spl_autoload_register(__CLASS__ . '::autoload');
        self::$registered++;
    }
}
  1. 此时,我们可以定义__construct(),它调用self::init($dirs)。这使我们也可以创建Loader的实例(如果需要的话)。
public function __construct($dirs = array())
{
    self::init($dirs);
}

它是如何工作的...

为了使用我们刚刚定义的自动加载程序类,您需要require Loader.php。如果您的命名空间文件位于当前目录之外的目录中,您还应该运行Loader::init()并提供额外的目录路径。

为了确保自动加载程序正常工作,我们还需要一个测试类。这是/path/to/cookbook/files/Application/Test/TestClass.php的定义:

<?php
namespace Application\Test;
class TestClass
{
    public function getTest()
    {
        return __METHOD__;
    }
}

现在创建一个样本chap_01_autoload_test.php代码文件来测试自动加载程序:

<?php
require __DIR__ . '/../Application/Autoload/Loader.php';
Application\Autoload\Loader::init(__DIR__ . '/..');

接下来,获取一个尚未加载的类的实例:

$test = new Application\Test\TestClass();
echo $test->getTest();

最后,尝试获取一个不存在的fake类。请注意,这将引发错误:

$fake = new Application\Test\FakeClass();
echo $fake->getTest();

清理网站

经常有兴趣扫描网站并从特定标签中提取信息。这种基本机制可以用来在网络中搜索有用的信息。有时需要获取<IMG>标签和SRC属性的列表,或者<A>标签和相应的HREF属性。可能性是无限的。

如何做...

  1. 首先,我们需要获取目标网站的内容。乍一看,似乎我们应该发出 cURL 请求,或者简单地使用file_get_contents()。这些方法的问题是,我们最终将不得不进行大量的字符串操作,很可能不得不大量使用可怕的正则表达式。为了避免所有这些,我们将简单地利用已经存在的 PHP 7 类DOMDocument。因此,我们创建一个DOMDocument实例,将其设置为UTF-8。我们不关心空格,并使用方便的loadHTMLFile()方法将网站的内容加载到对象中:
public function getContent($url)
{
    if (!$this->content) {
        if (stripos($url, 'http') !== 0) {
            $url = 'http://' . $url;
        }
        $this->content = new DOMDocument('1.0', 'utf-8');
        $this->content->preserveWhiteSpace = FALSE;
        // @ used to suppress warnings generated from // improperly configured web pages
        @$this->content->loadHTMLFile($url);
    }
    return $this->content;
}

提示

请注意,在调用loadHTMLFile()方法之前,我们在其前面加上了@。这不是为了掩盖糟糕的编码(!),这在 PHP 5 中经常发生!相反,@抑制了解析器在遇到编写不良的 HTML 时生成的通知。据推测,我们可以捕获通知并记录它们,可能还给我们的Hoover类提供诊断能力。

  1. 接下来,我们需要提取感兴趣的标签。我们使用getElementsByTagName()方法来实现这个目的。如果我们希望提取所有标签,我们可以提供*作为参数:
public function getTags($url, $tag)
{
    $count    = 0;
    $result   = array();
    $elements = $this->getContent($url)
                     ->getElementsByTagName($tag);
    foreach ($elements as $node) {
        $result[$count]['value'] = trim(preg_replace('/\s+/', ' ', $node->nodeValue));
        if ($node->hasAttributes()) {
            foreach ($node->attributes as $name => $attr) 
            {
                $result[$count]['attributes'][$name] = 
                    $attr->value;
            }
        }
        $count++;
    }
    return $result;
}
  1. 提取特定属性而不是标签可能也是有趣的。因此,我们为此定义另一个方法。在这种情况下,我们需要遍历所有标签并使用getAttribute()。您会注意到有一个用于 DNS 域的参数。我们添加了这个参数,以便在同一个域内保持扫描(例如,如果您正在构建一个网页树):
public function getAttribute($url, $attr, $domain = NULL)
{
    $result   = array();
    $elements = $this->getContent($url)
                     ->getElementsByTagName('*');
    foreach ($elements as $node) {
        if ($node->hasAttribute($attr)) {
            $value = $node->getAttribute($attr);
            if ($domain) {
                if (stripos($value, $domain) !== FALSE) {
                    $result[] = trim($value);
                }
            } else {
                $result[] = trim($value);
            }
        }
    }
    return $result;
}

它是如何工作的...

为了使用新的Hoover类,初始化自动加载程序(如前所述)并创建Hoover类的实例。然后可以运行Hoover::getTags()方法,以产生您指定为参数的 URL 的标签数组。

这是来自chap_01_vacuuming_website.php的一段代码,它使用Hoover类来扫描 O'Reilly 网站的<A>标签:

<?php
// modify as needed
define('DEFAULT_URL', 'http://oreilly.com/');
define('DEFAULT_TAG', 'a');

require __DIR__ . '/../Application/Autoload/Loader.php';
Application\Autoload\Loader::init(__DIR__ . '/..');

// get "vacuum" class
$vac = new Application\Web\Hoover();

// NOTE: the PHP 7 null coalesce operator is used
$url = strip_tags($_GET['url'] ?? DEFAULT_URL);
$tag = strip_tags($_GET['tag'] ?? DEFAULT_TAG);

echo 'Dump of Tags: ' . PHP_EOL;
var_dump($vac->getTags($url, $tag));

输出将看起来像这样:

它是如何工作的...

另请参阅

有关 DOM 的更多信息,请参阅 PHP 参考页面php.net/manual/en/class.domdocument.php

构建深层网络扫描器

有时您需要扫描一个网站,但要深入一级。例如,您想要构建一个网站的 Web 树图。这可以通过查找所有<A>标签并跟踪HREF属性到下一个网页来实现。一旦您获得了子页面,您可以继续扫描以完成树。

如何做...

  1. 深层网络扫描仪的核心组件是一个基本的Hoover类,如前所述。本配方中介绍的基本过程是扫描目标网站并清理所有HREF属性。为此,我们定义了一个Application\Web\Deep类。我们添加一个表示 DNS 域的属性:
namespace Application\Web;
class Deep
{
    protected $domain;
  1. 接下来,我们定义一个方法,将为扫描列表中表示的每个网站的标签进行清理。为了防止扫描器在整个万维网WWW)上进行搜索,我们将扫描限制在目标域上。添加yield from的原因是因为我们需要产生Hoover::getTags()生成的整个数组。yield from语法允许我们将数组视为子生成器:
public function scan($url, $tag)
{
    $vac    = new Hoover();
    $scan   = $vac->getAttribute($url, 'href', 
       $this->getDomain($url));
    $result = array();
    foreach ($scan as $subSite) {
        yield from $vac->getTags($subSite, $tag);
    }
    return count($scan);
}

注意

使用yield fromscan()方法转换为 PHP 7 委托生成器。通常,您会倾向于将扫描结果存储在数组中。然而,在这种情况下,检索到的信息量可能会非常庞大。因此,最好立即产生结果,以节省内存并产生即时结果。否则,将会有一个漫长的等待,可能会导致内存不足错误。

  1. 为了保持在同一个域中,我们需要一个方法,将从 URL 中返回域。我们使用方便的parse_url()函数来实现这个目的:
public function getDomain($url)
{
    if (!$this->domain) {
        $this->domain = parse_url($url, PHP_URL_HOST);
    }
    return $this->domain;
}

它是如何工作的...

首先,继续定义之前定义的Application\Web\Deep类,以及前一个配方中定义的Application\Web\Hoover类。

接下来,定义一个代码块,来自chap_01_deep_scan_website.php,设置自动加载(如本章前面描述的):

<?php
// modify as needed
define('DEFAULT_URL', unlikelysource.com');
define('DEFAULT_TAG', 'img');

require __DIR__ . '/../../Application/Autoload/Loader.php';
Application\Autoload\Loader::init(__DIR__ . '/../..');

接下来,获取我们新类的一个实例:

$deep = new Application\Web\Deep();

在这一点上,您可以从 URL 参数中检索 URL 和标签信息。PHP 7 的null coalesce运算符对于建立回退值非常有用:

$url = strip_tags($_GET['url'] ?? DEFAULT_URL);
$tag = strip_tags($_GET['tag'] ?? DEFAULT_TAG);

一些简单的 HTML 将显示结果:

foreach ($deep->scan($url, $tag) as $item) {
    $src = $item['attributes']['src'] ?? NULL;
    if ($src && (stripos($src, 'png') || stripos($src, 'jpg'))) {
        printf('<br><img src="%s"/>', $src);
    }
}

另请参阅

有关生成器和yield from的更多信息,请参阅php.net/manual/en/language.generators.syntax.php上的文章。

创建一个 PHP 5 到 PHP 7 代码转换器

在大多数情况下,PHP 5.x 代码可以在 PHP 7 上不经修改地运行。然而,有一些更改被归类为向后不兼容。这意味着,如果您的 PHP 5 代码以某种方式编写,或者使用了已删除的函数,您的代码将会出错,您将会遇到一个令人讨厌的错误。

准备工作

PHP 5 到 PHP 7 代码转换器执行两项任务:

  • 扫描您的代码文件,并将已删除的 PHP 5 功能转换为 PHP 7 中的等效功能

  • 在更改语言使用的地方添加了// WARNING注释,但不可能进行重写

注意

请注意,在运行转换器之后,不能保证您的代码在 PHP 7 中能够正常工作。您仍然需要查看添加的// WARNING标签。至少,这个方法将为您提供一个很好的起点,将您的 PHP 5 代码转换为在 PHP 7 中运行。

这个方法的核心是新的 PHP 7 preg_replace_callback_array()函数。这个神奇的函数允许您将一系列正则表达式作为键呈现,并将值表示为独立的回调。然后,您可以通过一系列转换来传递字符串。不仅如此,回调数组的主题本身也可以是一个数组。

如何做...

  1. 在一个新的类Application\Parse\Convert中,我们从一个scan()方法开始,该方法接受一个文件名作为参数。它检查文件是否存在。如果存在,它调用 PHP 的file()函数,该函数将文件加载到一个数组中,其中每个数组元素代表一行:
public function scan($filename)
{
    if (!file_exists($filename)) {
        throw new Exception(
            self::EXCEPTION_FILE_NOT_EXISTS);
    }
    $contents = file($filename);
    echo 'Processing: ' . $filename . PHP_EOL;

    $result = preg_replace_callback_array( [
  1. 接下来,我们开始传递一系列键/值对。键是一个正则表达式,它针对字符串进行处理。任何匹配项都会传递给回调函数,该回调函数表示为键/值对的值部分。我们检查已从 PHP 7 中删除的开放和关闭标签:
    // replace no-longer-supported opening tags
    '!^\<\%(\n| )!' =>
        function ($match) {
            return '<?php' . $match[1];
        },

    // replace no-longer-supported opening tags
    '!^\<\%=(\n| )!' =>
        function ($match) {
            return '<?php echo ' . $match[1];
        },

    // replace no-longer-supported closing tag
    '!\%\>!' =>
        function ($match) {
            return '?>';
        },
  1. 接下来是一系列警告,当检测到某些操作并且在 PHP 5 与 PHP 7 中处理它们之间存在潜在的代码中断时。在所有这些情况下,代码都不会被重写。而是添加了一个带有WARNING单词的内联注释:
    // changes in how $$xxx interpretation is handled
    '!(.*?)\$\$!' =>
        function ($match) {
            return '// WARNING: variable interpolation 
                   . ' now occurs left-to-right' . PHP_EOL
                   . '// see: http://php.net/manual/en/'
                   . '// migration70.incompatible.php'
                   . $match[0];
        },

    // changes in how the list() operator is handled
    '!(.*?)list(\s*?)?\(!' =>
        function ($match) {
            return '// WARNING: changes have been made '
                   . 'in list() operator handling.'
                   . 'See: http://php.net/manual/en/'
                   . 'migration70.incompatible.php'
                   . $match[0];
        },

    // instances of \u{
    '!(.*?)\\\u\{!' =>
        function ($match) {
        return '// WARNING: \\u{xxx} is now considered '
               . 'unicode escape syntax' . PHP_EOL
               . '// see: http://php.net/manual/en/'
               . 'migration70.new-features.php'
               . '#migration70.new-features.unicode-'
               . 'codepoint-escape-syntax' . PHP_EOL
               . $match[0];
    },

    // relying upon set_error_handler()
    '!(.*?)set_error_handler(\s*?)?.*\(!' =>
        function ($match) {
            return '// WARNING: might not '
                   . 'catch all errors'
                   . '// see: http://php.net/manual/en/'
                   . '// language.errors.php7.php'
                   . $match[0];
        },

    // session_set_save_handler(xxx)
    '!(.*?)session_set_save_handler(\s*?)?\((.*?)\)!' =>
        function ($match) {
            if (isset($match[3])) {
                return '// WARNING: a bug introduced in'
                       . 'PHP 5.4 which '
                       . 'affects the handler assigned by '
                       . 'session_set_save_handler() and '
                       . 'where ignore_user_abort() is TRUE 
                       . 'has been fixed in PHP 7.'
                       . 'This could potentially break '
                       . 'your code under '
                       . 'certain circumstances.' . PHP_EOL
                       . 'See: http://php.net/manual/en/'
                       . 'migration70.incompatible.php'
                       . $match[0];
            } else {
                return $match[0];
            }
        },
  1. 任何尝试使用<<>>与负操作符或超过 64 的操作都会被包裹在try { xxx } catch() { xxx }块中,寻找ArithmeticError的抛出:
    // wraps bit shift operations in try / catch
    '!^(.*?)(\d+\s*(\<\<|\>\>)\s*-?\d+)(.*?)$!' =>
        function ($match) {
            return '// WARNING: negative and '
                   . 'out-of-range bitwise '
                   . 'shift operations will now 
                   . 'throw an ArithmeticError' . PHP_EOL
                   . 'See: http://php.net/manual/en/'
                   . 'migration70.incompatible.php'
                   . 'try {' . PHP_EOL
                   . "\t" . $match[0] . PHP_EOL
                   . '} catch (\\ArithmeticError $e) {'
                   . "\t" . 'error_log("File:" 
                   . $e->getFile() 
                   . " Message:" . $e->getMessage());'
                   . '}' . PHP_EOL;
        },

注意

PHP 7 已更改了错误处理方式。在某些情况下,错误被移动到与异常类似的分类中,并且可以被捕获!Error类和Exception类都实现了Throwable接口。如果要捕获ErrorException,请捕获Throwable

  1. 接下来,转换器会重写任何使用call_user_method*()的用法,这在 PHP 7 中已被移除。这些将被替换为使用call_user_func*()的等效用法:
    // replaces "call_user_method()" with
    // "call_user_func()"
    '!call_user_method\((.*?),(.*?)(,.*?)\)(\b|;)!' =>
        function ($match) {
            $params = $match[3] ?? '';
            return '// WARNING: call_user_method() has '
                      . 'been removed from PHP 7' . PHP_EOL
                      . 'call_user_func(['. trim($match[2]) . ',' 
                      . trim($match[1]) . ']' . $params . ');';
        },

    // replaces "call_user_method_array()" 
    // with "call_user_func_array()"
    '!call_user_method_array\((.*?),(.*?),(.*?)\)(\b|;)!' =>
        function ($match) {
            return '// WARNING: call_user_method_array()'
                   . 'has been removed from PHP 7'
                   . PHP_EOL
                   . 'call_user_func_array([' 
                   . trim($match[2]) . ',' 
                   . trim($match[1]) . '], ' 
                   . $match[3] . ');';
        },
  1. 最后,任何尝试使用带有/e修饰符的preg_replace()都会被重写为使用preg_replace_callback()
     '!^(.*?)preg_replace.*?/e(.*?)$!' =>
    function ($match) {
        $last = strrchr($match[2], ',');
        $arg2 = substr($match[2], 2, -1 * (strlen($last)));
        $arg1 = substr($match[0], 
                       strlen($match[1]) + 12, 
                       -1 * (strlen($arg2) + strlen($last)));
         $arg1 = trim($arg1, '(');
         $arg1 = str_replace('/e', '/', $arg1);
         $arg3 = '// WARNING: preg_replace() "/e" modifier 
                   . 'has been removed from PHP 7'
                   . PHP_EOL
                   . $match[1]
                   . 'preg_replace_callback('
                   . $arg1
                   . 'function ($m) { return ' 
                   .    str_replace('$1','$m', $match[1]) 
                   .      trim($arg2, '"\'') . '; }, '
                   .      trim($last, ',');
         return str_replace('$1', '$m', $arg3);
    },

        // end array
        ],

        // this is the target of the transformations
        $contents
    );
    // return the result as a string
    return implode('', $result);
}

工作原理...

要使用转换器,请从命令行运行以下代码。您需要提供要作为参数扫描的 PHP 5 代码的文件名。

这段代码块chap_01_php5_to_php7_code_converter.php,从命令行运行,调用转换器:

<?php
// get filename to scan from command line
$filename = $argv[1] ?? '';

if (!$filename) {
    echo 'No filename provided' . PHP_EOL;
    echo 'Usage: ' . PHP_EOL;
    echo __FILE__ . ' <filename>' . PHP_EOL;
    exit;
}

// setup class autoloading
require __DIR__ . '/../Application/Autoload/Loader.php';

// add current directory to the path
Application\Autoload\Loader::init(__DIR__ . '/..');

// get "deep scan" class
$convert = new Application\Parse\Convert();
echo $convert->scan($filename);
echo PHP_EOL;

另请参阅

有关不兼容的更多信息,请参考php.net/manual/en/migration70.incompatible.php

第二章:使用 PHP 7 高性能特性

在本章中,我们将讨论并了解 PHP 5 和 PHP 7 之间的语法差异,包括以下内容:

  • 理解抽象语法树

  • 理解解析中的差异

  • 理解foreach()处理中的差异

  • 使用 PHP 7 增强功能提高性能

  • 遍历大型文件

  • 将电子表格上传到数据库

  • 递归目录迭代器

介绍

在本章中,我们将直接进入 PHP 7,介绍利用新的高性能特性的配方。然而,我们将首先介绍一系列较小的配方,以说明 PHP 7 处理参数解析、语法、foreach()循环和其他增强功能的差异。在深入探讨本章内容之前,让我们讨论一些 PHP 5 和 PHP 7 之间的基本差异。

PHP 7 引入了一个新的层,称为抽象语法树AST),它有效地将解析过程与伪编译过程分离。尽管新层对性能几乎没有影响,但它赋予了语言一种新的语法统一性,这在以前是不可能的。

AST 的另一个好处是取消引用的过程。取消引用简单地指的是立即从对象中获取属性或运行方法,立即访问数组元素,并立即执行回调的能力。在 PHP 5 中,这种支持是不一致和不完整的。例如,要执行回调,通常需要先将回调或匿名函数赋值给一个变量,然后执行它。在 PHP 7 中,你可以立即执行它。

理解抽象语法树

作为开发人员,你可能会对摆脱 PHP 5 及更早版本中施加的某些语法限制感兴趣。除了之前提到的语法的统一性外,你将看到语法最大的改进是能够调用任何返回值,只需在后面添加一组额外的括号。此外,当返回值是数组时,你将能够直接访问任何数组元素。

如何做...

  1. 任何返回回调的函数或方法都可以通过简单地添加括号()(带或不带参数)立即执行。任何返回数组的函数或方法都可以通过使用方括号[]指示元素来立即取消引用。在下面显示的简短(但琐碎)示例中,函数test()返回一个数组。数组包含六个匿名函数。$a的值为$t$$a被解释为$test
function test()
{
    return [
        1 => function () { return [
            1 => function ($a) { return 'Level 1/1:' . ++$a; },
            2 => function ($a) { return 'Level 1/2:' . ++$a; },
        ];},
        2 => function () { return [
            1 => function ($a) { return 'Level 2/1:' . ++$a; },
            2 => function ($a) { return 'Level 2/2:' . ++$a; },
        ];}
    ];
}

$a = 't';
$t = 'test';
echo $$a()[1]()2;
  1. AST 允许我们发出echo $$a()[1]()2命令。这是从左到右解析的,执行如下:
  • $$a()被解释为test(),返回一个数组

  • [1]取消引用数组元素1,返回一个回调

  • ()执行此回调,返回一个包含两个元素的数组

  • [2]取消引用数组元素2,返回一个回调

  • (100)执行此回调,提供值100,返回Level 1/2:101

提示

在 PHP 5 中不可能有这样的语句:会返回解析错误。

  1. 以下是一个更加实质性的例子,利用 AST 语法来定义数据过滤和验证类。首先,我们定义Application\Web\Securityclass。在构造函数中,我们构建并定义了两个数组。第一个数组由过滤回调组成。第二个数组有验证回调:
public function __construct()
  {
    $this->filter = [
      'striptags' => function ($a) { return strip_tags($a); },
      'digits'    => function ($a) { return preg_replace(
      '/[⁰-9]/', '', $a); },
      'alpha'     => function ($a) { return preg_replace(
      '/[^A-Z]/i', '', $a); }
    ];
    $this->validate = [
      'alnum'  => function ($a) { return ctype_alnum($a); },
      'digits' => function ($a) { return ctype_digit($a); },
      'alpha'  => function ($a) { return ctype_alpha($a); }
    ];
  }
  1. 我们希望能以开发人员友好的方式调用此功能。因此,如果我们想要过滤数字,那么运行这样的命令将是理想的:
$security->filterDigits($item));
  1. 为了实现这一点,我们定义了魔术方法__call(),它使我们能够访问不存在的方法:
public function __call($method, $params)
{

  preg_match('/^(filter|validate)(.*?)$/i', $method, $matches);
  $prefix   = $matches[1] ?? '';
  $function = strtolower($matches[2] ?? '');
  if ($prefix && $function) {
    return $this->$prefix$function;
  }
  return $value;
}

我们使用preg_match()来匹配$method参数与filtervalidate。然后,第二个子匹配将被转换为$this->filter$this->validate中的数组键。如果两个子模式都产生子匹配,我们将第一个子匹配分配给$prefix,将第二个子匹配分配给$function。这些最终成为执行适当回调时的变量参数。

提示

不要对这些东西太疯狂!

当您沉浸在 AST 所带来的新的表达自由中时,请务必记住,您最终编写的代码可能会变得极其晦涩。这最终将导致长期的维护问题。

它是如何工作的...

首先,我们创建一个示例文件,chap_02_web_filtering_ast_example.php,以利用第一章中定义的自动加载类,构建基础,以获得Application\Web\Security的实例:

require __DIR__ . '/../Application/Autoload/Loader.php';
Application\Autoload\Loader::init(__DIR__ . '/..');
$security = new Application\Web\Security();

接下来,我们定义一个测试数据块:

$data = [
    '<ul><li>Lots</li><li>of</li><li>Tags</li></ul>',
    12345,
    'This is a string',
    'String with number 12345',
];

最后,我们为每个测试数据项调用每个过滤器和验证器:

foreach ($data as $item) {
  echo 'ORIGINAL: ' . $item . PHP_EOL;
  echo 'FILTERING' . PHP_EOL;
  printf('%12s : %s' . PHP_EOL,'Strip Tags', $security->filterStripTags($item));
  printf('%12s : %s' . PHP_EOL, 'Digits', $security->filterDigits($item));
  printf('%12s : %s' . PHP_EOL, 'Alpha', $security->filterAlpha($item));

  echo 'VALIDATORS' . PHP_EOL;
  printf('%12s : %s' . PHP_EOL, 'Alnum',  
  ($security->validateAlnum($item))  ? 'T' : 'F');
  printf('%12s : %s' . PHP_EOL, 'Digits', 
  ($security->validateDigits($item)) ? 'T' : 'F');
  printf('%12s : %s' . PHP_EOL, 'Alpha',  
  ($security->validateAlpha($item))  ? 'T' : 'F');
}

以下是一些输入字符串的输出:

它是如何工作的...

另请参阅

有关 AST 的更多信息,请参阅涉及抽象语法树的 RFC,可以在wiki.php.net/rfc/abstract_syntax_tree上查看。

了解解析的差异

在 PHP 5 中,赋值操作的右侧表达式是从右到左解析的。在 PHP 7 中,解析是一致的从左到右

如何做...

  1. 变量变量是间接引用值的一种方式。在下面的例子中,首先$$foo被解释为${$bar}。因此最终的返回值是$bar的值,而不是$foo的直接值(应该是bar):
$foo = 'bar';
$bar = 'baz';
echo $$foo; // returns  'baz'; 
  1. 在下一个例子中,我们有一个变量变量$$foo,它引用一个具有bar 键baz 子键的多维数组:
$foo = 'bar';
$bar = ['bar' => ['baz' => 'bat']];
// returns 'bat'
echo $$foo['bar']['baz'];
  1. 在 PHP 5 中,解析是从右到左进行的,这意味着 PHP 引擎将寻找一个$foo 数组,其中包含一个bar 键和一个baz 子键。然后,元素的返回值将被解释以获得最终值${$foo['bar']['baz']}

  2. 然而,在 PHP 7 中,解析是一致的,从左到右进行,这意味着首先解释($$foo)['bar']['baz']

  3. 在下一个示例中,您可以看到在 PHP 5 中$foo->$bar['bada']的解释与 PHP 7 相比有很大不同。在下面的例子中,PHP 5 首先会解释$bar['bada'],并将此返回值与$foo 对象实例进行引用。另一方面,在 PHP 7 中,解析是一致的,从左到右进行,这意味着首先解释$foo->$bar,并期望一个具有bada 元素的数组。顺便说一句,这个例子还使用了 PHP 7 的匿名类特性:

// PHP 5: $foo->{$bar['bada']}
// PHP 7: ($foo->$bar)['bada']
$bar = 'baz';
// $foo = new class 
{ 
    public $baz = ['bada' => 'boom']; 
};
// returns 'boom'
echo $foo->$bar['bada'];
  1. 最后一个示例与上面的示例相同,只是期望的返回值是一个回调,然后立即执行如下:
// PHP 5: $foo->{$bar['bada']}()
// PHP 7: ($foo->$bar)['bada']()
$bar = 'baz';
// NOTE: this example uses the new PHP 7 anonymous class feature
$foo = new class 
{ 
     public function __construct() 
    { 
        $this->baz = ['bada' => function () { return 'boom'; }]; 
    } 
};
// returns 'boom'
echo $foo->$bar['bada']();

它是如何工作的...

将 1 和 2 中的代码示例放入一个单独的 PHP 文件中,您可以将其命名为chap_02_understanding_diffs_in_parsing.php。首先使用 PHP 5 执行该脚本,您将注意到会产生一系列错误,如下所示:

它是如何工作的...

错误的原因是 PHP 5 解析不一致,并且对所请求的变量变量的状态得出了错误的结论(如前所述)。现在,您可以继续添加剩余的示例,如步骤 5 和 6 所示。然后,如果您在 PHP 7 中运行此脚本,将会出现所描述的结果,如下所示:

它是如何工作的...

另请参阅

有关解析的更多信息,请参阅涉及统一 变量语法的 RFC,可以在wiki.php.net/rfc/uniform_variable_syntax上查看。

理解 foreach()处理中的差异

在某些相对晦涩的情况下,foreach()循环内部代码的行为在 PHP 5 和 PHP 7 之间会有所不同。首先,有了大量的内部改进,这意味着在foreach()循环内部的处理在 PHP 7 下的速度会比在 PHP 5 下快得多。在 PHP 5 中注意到的问题包括在foreach()循环内部使用current()unset()对数组的操作。其他问题涉及通过引用传递值同时操作数组本身。

如何做...

  1. 考虑以下代码块:
$a = [1, 2, 3];
foreach ($a as $v) {
  printf("%2d\n", $v);
  unset($a[1]);
}
  1. 在 PHP 5 和 7 中,输出如下:
 1
 2
 3
  1. 然而,在循环之前添加一个赋值,行为会改变:
$a = [1, 2, 3];
$b = &$a;
foreach ($a as $v) {
  printf("%2d\n", $v);
  unset($a[1]);
}
  1. 比较 PHP 5 和 7 的输出:
PHP 5 PHP 7
1****3 123
  1. 处理引用内部数组指针的函数在 PHP 5 中也导致不一致的行为。看下面的代码示例:
$a = [1,2,3];
foreach($a as &$v) {
    printf("%2d - %2d\n", $v, current($a));
}

提示

每个数组都有一个指向其“当前”元素的内部指针,从1开始,“current()”返回数组中的当前元素。

  1. 请注意,在 PHP 7 中运行的输出是规范化和一致的:
PHP 5 PHP 7
1 - 22 - 33 - 0 1 - 12 - 13 - 1
  1. foreach()循环中添加一个新元素,一旦引用数组迭代完成,也在 PHP 5 中存在问题。这种行为在 PHP 7 中已经变得一致。以下代码示例演示了这一点:
$a = [1];
foreach($a as &$v) {
    printf("%2d -\n", $v);
    $a[1]=2;
}
  1. 我们将观察到以下输出:
PHP 5 PHP 7
1 - 1 -****2-
  1. 在 PHP 5 中解决的 PHP 7 中的另一个不良行为示例是通过引用遍历数组时,使用修改数组的函数,如array_push()array_pop()array_shift()array_unshift()

看看这个例子:

$a=[1,2,3,4];
foreach($a as &$v) {
    echo "$v\n";
    array_pop($a);
}
  1. 您将观察到以下输出:
PHP 5 PHP 7
121****1 1****2
  1. 最后,我们有一个情况,您正在通过引用遍历数组,并且有一个嵌套的foreach()循环,它本身也通过引用在相同的数组上进行迭代。在 PHP 5 中,这种结构根本不起作用。在 PHP 7 中,这个问题已经解决。以下代码块演示了这种行为:
$a = [0, 1, 2, 3];
foreach ($a as &$x) {
       foreach ($a as &$y) {
         echo "$x - $y\n";
         if ($x == 0 && $y == 1) {
           unset($a[1]);
           unset($a[2]);
         }
       }
}
  1. 以下是输出:
PHP 5 PHP 7
0 - 00 - 10 - 3 0 - 00 - 10 - 33 - 03 -3

它是如何工作的...

将这些代码示例添加到一个名为chap_02_foreach.php的单个 PHP 文件中。从命令行下在 PHP 5 下运行脚本。预期输出如下:

它是如何工作的...

在 PHP 7 下运行相同的脚本并注意差异:

它是如何工作的...

另请参阅

有关更多信息,请参阅解决此问题的 RFC,该 RFC 已被接受。可以在以下网址找到有关此 RFC 的介绍:wiki.php.net/rfc/php7_foreach

使用 PHP 7 增强性能

开发人员正在利用的一个趋势是使用匿名函数。处理匿名函数时的一个经典问题是以这样的方式编写它们,以便任何对象都可以绑定到$this,并且函数仍然可以工作。PHP 5 代码中使用的方法是使用bindTo()。在 PHP 7 中,添加了一个新方法call(),它提供了类似的功能,但性能大大提高。

如何做...

为了利用call(),在一个漫长的循环中执行一个匿名函数。在这个例子中,我们将演示一个匿名函数,它通过扫描日志文件,识别按出现频率排序的 IP 地址:

  1. 首先,我们定义一个Application\Web\Access类。在构造函数中,我们接受一个文件名作为参数。日志文件被打开为SplFileObject并分配给$this->log
Namespace Application\Web;

use Exception;
use SplFileObject;
class Access
{
  const ERROR_UNABLE = 'ERROR: unable to open file';
  protected $log;
  public $frequency = array();
  public function __construct($filename)
  {
    if (!file_exists($filename)) {
      $message = __METHOD__ . ' : ' . self::ERROR_UNABLE . PHP_EOL;
      $message .= strip_tags($filename) . PHP_EOL;
      throw new Exception($message);
    }
    $this->log = new SplFileObject($filename, 'r');
  }
  1. 接下来,我们定义一个遍历文件的生成器,逐行进行迭代:
public function fileIteratorByLine()
{
  $count = 0;
  while (!$this->log->eof()) {
    yield $this->log->fgets();
    $count++;
  }
  return $count;
}
  1. 最后,我们定义一个方法,查找并提取 IP 地址作为子匹配:
public function getIp($line)
{
  preg_match('/(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})/', $line, $match);
  return $match[1] ?? '';
  }
}

它是如何工作的...

首先,我们定义一个调用程序chap_02_performance_using_php7_enchancement_call.php,利用第一章中定义的自动加载类,建立基础,来获取Application\Web\Access的实例:

define('LOG_FILES', '/var/log/apache2/*access*.log');
require __DIR__ . '/../Application/Autoload/Loader.php';
Application\Autoload\Loader::init(__DIR__ . '/..');

接下来我们定义匿名函数,它处理日志文件中的一行。如果检测到 IP 地址,它将成为$frequency 数组中的一个键,并且增加这个键的当前值。

// define functions
$freq = function ($line) {
  $ip = $this->getIp($line);
  if ($ip) {
    echo '.';
    $this->frequency[$ip] = 
    (isset($this->frequency[$ip])) ? $this->frequency[$ip] + 1 : 1;
  }
};

然后我们循环遍历每个找到的日志文件中的行迭代,处理 IP 地址:

foreach (glob(LOG_FILES) as $filename) {
  echo PHP_EOL . $filename . PHP_EOL;
  // access class
  $access = new Application\Web\Access($filename);
  foreach ($access->fileIteratorByLine() as $line) {
    $freq->call($access, $line);
  }
}

提示

实际上你也可以在 PHP 5 中做同样的事情。但是需要两行代码:

$func = $freq->bindTo($access);
$func($line);

在 PHP 7 中,使用call()的性能比较使用call()慢 20%到 50%。

最后,我们对数组进行逆向排序,但保持键。输出是在一个简单的foreach()循环中产生的:

arsort($access->frequency);
foreach ($access->frequency as $key => $value) {
  printf('%16s : %6d' . PHP_EOL, $key, $value);
}

输出将根据你处理的access.log文件而有所不同。这里是一个示例:

它是如何工作的...

还有更多...

许多 PHP 7 性能改进与新功能和函数无关。相反,它们采取了内部改进的形式,直到你开始运行程序之前都是不可见的。以下是属于这一类别的改进的简短列表:

功能 更多信息: 注释
快速参数解析 wiki.php.net/rfc/fast_zpp 在 PHP 5 中,提供给函数的参数必须为每个函数调用进行解析。参数以字符串形式传递,并以类似于scanf()函数的方式进行解析。在 PHP 7 中,这个过程已经被优化,变得更加高效,导致了显著的性能提升。这种改进很难衡量,但似乎在 6%左右。
PHP NG wiki.php.net/rfc/phpng PHP NGNext Generation)计划代表了对大部分 PHP 语言的重写。它保留了现有功能,但涉及了所有可能的时间节省和效率措施。数据结构已经被压缩,内存利用更加高效。例如,只有一个改变影响了数组处理,导致了显著的性能提升,同时大大减少了内存使用。
去除死板 wiki.php.net/rfc/removal_of_dead_sapis_and_exts 大约有二十多个扩展属于以下类别之一:已弃用、不再维护、未维护的依赖项,或者未移植到 PHP 7。核心开发人员组的投票决定移除“短列表”上约 2/3 的扩展。这将减少开销,并加快 PHP 语言的未来整体发展速度。

迭代处理大文件

诸如file_get_contents()file()之类的函数使用起来快速简单,但由于内存限制,它们在处理大文件时很快会出现问题。php.inimemory_limit设置的默认值为 128 兆字节。因此,任何大于这个值的文件都不会被加载。

在解析大文件时的另一个考虑是,你的函数或类方法产生输出的速度有多快?例如,在产生用户输出时,尽管一开始累积输出到一个数组中似乎更好。然后一次性输出以提高效率。不幸的是,这可能会对用户体验产生不利影响。也许更好的方法是创建一个生成器,并使用yield 关键字产生即时结果。

如何做...

如前所述,file*函数(即file_get_contents())不适用于大文件。简单的原因是这些函数在某一点上会将整个文件内容表示在内存中。因此,本示例的重点将放在f*函数(即fopen())上。

然而,有点不同的是,我们不直接使用f*函数,而是使用SPL标准 PHP 库)中包含的SplFileObject类:

  1. 首先,我们定义了一个Application\Iterator\LargeFile类,具有适当的属性和常量:
namespace Application\Iterator;

use Exception;
use InvalidArgumentException;
use SplFileObject;
use NoRewindIterator;

class LargeFile
{
  const ERROR_UNABLE = 'ERROR: Unable to open file';
  const ERROR_TYPE   = 'ERROR: Type must be "ByLength", "ByLine" or "Csv"';     
  protected $file;
  protected $allowedTypes = ['ByLine', 'ByLength', 'Csv'];
  1. 然后我们定义了一个__construct()方法,接受文件名作为参数,并用SplFileObject实例填充$file属性。如果文件不存在,这也是抛出异常的好地方:
public function __construct($filename, $mode = 'r')
{
  if (!file_exists($filename)) {
    $message = __METHOD__ . ' : ' . self::ERROR_UNABLE . PHP_EOL;
    $message .= strip_tags($filename) . PHP_EOL;
    throw new Exception($message);
  }
  $this->file = new SplFileObject($filename, $mode);
}
  1. 接下来我们定义了一个fileIteratorByLine()method方法,该方法使用fgets()逐行读取文件。创建一个类似的fileIteratorByLength()方法,但使用fread()来实现也是个不错的主意。使用fgets()的方法适用于包含换行符的文本文件。另一个方法可以用于解析大型二进制文件:
protected function fileIteratorByLine()
{
  $count = 0;
  while (!$this->file->eof()) {
    yield $this->file->fgets();
    $count++;
  }
  return $count;
}

protected function fileIteratorByLength($numBytes = 1024)
{
  $count = 0;
  while (!$this->file->eof()) {
    yield $this->file->fread($numBytes);
    $count++;
  }
  return $count; 
}
  1. 最后,我们定义了一个getIterator()方法,返回一个NoRewindIterator()实例。该方法接受ByLineByLength作为参数,这两个参数是指前一步骤中定义的两种方法。该方法还需要接受$numBytes,以防调用ByLength。我们需要一个NoRewindIterator()实例的原因是强制在这个例子中只能单向读取文件:
public function getIterator($type = 'ByLine', $numBytes = NULL)
{
  if(!in_array($type, $this->allowedTypes)) {
    $message = __METHOD__ . ' : ' . self::ERROR_TYPE . PHP_EOL;
    throw new InvalidArgumentException($message);
  }
  $iterator = 'fileIterator' . $type;
  return new NoRewindIterator($this->$iterator($numBytes));
}

它是如何工作的...

首先,我们利用第一章中定义的自动加载类,在调用程序chap_02_iterating_through_a_massive_file.php中获取Application\Iterator\LargeFile的实例:

define('MASSIVE_FILE', '/../data/files/war_and_peace.txt');
require __DIR__ . '/../Application/Autoload/Loader.php';
Application\Autoload\Loader::init(__DIR__ . '/..');

接下来,在try {...} catch () {...}块中,我们获取一个ByLine迭代器的实例:

try {
  $largeFile = new Application\Iterator\LargeFile(__DIR__ . MASSIVE_FILE);
  $iterator = $largeFile->getIterator('ByLine');

然后我们提供了一个有用的示例,即定义每行的平均单词数:

$words = 0;
foreach ($iterator as $line) {
  echo $line;
  $words += str_word_count($line);
}
echo str_repeat('-', 52) . PHP_EOL;
printf("%-40s : %8d\n", 'Total Words', $words);
printf("%-40s : %8d\n", 'Average Words Per Line', 
($words / $iterator->getReturn()));
echo str_repeat('-', 52) . PHP_EOL;

然后我们结束catch块:

} catch (Throwable $e) {
  echo $e->getMessage();
}

预期输出(太大无法在此显示!)显示了《战争与和平》古腾堡版本中有 566,095 个单词。此外,我们发现每行的平均单词数为八个。

将电子表格上传到数据库

虽然 PHP 没有直接读取特定电子表格格式(如 XLSX、ODS 等)的能力,但它可以读取(CSV 逗号分隔值)文件。因此,为了处理客户的电子表格,您需要要求他们以 CSV 格式提供文件,或者您需要自行进行转换。

准备就绪...

将电子表格(即 CSV 文件)上传到数据库时,有三个主要考虑因素:

  • 遍历(可能)庞大的文件

  • 将每个电子表格行提取为 PHP 数组

  • 将 PHP 数组插入数据库

庞大文件的迭代将使用前面的方法处理。我们将使用fgetcsv()函数将 CSV 行转换为 PHP 数组。最后,我们将使用(PDO PHP 数据对象)类建立数据库连接并执行插入操作。

如何做...

  1. 首先,我们定义了一个Application\Database\Connection类,该类根据构造函数提供的一组参数创建一个 PDO 实例:
<?php
  namespace Application\Database;

  use Exception;
  use PDO;

  class Connection
  { 
    const ERROR_UNABLE = 'ERROR: Unable to create database connection';    
    public $pdo;

    public function __construct(array $config)
    {
      if (!isset($config['driver'])) {
        $message = __METHOD__ . ' : ' . self::ERROR_UNABLE . PHP_EOL;
        throw new Exception($message);
    }
    $dsn = $config['driver'] 
    . ':host=' . $config['host'] 
    . ';dbname=' . $config['dbname'];
    try {
      $this->pdo = new PDO($dsn, 
      $config['user'], 
      $config['password'], 
      [PDO::ATTR_ERRMODE => $config['errmode']]);
    } catch (PDOException $e) {
      error_log($e->getMessage());
    }
  }

}
  1. 然后我们加入了一个Application\Iterator\LargeFile的实例。我们为这个类添加了一个新的方法,用于遍历 CSV 文件:
protected function fileIteratorCsv()
{
  $count = 0;
  while (!$this->file->eof()) {
    yield $this->file->fgetcsv();
    $count++;
  }
  return $count;        
}    
  1. 我们还需要将Csv添加到允许的迭代器方法列表中:
  const ERROR_UNABLE = 'ERROR: Unable to open file';
  const ERROR_TYPE   = 'ERROR: Type must be "ByLength", "ByLine" or "Csv"';

  protected $file;
  protected $allowedTypes = ['ByLine', 'ByLength', 'Csv'];

它是如何工作的...

首先我们定义一个配置文件,/path/to/source/config/db.config.php,其中包含数据库连接参数:

<?php
return [
  'driver'   => 'mysql',
  'host'     => 'localhost',
  'dbname'   => 'php7cookbook',
  'user'     => 'cook',
  'password' => 'book',
  'errmode'  => PDO::ERRMODE_EXCEPTION,
];

接下来,我们利用第一章中定义的自动加载类,建立基础,来获得Application\Database\ConnectionApplication\Iterator\LargeFile的实例,定义一个调用程序chap_02_uploading_csv_to_database.php

define('DB_CONFIG_FILE', '/../data/config/db.config.php');
define('CSV_FILE', '/../data/files/prospects.csv');
require __DIR__ . '/../../Application/Autoload/Loader.php';
Application\Autoload\Loader::init(__DIR__ . '/..');

之后,我们设置了一个try {...} catch () {...}块,其中捕获了Throwable。这使我们能够同时捕获异常和错误:

try {
  // code goes here  
} catch (Throwable $e) {
  echo $e->getMessage();
}

try {...} catch () {...}块中,我们获得连接和大文件迭代器类的实例:

$connection = new Application\Database\Connection(
include __DIR__ . DB_CONFIG_FILE);
$iterator  = (new Application\Iterator\LargeFile(__DIR__ . CSV_FILE))
->getIterator('Csv');

然后我们利用 PDO 准备/执行功能。准备好的语句的 SQL 使用?来表示在循环中提供的值:

$sql = 'INSERT INTO `prospects` '
  . '(`id`,`first_name`,`last_name`,`address`,`city`,`state_province`,'
  . '`postal_code`,`phone`,`country`,`email`,`status`,`budget`,`last_updated`) '
  . ' VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)';
$statement = $connection->pdo->prepare($sql);

然后我们使用foreach()来循环遍历文件迭代器。每个yield语句产生一个值数组,表示数据库中的一行。然后我们可以使用这些值与PDOStatement::execute()一起使用,将这些值的行插入到数据库中执行准备好的语句:

foreach ($iterator as $row) {
  echo implode(',', $row) . PHP_EOL;
  $statement->execute($row);
}

然后您可以检查数据库,以验证数据是否已成功插入。

递归目录迭代器

获取目录中文件的列表非常容易。传统上,开发人员使用glob()函数来实现这个目的。要从目录树中的特定点递归获取所有文件和目录的列表则更加棘手。这个方法利用了一个(SPL 标准 PHP 库)RecursiveDirectoryIterator,它将非常好地实现这个目的。

这个类的作用是解析目录树,找到第一个子目录,然后沿着分支继续,直到没有更多的子目录,然后停止!不幸的是,这不是我们想要的。我们需要以某种方式让RecursiveDirectoryIterator继续解析每棵树和分支,从给定的起点开始,直到没有更多的文件或目录。碰巧有一个奇妙的类RecursiveIteratorIterator,它正好可以做到这一点。通过将RecursiveDirectoryIterator包装在RecursiveIteratorIterator中,我们可以完成对任何目录树的完整遍历。

提示

警告!

非常小心地选择文件系统遍历的起点。如果您从根目录开始,您可能会导致服务器崩溃,因为递归过程将一直持续,直到找到所有文件和目录!

如何做...

  1. 首先,我们定义了一个Application\Iterator\Directory类,该类定义了适当的属性和常量,并使用外部类:
namespace Application\Iterator;

use Exception;
use RecursiveDirectoryIterator;
use RecursiveIteratorIterator;
use RecursiveRegexIterator;
use RegexIterator;

class Directory
{

  const ERROR_UNABLE = 'ERROR: Unable to read directory';

  protected $path;
  protected $rdi;
  // recursive directory iterator
  1. 构造函数基于目录路径创建了一个RecursiveDirectoryIterator实例,该实例位于RecursiveIteratorIterator内部:
public function __construct($path)
{
  try {
    $this->rdi = new RecursiveIteratorIterator(
      new RecursiveDirectoryIterator($path),
      RecursiveIteratorIterator::SELF_FIRST);
  } catch (\Throwable $e) {
    $message = __METHOD__ . ' : ' . self::ERROR_UNABLE . PHP_EOL;
    $message .= strip_tags($path) . PHP_EOL;
    echo $message;
    exit;
  }
}
  1. 接下来,我们决定如何处理迭代。一种可能性是模仿 Linux 的ls -l -R命令的输出。请注意,我们使用了yield关键字,有效地将此方法转换为生成器,然后可以从外部调用。目录迭代产生的每个对象都是一个 SPL FileInfo对象,它可以为我们提供有关文件的有用信息。这个方法可能是这样的:
public function ls($pattern = NULL)
{
  $outerIterator = ($pattern) 
  ? $this->regex($this->rdi, $pattern) 
  : $this->rdi;
  foreach($outerIterator as $obj){
    if ($obj->isDir()) {
      if ($obj->getFileName() == '..') {
        continue;
      }
      $line = $obj->getPath() . PHP_EOL;
    } else {
      $line = sprintf('%4s %1d %4s %4s %10d %12s %-40s' . PHP_EOL,
      substr(sprintf('%o', $obj->getPerms()), -4),
      ($obj->getType() == 'file') ? 1 : 2,
      $obj->getOwner(),
      $obj->getGroup(),
      $obj->getSize(),
      date('M d Y H:i', $obj->getATime()),
      $obj->getFileName());
    }
    yield $line;
  }
}
  1. 您可能已经注意到,方法调用包括文件模式。我们需要一种方法来过滤递归,只包括匹配的文件。SPL 中还有另一个迭代器完全适合这个需求:RegexIterator类:
protected function regex($iterator, $pattern)
{
  $pattern = '!^.' . str_replace('.', '\\.', $pattern) . '$!';
  return new RegexIterator($iterator, $pattern);
}
  1. 最后,这是另一种方法,但这次我们将模仿dir /s命令:
public function dir($pattern = NULL)
{
  $outerIterator = ($pattern) 
  ? $this->regex($this->rdi, $pattern) 
  : $this->rdi;
  foreach($outerIterator as $name => $obj){
      yield $name . PHP_EOL;
    }        
  }
}

工作原理...

首先,我们利用第一章中定义的自动加载类,建立基础,来获得Application\Iterator\Directory的实例,定义一个调用程序chap_02_recursive_directory_iterator.php

define('EXAMPLE_PATH', realpath(__DIR__ . '/../'));
require __DIR__ . '/../Application/Autoload/Loader.php';
Application\Autoload\Loader::init(__DIR__ . '/..');
$directory = new Application\Iterator\Directory(EXAMPLE_PATH);

然后,在try {...} catch () {...}块中,我们调用了我们的两个方法,使用一个示例目录路径:

try {
  echo 'Mimics "ls -l -R" ' . PHP_EOL;
  foreach ($directory->ls('*.php') as $info) {
    echo $info;
  }

  echo 'Mimics "dir /s" ' . PHP_EOL;
  foreach ($directory->dir('*.php') as $info) {
    echo $info;
  }

} catch (Throwable $e) {
  echo $e->getMessage();
}

ls()的输出将如下所示:

工作原理...

dir()的输出将如下所示:

工作原理...

第三章:使用 PHP 函数式编程

在本章中,我们将涵盖以下主题:

  • 开发函数

  • 数据类型提示

  • 使用返回值数据类型

  • 使用迭代器

  • 使用生成器编写自己的迭代器

介绍

在本章中,我们将考虑利用 PHP 的函数式编程能力的方法。函数式或过程式编程是在 PHP 版本 4 引入面向对象编程OOP)之前编写 PHP 代码的传统方式。函数式编程是将程序逻辑封装到一系列离散的函数中,这些函数通常存储在单独的 PHP 文件中。然后可以在任何未来的脚本中包含此文件,从而允许随意调用定义的函数。

开发函数

最困难的部分是决定如何将编程逻辑分解为函数。另一方面,在 PHP 中开发函数的机制非常简单。只需使用function关键字,给它一个名称,然后跟着括号。

如何做...

  1. 代码本身放在大括号中,如下所示:
function someName ($parameter)
{ 
  $result = 'INIT';
  // one or more statements which do something
  // to affect $result
  $result .= ' and also ' . $parameter;
  return $result; 
}
  1. 您可以定义一个或多个参数。要使其中一个参数变为可选,只需分配一个默认值。如果不确定要分配什么默认值,请使用NULL
function someOtherName ($requiredParam, $optionalParam = NULL)
  { 
    $result = 0;
    $result += $requiredParam;
    $result += $optionalParam ?? 0;
    return $result; 
  }

注意

您不能重新定义函数。唯一的例外是在不同的命名空间中定义重复的函数。这个定义会生成一个错误:

function someTest()
{
  return 'TEST';
}
function someTest($a)
{
  return 'TEST:' . $a;
}
  1. 如果不知道将向函数提供多少参数,或者想要允许无限数量的参数,请使用...后跟一个变量名。提供的所有参数将出现在变量中的数组中:
function someInfinite(...$params)
{
  // any params passed go into an array $params
  return var_export($params, TRUE);
}
  1. 函数可以调用自身。这被称为递归。以下函数执行递归目录扫描:
function someDirScan($dir)
{
  // uses "static" to retain value of $list
  static $list = array();
  // get a list of files and directories for this path
  $list = glob($dir . DIRECTORY_SEPARATOR . '*');
  // loop through
  foreach ($list as $item) {
    if (is_dir($item)) {
      $list = array_merge($list, someDirScan($item));
    }
  }
  return $list;
}

注意

在函数内使用static关键字已经有 12 年以上的历史了。static的作用是在函数调用之间保留变量的值。

如果需要在 HTTP 请求之间保留变量的值,请确保已启动 PHP 会话并将值存储在$_SESSION中。

  1. 在 PHP 命名空间中定义函数时受到限制。这个特性可以用来为函数库之间提供额外的逻辑分离。为了锚定命名空间,您需要添加use关键字。以下示例放置在单独的命名空间中。请注意,即使函数名称相同,它们也不会发生冲突,因为它们彼此之间不可见。

  2. 我们在命名空间Alpha中定义了someFunction()。我们将其保存到一个单独的 PHP 文件chap_03_developing_functions_namespace_alpha.php中:

<?php
namespace Alpha;

function someFunction()
{
  echo __NAMESPACE__ . ':' . __FUNCTION__ . PHP_EOL;
}
  1. 然后我们在命名空间Beta中定义了someFunction()。我们将其保存到一个单独的 PHP 文件chap_03_developing_functions_namespace_beta.php中:
<?php
namespace Beta;

function someFunction()
{
  echo __NAMESPACE__ . ':' . __FUNCTION__ . PHP_EOL;
}
  1. 然后我们可以通过在函数名前加上命名空间名称来调用someFunction()
include (__DIR__ . DIRECTORY_SEPARATOR 
         . 'chap_03_developing_functions_namespace_alpha.php');
include (__DIR__ . DIRECTORY_SEPARATOR 
         . 'chap_03_developing_functions_namespace_beta.php');
      echo Alpha\someFunction();
      echo Beta\someFunction();

提示

最佳实践

最佳实践是将函数库(以及类!)放入单独的文件中:一个命名空间一个文件,一个类或函数库一个文件。

可以在单个命名空间中定义许多类或函数库。将开发到单独的命名空间的唯一原因是如果要促进功能的逻辑分离。

它是如何工作的...

最佳实践是将所有逻辑相关的函数放入一个单独的 PHP 文件中。创建一个名为chap_03_developing_functions_library.php的文件,并将这些函数(前面描述的)放入其中。

  • someName()

  • someOtherName()

  • someInfinite()

  • someDirScan()

  • someTypeHint()

然后将此文件包含在使用这些函数的代码中。

include (__DIR__ . DIRECTORY_SEPARATOR . 'chap_03_developing_functions_library.php');

要调用someName()函数,请使用名称并提供参数。

echo someName('TEST');   // returns "INIT and also TEST"

你可以像这样调用someOtherName()函数使用一个或两个参数:

echo someOtherName(1);    // returns  1
echo someOtherName(1, 1);   //  returns 2

someInfinite()函数接受无限(或可变)数量的参数。以下是调用这个函数的一些例子:

echo someInfinite(1, 2, 3);
echo PHP_EOL;
echo someInfinite(22.22, 'A', ['a' => 1, 'b' => 2]);

输出如下:

它是如何工作的...

我们可以这样调用someDirScan()

echo someInfinite(1, 2, 3);
echo PHP_EOL;
echo someInfinite(22.22, 'A', ['a' => 1, 'b' => 2]);

输出如下:

它是如何工作的...

数据类型提示

在开发函数时,许多情况下你可能会在其他项目中重用相同的函数库。此外,如果你与团队合作,你的代码可能会被其他开发人员使用。为了控制你的代码的使用,使用类型提示可能是合适的。这涉及到指定函数对于特定参数期望的数据类型。

如何做...

  1. 函数中的参数可以加上类型提示。以下类型提示在 PHP 5 和 PHP 7 中都可用:
  • 数组

  • 可调用的

  1. 如果调用函数,并传递了错误的参数类型,将抛出TypeError。以下示例需要一个数组、一个DateTime的实例和一个匿名函数:
function someTypeHint(Array $a, DateTime $t, Callable $c)
{
  $message = '';
  $message .= 'Array Count: ' . count($a) . PHP_EOL;
  $message .= 'Date: ' . $t->format('Y-m-d') . PHP_EOL;
  $message .= 'Callable Return: ' . $c() . PHP_EOL;
  return $message;
}

提示

你不必为每个参数提供类型提示。只有在提供不同的数据类型会对函数处理产生负面影响时才使用这种技术。例如,如果你的函数使用foreach()循环,如果你没有提供一个数组,或者实现了Traversable的东西,就会产生一个错误。

  1. 在 PHP 7 中,假设适当的declare()指令已经被声明,标量(即整数、浮点数、布尔值和字符串)类型提示是允许的。另一个函数演示了如何实现这一点。在包含你希望使用标量类型提示的函数的代码库文件的顶部,在开头的 PHP 标记之后添加这个declare()指令:
declare(strict_types=1);
  1. 现在你可以定义一个包含标量类型提示的函数:
function someScalarHint(bool $b, int $i, float $f, string $s)
{
  return sprintf("\n%20s : %5s\n%20s : %5d\n%20s " . 
                 ": %5.2f\n%20s : %20s\n\n",
                 'Boolean', ($b ? 'TRUE' : 'FALSE'),
                 'Integer', $i,
                 'Float',   $f,
                 'String',  $s);
}
  1. 在 PHP 7 中,假设已经声明了严格的类型提示,布尔类型提示与其他三种标量类型(即整数、浮点数和字符串)有些不同。你可以提供任何标量作为参数,不会抛出TypeError!然而,一旦传递到函数中,传入的值将自动转换为布尔数据类型。如果传递的数据类型不是标量(即数组或对象),将抛出TypeError。这是一个定义boolean数据类型的函数的例子。请注意,返回值将自动转换为boolean
function someBoolHint(bool $b)
{
  return $b;
}

它是如何工作的...

首先,你可以将someTypeHint()someScalarHint()someBoolHint()这三个函数放在一个单独的文件中以供包含。在这个例子中,我们将文件命名为chap_03_developing_functions_type_hints_library.php。不要忘记在顶部添加declare(strict_types=1)

在我们的调用代码中,你需要包含这个文件:

include (__DIR__ . DIRECTORY_SEPARATOR . 'chap_03_developing_functions_type_hints_library.php');

要测试someTypeHint(),调用函数两次,一次使用正确的数据类型,第二次使用不正确的类型。这将抛出一个TypeError,因此你需要将函数调用包装在try { ... } catch () { ...}块中:

try {
    $callable = function () { return 'Callback Return'; };
    echo someTypeHint([1,2,3], new DateTime(), $callable);
    echo someTypeHint('A', 'B', 'C');
} catch (TypeError $e) {
    echo $e->getMessage();
    echo PHP_EOL;
}

从这个子部分末尾显示的输出中可以看出,当传递正确的数据类型时没有问题。当传递不正确的类型时,将抛出TypeError

注意

在 PHP 7 中,某些错误已经转换为Error类,这与Exception的处理方式有些相似。这意味着你可以捕获ErrorTypeErrorError的一个特定子类,当向函数传递不正确的数据类型时抛出。

所有 PHP 7 的Error类都实现了Throwable接口,Exception类也是如此。如果你不确定是否需要捕获Error还是Exception,你可以添加一个捕获Throwable的块。

接下来,您可以测试someScalarHint(),用正确和不正确的值调用它,将调用包装在try { ... } catch () { ...}块中:

try {
    echo someScalarHint(TRUE, 11, 22.22, 'This is a string');
    echo someScalarHint('A', 'B', 'C', 'D');
} catch (TypeError $e) {
    echo $e->getMessage();
}

如预期的那样,对该函数的第一次调用有效,而第二次调用会抛出TypeError

当对布尔值进行类型提示时,传递的任何标量值都不会导致抛出TypeError!相反,该值将被解释为其布尔等价值。如果随后返回此值,则数据类型将更改为布尔值。

要测试这一点,调用之前定义的someBoolHint()函数,并将任何标量值作为参数传入。var_dump()方法显示数据类型始终是布尔值:

try {
    // positive results
    $b = someBooleanHint(TRUE);
    $i = someBooleanHint(11);
    $f = someBooleanHint(22.22);
    $s = someBooleanHint('X');
    var_dump($b, $i, $f, $s);
    // negative results
    $b = someBooleanHint(FALSE);
    $i = someBooleanHint(0);
    $f = someBooleanHint(0.0);
    $s = someBooleanHint('');
    var_dump($b, $i, $f, $s);
} catch (TypeError $e) {
    echo $e->getMessage();
}

如果您现在尝试相同的函数调用,但传入非标量数据类型,则会抛出TypeError

try {
    $a = someBoolHint([1,2,3]);
    var_dump($a);
} catch (TypeError $e) {
    echo $e->getMessage();
}
try {
    $o = someBoolHint(new stdClass());
    var_dump($o);
} catch (TypeError $e) {
    echo $e->getMessage();
}

这是整体输出:

它是如何工作的...

另请参阅

PHP 7.1 引入了一个新的类型提示iterable,它允许数组、IteratorsGenerators作为参数。有关更多信息,请参阅:

有关标量类型提示实现背后的原理的背景讨论,请参阅本文:

使用返回值数据类型

PHP 7 允许您为函数的返回值指定数据类型。然而,与标量类型提示不同,您不需要添加任何特殊声明。

如何做...

  1. 这个例子向您展示了如何为函数返回值分配数据类型。要分配返回数据类型,首先像通常一样定义函数。在右括号后面,加一个空格,然后是数据类型和一个冒号:
function returnsString(DateTime $date, $format) : string
{
  return $date->format($format);
}

注意

PHP 7.1 引入了一种称为可空类型的返回数据类型的变体。您需要做的就是将string更改为?string。这允许函数返回stringNULL

  1. 函数返回的任何东西,无论在函数内部的数据类型如何,都将被转换为声明的数据类型作为返回值。请注意,在这个例子中,将$a$b$c的值相加以产生一个单一的总和,然后返回。通常您会期望返回值是一个数字数据类型。然而,在这种情况下,返回数据类型被声明为string,这将覆盖 PHP 的类型转换过程:
function convertsToString($a, $b, $c) : string

  return $a + $b + $c;
}
  1. 您还可以将类分配为返回数据类型。在这个例子中,我们将返回类型分配为 PHP DateTime扩展的一部分的DateTime
function makesDateTime($year, $month, $day) : DateTime
{
  $date = new DateTime();
  $date->setDate($year, $month, $day);
  return $date;
}

注意

makesDateTime()函数将是标量类型提示的一个潜在候选。如果$year$month$day不是整数,在调用setDate()时会生成一个Warning。如果您使用标量类型提示,并且传递了错误的数据类型,将抛出TypeError。虽然生成警告或抛出TypeError并不重要,但至少TypeError会导致错误使用您的代码的开发人员警觉起来!

  1. 如果一个函数有一个返回数据类型,并且您在函数代码中返回了错误的数据类型,那么在运行时会抛出TypeError。这个函数分配了一个DateTime的返回类型,但返回了一个字符串。会抛出TypeError,但直到运行时,当 PHP 引擎检测到不一致时才会抛出:
function wrongDateTime($year, $month, $day) : DateTime
{
  return date($year . '-' . $month . '-' . $day);
}

注意

如果返回数据类型类不是内置的 PHP 类之一(即 SPL 的一部分),则需要确保已自动加载或包含该类。

它是如何工作的...

首先,将前面提到的函数放入名为chap_03_developing_functions_return_types_library.php的库文件中。这个文件需要包含在调用这些函数的chap_03_developing_functions_return_types.php脚本中:

include (__DIR__ . '/chap_03_developing_functions_return_types_library.php');

现在,您可以调用returnsString(),提供一个DateTime实例和一个格式字符串:

$date   = new DateTime();
$format = 'l, d M Y';
$now    = returnsString($date, $format);
echo $now . PHP_EOL;
var_dump($now);

如预期的那样,输出是一个字符串:

它是如何工作的...

现在您可以调用convertsToString()并提供三个整数作为参数。注意返回类型是字符串:

echo "\nconvertsToString()\n";
var_dump(convertsToString(2, 3, 4));

它是如何工作的...

为了证明这一点,您可以将一个类分配为返回值,使用三个整数参数调用makesDateTime()

echo "\nmakesDateTime()\n";
$d = makesDateTime(2015, 11, 21);
var_dump($d);

它是如何工作的...

最后,使用三个整数参数调用wrongDateTime()

try {
    $e = wrongDateTime(2015, 11, 21);
    var_dump($e);
} catch (TypeError $e) {
    echo $e->getMessage();
}

注意,在运行时抛出了TypeError

它是如何工作的...

还有更多...

PHP 7.1 添加了一个新的返回值类型,void。当您不希望从函数中返回任何值时使用。有关更多信息,请参阅wiki.php.net/rfc/void_return_type

另请参阅

有关返回类型声明的更多信息,请参阅以下文章:

有关可空类型的信息,请参阅本文:

使用迭代器

迭代器是一种特殊类型的类,允许您遍历一个容器或列表。关键词在于遍历。这意味着迭代器提供了浏览列表的方法,但它本身不执行遍历。

SPL 提供了丰富的通用和专门设计用于不同上下文的迭代器。例如,ArrayIterator被设计用于允许面向对象遍历数组。DirectoryIterator被设计用于文件系统扫描。

某些 SPL 迭代器被设计用于与其他迭代器一起工作,并增加价值。示例包括FilterIteratorLimitIterator。前者使您能够从父迭代器中删除不需要的值。后者提供了分页功能,您可以指定要遍历多少项以及确定从何处开始的偏移量。

最后,还有一系列递归迭代器,允许您重复调用父迭代器。一个例子是RecursiveDirectoryIterator,它从起始点扫描整个目录树,直到最后一个可能的子目录。

如何做...

  1. 我们首先检查ArrayIterator类。它非常容易使用。您只需要将数组作为参数提供给构造函数。之后,您可以使用所有基于 SPL 的迭代器标准的方法,例如current()next()等。
$iterator = new ArrayIterator($array);

注意

使用ArrayIterator将标准 PHP 数组转换为迭代器。在某种意义上,这提供了过程式编程和面向对象编程之间的桥梁。

  1. 作为迭代器的实际用途的一个例子,请查看这个例子。它接受一个迭代器并生成一系列 HTML<ul><li>标签:
function htmlList($iterator)
{
  $output = '<ul>';
  while ($value = $iterator->current()) {
    $output .= '<li>' . $value . '</li>';
    $iterator->next();
  }
  $output .= '</ul>';
  return $output;
}
  1. 或者,您可以简单地将ArrayIterator实例包装到一个简单的foreach()循环中:
function htmlList($iterator)
{
  $output = '<ul>';
  foreach($iterator as $value) {
    $output .= '<li>' . $value . '</li>';
  }
  $output .= '</ul>';
  return $output;
}
  1. CallbackFilterIterator是一种很好的方式,可以为您可能正在使用的任何现有迭代器增加价值。它允许您包装任何现有迭代器并筛选输出。在这个例子中,我们将定义fetchCountryName(),它遍历生成国家名称列表的数据库查询。首先,我们从使用第一章中定义的Application\Database\Connection类的查询中定义一个ArrayIterator实例,建立基础
function fetchCountryName($sql, $connection)
{
  $iterator = new ArrayIterator();
  $stmt = $connection->pdo->query($sql);
  while($row = $stmt->fetch(PDO::FETCH_ASSOC)) {
    $iterator->append($row['name']);
  }
  return $iterator;
}
  1. 接下来,我们定义一个过滤方法nameFilterIterator(),它接受部分国家名称作为参数,以及ArrayIterator实例:
function nameFilterIterator($innerIterator, $name)
{
  if (!$name) return $innerIterator;
  $name = trim($name);
  $iterator = new CallbackFilterIterator($innerIterator, 
    function($current, $key, $iterator) use ($name) {
      $pattern = '/' . $name . '/i';
      return (bool) preg_match($pattern, $current);
    }
  );
  return $iterator;
}
  1. LimitIterator 为您的应用程序添加了基本的分页功能。要使用此迭代器,您只需要提供父迭代器、偏移量和限制。LimitIterator 将只产生从偏移量开始的整个数据集的子集。以步骤 2 中提到的相同示例为例,我们将对来自数据库查询的结果进行分页。我们可以通过简单地将fetchCountryName()方法生成的迭代器包装在LimitIterator实例中来实现这一点:
$pagination = new LimitIterator(fetchCountryName(
$sql, $connection), $offset, $limit);

注意

在使用LimitIterator时要小心。为了实现限制,它需要将整个数据集保存在内存中。因此,在迭代大型数据集时,这不是一个好工具。

  1. 迭代器可以堆叠。在这个简单的例子中,ArrayIteratorFilterIterator处理,然后由LimitIterator限制。首先,我们设置一个ArrayIterator实例:
$i = new ArrayIterator($a);
  1. 接下来,我们将ArrayIterator插入FilterIterator实例中。请注意,我们正在使用新的 PHP 7 匿名类特性。在这种情况下,匿名类扩展了FilterIterator并覆盖了accept()方法,只允许具有偶数 ASCII 代码的字母:
$f = new class ($i) extends FilterIterator { 
  public function accept()
  {
    $current = $this->current();
    return !(ord($current) & 1);
  }
};
  1. 最后,我们将FilterIterator实例作为参数提供给LimitIterator,并提供偏移量(在本例中为2)和限制(在本例中为6):
$l = new LimitIterator($f, 2, 6);
  1. 然后,我们可以定义一个简单的函数来显示输出,并依次调用每个迭代器,以查看由range('A', 'Z')生成的简单数组的结果:
function showElements($iterator)
{
  foreach($iterator as $item)  echo $item . ' ';
  echo PHP_EOL;
}

$a = range('A', 'Z');
$i = new ArrayIterator($a);
showElements($i);
  1. 这是一个变体,通过在ArrayIterator上堆叠FilterIterator来产生每隔一个字母:
$f = new class ($i) extends FilterIterator {
public function accept()
  {
    $current = $this->current();
    return !(ord($current) & 1);
  }
};
showElements($f);
  1. 这里还有另一个变体,它只产生F H J L N P,这演示了一个消耗FilterIteratorLimitIterator,而FilterIterator又消耗ArrayIterator。这三个示例的输出如下:
$l = new LimitIterator($f, 2, 6);
showElements($l);

如何做...

  1. 回到我们的例子,它产生了一个国家名称列表,假设我们希望迭代一个由国家名称和 ISO 代码组成的多维数组,而不仅仅是国家名称。到目前为止提到的简单迭代器是不够的。相反,我们将使用所谓的递归迭代器。

  2. 首先,我们需要定义一个方法,该方法使用先前提到的数据库连接类从数据库中提取所有列。与以前一样,我们返回一个由查询数据填充的ArrayIterator实例:

function fetchAllAssoc($sql, $connection)
{
  $iterator = new ArrayIterator();
  $stmt = $connection->pdo->query($sql);
  while($row = $stmt->fetch(PDO::FETCH_ASSOC)) {
    $iterator->append($row);
  }
  return $iterator;
}
  1. 乍一看,人们可能会简单地将标准的ArrayIterator实例包装在RecursiveArrayIterator中。不幸的是,这种方法只执行迭代,并且不能给我们想要的:对从数据库查询返回的多维数组的所有元素进行迭代:
$iterator = fetchAllAssoc($sql, $connection);
$shallow  = new RecursiveArrayIterator($iterator);
  1. 虽然这返回一个迭代,其中每个项表示数据库查询的一行,但在这种情况下,我们希望提供一个迭代,该迭代将遍历查询返回的所有行的所有列。为了实现这一点,我们需要通过RecursiveIteratorIterator来展开大规模的操作。

  2. 蒙提·派森的粉丝将沉浸在这个类名的丰富讽刺之中,因为它让人回忆起多余部门。恰当地,这个类让我们的老朋友RecursiveArrayIterator类加班工作,并对数组的所有级别进行深度迭代:

$deep     = new RecursiveIteratorIterator($shallow);

工作原理...

作为一个实际的例子,您可以开发一个测试脚本,使用迭代器实现过滤和分页。对于这个示例,您可以调用chap_03_developing_functions_filtered_and_paginated.php测试代码文件。

首先,按照最佳实践,将上述描述的函数放入名为chap_03_developing_functions_iterators_library.php的包含文件中。在测试脚本中,确保包含此文件。

数据源是一个名为iso_country_codes的表,其中包含 ISO2、ISO3 和国家名称。数据库连接可以在一个config/db.config.php文件中。您还可以包括在前一章中讨论的Application\Database\Connection类:

define('DB_CONFIG_FILE', '/../config/db.config.php');
define('ITEMS_PER_PAGE', [5, 10, 15, 20]);
include (__DIR__ . '/chap_03_developing_functions_iterators_library.php');
include (__DIR__ . '/../Application/Database/Connection.php');

注意

在 PHP 7 中,您可以将常量定义为数组。在本例中,ITEMS_PER_PAGE被定义为一个数组,并用于生成 HTMLSELECT元素。

接下来,您可以处理国家名称和每页项目数的输入参数。当前页码将从0开始,并且可以递增(下一页)或递减(上一页):

$name = strip_tags($_GET['name'] ?? '');
$limit  = (int) ($_GET['limit'] ?? 10);
$page   = (int) ($_GET['page']  ?? 0);
$offset = $page * $limit;
$prev   = ($page > 0) ? $page - 1 : 0;
$next   = $page + 1;

现在,您已经准备好启动数据库连接并运行一个简单的SELECT查询。这应该放在try {} catch {}块中。然后,您可以将要堆叠的迭代器放在try {}块内:

try {
    $connection = new Application\Database\Connection(
      include __DIR__ . DB_CONFIG_FILE);
    $sql    = 'SELECT * FROM iso_country_codes';
    $arrayIterator    = fetchCountryName($sql, $connection);
    $filteredIterator = nameFilterIterator($arrayIterator, $name);
    $limitIterator    = pagination(
    $filteredIterator, $offset, $limit);
} catch (Throwable $e) {
    echo $e->getMessage();
}

现在我们准备好进行 HTML 编写。在这个简单的例子中,我们提供一个表单,让用户选择每页的项目数和国家名称:

<form>
  Country Name:
  <input type="text" name="name" 
         value="<?= htmlspecialchars($name) ?>">
  Items Per Page: 
  <select name="limit">
    <?php foreach (ITEMS_PER_PAGE as $item) : ?>
      <option<?= ($item == $limit) ? ' selected' : '' ?>>
      <?= $item ?></option>
    <?php endforeach; ?>
  </select>
  <input type="submit" />
</form>
  <a href="?name=<?= $name ?>&limit=<?= $limit ?>
    &page=<?= $prev ?>">
  << PREV</a> 
  <a href="?name=<?= $name ?>&limit=<?= $limit ?>
    &page=<?= $next ?>">
  NEXT >></a>
<?= htmlList($limitIterator); ?>

输出将看起来像这样:

工作原理...

最后,为了测试国家数据库查找的递归迭代,您需要包括迭代器的库文件,以及Application\Database\Connection类:

define('DB_CONFIG_FILE', '/../config/db.config.php');
include (__DIR__ . '/chap_03_developing_functions_iterators_library.php');
include (__DIR__ . '/../Application/Database/Connection.php');

与以前一样,您应该将数据库查询放在try {} catch {}块中。然后,您可以将用于测试递归迭代的代码放在try {}块内:

try {
    $connection = new Application\Database\Connection(
    include __DIR__ . DB_CONFIG_FILE);
    $sql    = 'SELECT * FROM iso_country_codes';
    $iterator = fetchAllAssoc($sql, $connection);
    $shallow  = new RecursiveArrayIterator($iterator);
    foreach ($shallow as $item) var_dump($item);
    $deep     = new RecursiveIteratorIterator($shallow);
    foreach ($deep as $item) var_dump($item);     
} catch (Throwable $e) {
    echo $e->getMessage();
}

以下是您可以期望从RecursiveArrayIterator输出的内容:

工作原理...

使用RecursiveIteratorIterator后的输出如下:

工作原理...

使用生成器编写自己的迭代器

在前面的一系列示例中,我们演示了 PHP 7 SPL 中提供的迭代器的使用。但是,如果这个集合不能满足给定项目的需求,该怎么办?一个解决方案是开发一个函数,该函数不是构建一个然后返回的数组,而是使用yield关键字通过迭代逐步返回值。这样的函数被称为生成器。实际上,在后台,PHP 引擎将自动将您的函数转换为一个称为Generator的特殊内置类。

这种方法有几个优点。主要好处在于当您有一个大容器要遍历时(即解析一个大文件),可以看到。传统的方法是构建一个数组,然后返回该数组。这样做的问题是您实际上需要的内存量翻倍!此外,性能受到影响,因为只有在最终数组被返回后才能实现结果。

如何做...

  1. 在这个例子中,我们在基于迭代器的函数库上构建了一个我们自己设计的生成器。在这种情况下,我们将复制上面关于迭代器的部分中描述的功能,其中我们堆叠了ArrayIteratorFilterIteratorLimitIterator

  2. 因为我们需要访问源数组、所需的过滤器、页码和每页项目数,所以我们将适当的参数包含到一个单独的filteredResultsGenerator()函数中。然后,我们根据页码和限制(即每页项目数)计算偏移量。接下来,我们循环遍历数组,应用过滤器,并在偏移量尚未达到时继续循环,或者在达到限制时中断:

function filteredResultsGenerator(array $array, $filter, $limit = 10, $page = 0)
  {
    $max    = count($array);
    $offset = $page * $limit;
    foreach ($array as $key => $value) {
      if (!stripos($value, $filter) !== FALSE) continue;
      if (--$offset >= 0) continue;
      if (--$limit <= 0) break; 
      yield $value;
    }
  }
  1. 您会注意到这个函数和其他函数之间的主要区别是yield关键字。这个关键字的作用是向 PHP 引擎发出信号,产生一个Generator实例并封装代码。

工作原理...

为了演示filteredResultsGenerator()函数的使用,我们将让您实现一个 Web 应用程序,该应用程序扫描一个网页并生成一个经过过滤和分页的 URL 列表,这些 URL 列表是从HREF属性中获取的。

首先,您需要将filteredResultsGenerator()函数的代码添加到先前配方中使用的库文件中,然后将先前描述的函数放入一个包含文件chap_03_developing_functions_iterators_library.php中。

接下来,定义一个测试脚本chap_03_developing_functions_using_generator.php,其中包括函数库以及定义在第一章中描述的Application\Web\Hoover文件,构建基础

include (__DIR__ . DIRECTORY_SEPARATOR . 'chap_03_developing_functions_iterators_library.php');
include (__DIR__ . '/../Application/Web/Hoover.php');

然后,您需要从用户那里收集关于要扫描的 URL,要用作过滤器的字符串,每页多少项以及当前页码的输入。

注意

null coalesce运算符(??)非常适合从 Web 获取输入。如果未定义,它不会生成任何通知。如果未从用户输入接收参数,则可以提供默认值。

$url    = trim(strip_tags($_GET['url'] ?? ''));
$filter = trim(strip_tags($_GET['filter'] ?? ''));
$limit  = (int) ($_GET['limit'] ?? 10);
$page   = (int) ($_GET['page']  ?? 0);

提示

最佳实践

Web 安全性应始终是优先考虑的。在此示例中,您可以使用strip_tags(),并将数据类型强制转换为整数(int)来消毒用户输入。

然后,您可以定义用于分页列表中上一页和下一页链接的变量。请注意,您还可以应用健全性检查,以确保下一页不会超出结果集的末尾。为简洁起见,本示例中未应用此类健全性检查:

$next   = $page + 1;
$prev   = $page - 1;
$base   = '?url=' . htmlspecialchars($url) 
        . '&filter=' . htmlspecialchars($filter) 
        . '&limit=' . $limit 
        . '&page=';

然后,我们需要创建一个Application\Web\Hoover实例,并从目标 URL 中获取HREF属性:

$vac    = new Application\Web\Hoover();
$list   = $vac->getAttribute($url, 'href');

最后,我们定义了 HTML 输出,通过先前描述的htmlList()函数渲染输入表单并运行我们的生成器:

<form>
<table>
<tr>
<th>URL</th>
<td>
<input type="text" name="url" 
  value="<?= htmlspecialchars($url) ?>"/>
</td>
</tr>
<tr>
<th>Filter</th>
<td>
<input type="text" name="filter" 
  value="<?= htmlspecialchars($filter) ?>"/></td>
</tr>
<tr>
<th>Limit</th>
<td><input type="text" name="limit" value="<?= $limit ?>"/></td>
</tr>
<tr>
<th>&nbsp;</th><td><input type="submit" /></td>
</tr>
<tr>
<td>&nbsp;</td>
<td>
<a href="<?= $base . $prev ?>"><-- PREV | 
<a href="<?= $base . $next ?>">NEXT --></td>
</tr>
</table>
</form>
<hr>
<?= htmlList(filteredResultsGenerator(
$list, $filter, $limit, $page)); ?>

这是一个输出的例子:

工作原理...

第四章:使用 PHP 面向对象编程

在本章中我们将涵盖:

  • 开发类

  • 扩展类

  • 使用静态属性和方法

  • 使用命名空间

  • 定义可见性

  • 使用接口

  • 使用 traits

  • 实现匿名类

介绍

在本章中,我们将考虑利用 PHP 7.0、7.1 及以上版本中可用的面向对象编程OOP)功能的相关内容。PHP 7.x 中大部分的 OOP 功能也适用于 PHP 5.6。PHP 7 引入的新功能是支持匿名类。在 PHP 7.1 中,您可以修改类常量的可见性。

注意

另一个全新的功能是捕获某些类型的错误。这在第十三章最佳实践、测试和调试中更详细地讨论。

开发类

传统的开发方法是将类放入自己的文件中。通常,类包含实现单一目的的逻辑。类进一步分解为自包含的函数,这些函数被称为方法。在类内定义的变量被称为属性。建议同时开发一个测试类,这是在第十三章最佳实践、测试和调试中更详细讨论的主题。

如何做...

  1. 创建一个文件来包含类定义。为了自动加载的目的,建议文件名与类名匹配。在文件顶部,在关键字class之前,添加一个DocBlock。然后您可以定义属性和方法。在这个例子中,我们定义了一个类Test。它有一个属性$test和一个方法getTest()
<?php
declare(strict_types=1);
/**
 * This is a demonstration class.
 *
 * The purpose of this class is to get and set 
 * a protected property $test
 *
 */
class Test
{

  protected $test = 'TEST';

  /**
   * This method returns the current value of $test
   *
   * @return string $test
   */
  public function getTest() : string
  {
    return $this->test;
  }

  /**
   * This method sets the value of $test
   *
   * @param string $test
   * @return Test $this
   */
  public function setTest(string $test)
  {
    $this->test = $test;
    return $this;
  }
}

提示

最佳实践

将文件命名为类名被认为是最佳实践。虽然 PHP 中的类名不区分大小写,但进一步被认为是最佳实践的是使用大写字母作为类名的第一个字母。您不应该在类定义文件中放置可执行代码。

每个类都应该在关键字class之前包含一个DocBlock。在 DocBlock 中,您应该包括一个关于类目的简短描述。空一行,然后包括更详细的描述。您还可以包括@标签,如@author@license等。每个方法也应该在之前包含一个标识方法目的的 DocBlock,以及它的传入参数和返回值。

  1. 可以在一个文件中定义多个类,但这不被认为是最佳实践。在这个例子中,我们创建一个文件NameAddress.php,其中定义了两个类,NameAddress
<?php
declare(strict_types=1);
class Name
{

  protected $name = '';

  public function getName() : string
  {
    return $this->name;
  }

  public function setName(string $name)
  {
    $this->name = $name;

    return $this;
  }
}

class Address
{

  protected $address = '';

  public function getAddress() : string
  {
    return $this->address;
  }

  public function setAddress(string $address)
  {
    $this->address = $address;
    return $this;
  }
}

提示

虽然您可以在单个文件中定义多个类,如前面的代码片段所示,但这并不被认为是最佳实践。这不仅会使文件的逻辑纯度降低,而且会使自动加载变得更加困难。

  1. 类名不区分大小写。重复将被标记为错误。在这个例子中,在一个名为TwoClass.php的文件中,我们定义了两个类,TwoClasstwoclass
<?php
class TwoClass
{
  public function showOne()
  {
    return 'ONE';
  }
}

// a fatal error will occur when the second class definition is parsed
class twoclass
{
  public function showTwo()
  {
    return 'TWO';
  }
}
  1. PHP 7.1 已解决了在使用关键字$this时的不一致行为。虽然在 PHP 7.0 和 PHP 5.x 中允许使用,但在 PHP 7.1 中,如果$this被用作:
  • 一个参数

  • 一个static变量

  • 一个global变量

  • try...catch块中使用的变量

  • foreach()中使用的变量

  • 作为unset()的参数

  • 作为一个变量(即,$a = 'this'; echo $$a

  • 通过引用间接使用

  1. 如果您需要创建一个对象实例,但不想定义一个离散的类,您可以使用内置于 PHP 中的通用stdClassstdClass允许您在不必定义一个扩展stdClass的离散类的情况下即兴定义属性:
$obj = new stdClass();
  1. 这个功能在 PHP 的许多不同地方使用。例如,当您使用PHP 数据对象PDO)来进行数据库查询时,其中的一个获取模式是PDO::FETCH_OBJ。这种模式返回stdClass的实例,其中的属性代表数据库表列:
$stmt = $connection->pdo->query($sql);
$row  = $stmt->fetch(PDO::FETCH_OBJ);

它是如何工作的...

取出前面代码片段中的Test类的例子,并将代码放入一个名为Test.php的文件中。创建另一个名为chap_04_oop_defining_class_test.php的文件。添加以下代码:

require __DIR__ . '/Test.php';

$test = new Test();
echo $test->getTest();
echo PHP_EOL;

$test->setTest('ABC');
echo $test->getTest();
echo PHP_EOL;

输出将显示$test属性的初始值,然后通过调用setTest()修改的新值:

它是如何工作的...

下一个例子让您在一个名为NameAddress.php的单个文件中定义两个类,NameAddress。您可以使用以下代码调用和使用这两个类:

require __DIR__ . '/NameAddress.php';

$name = new Name();
$name->setName('TEST');
$addr = new Address();
$addr->setAddress('123 Main Street');

echo $name->getName() . ' lives at ' . $addr->getAddress();

注意

虽然 PHP 解释器没有生成错误,但通过定义多个类,文件的逻辑纯度受到了损害。此外,文件名与类名不匹配,这可能会影响自动加载的能力。

接下来的例子的输出如下所示:

它是如何工作的...

步骤 3 还展示了一个文件中的两个类定义。然而,在这种情况下,目标是演示 PHP 中的类名是不区分大小写的。将代码放入一个名为TwoClass.php的文件中。当您尝试包含该文件时,将生成一个错误:

它是如何工作的...

为了演示直接使用stdClass,创建一个实例,为属性赋值,并使用var_dump()来显示结果。要查看stdClass在内部的使用方式,使用var_dump()来显示PDO查询的结果,其中获取模式设置为FETCH_OBJ

输入以下代码:

$obj = new stdClass();
$obj->test = 'TEST';
echo $obj->test;
echo PHP_EOL;

include (__DIR__ . '/../Application/Database/Connection.php');
$connection = new Application\Database\Connection(
  include __DIR__ . DB_CONFIG_FILE);

$sql  = 'SELECT * FROM iso_country_codes';
$stmt = $connection->pdo->query($sql);
$row  = $stmt->fetch(PDO::FETCH_OBJ);
var_dump($row);

以下是输出:

它是如何工作的...

参见...

有关 PHP 7.1 中关键字$this的改进的更多信息,请参阅wiki.php.net/rfc/this_var

扩展类

开发人员使用 OOP 的主要原因之一是因为它能够重用现有的代码,同时又能够添加或覆盖功能。在 PHP 中,关键字extends用于在类之间建立父/子关系。

如何做...

  1. child类中,使用关键字extends来建立继承关系。在接下来的例子中,Customer类扩展了Base类。Customer的任何实例都将继承可见的方法和属性,这里是$idgetId()setId()
class Base
{
  protected $id;
  public function getId()
  {
    return $this->id;
  }
  public function setId($id)
  {
    $this->id = $id;
  }
}

class Customer extends Base
{
  protected $name;
  public function getName()
  {
    return $this->name;
  }
  public function setName($name)
  {
    $this->name = $name;
  }
}
  1. 您可以通过将其标记为abstract来强制任何使用您的类的开发人员定义一个方法。在这个例子中,Base类将validate()方法定义为abstract。它必须是抽象的原因是因为从父类Base的角度来确定子类如何被验证是不可能的:
abstract class Base
{
  protected $id;
  public function getId()
  {
    return $this->id;
  }
  public function setId($id)
  {
    $this->id = $id;
  }
  public function validate();
}

注意

如果一个类包含一个抽象方法,那么这个类本身必须声明为abstract

  1. PHP 只支持单一继承线。下一个例子展示了一个名为Member的类,它从Customer继承。Customer又从Base继承:
class Base
{
  protected $id;
  public function getId()
  {
    return $this->id;
  }
  public function setId($id)
  {
    $this->id = $id;
  }
}

class Customer extends Base
{
  protected $name;
  public function getName()
  {
    return $this->name;
  }
  public function setName($name)
  {
    $this->name = $name;
  }
}

class Member extends Customer
{
  protected $membership;
  public function getMembership()
  {
    return $this->membership;
  }
  public function setMembership($memberId)
  {
    $this->membership = $memberId;
  }
}
  1. 为了满足类型提示,目标类的任何子类都可以使用。下面的代码片段中显示的test()函数需要Base类的一个实例作为参数。继承线中的任何类都可以被接受为参数。传递给test()的任何其他内容都会引发TypeError
function test(Base $object)
{
  return $object->getId();
}

它是如何工作的...

在第一个要点中,定义了一个Base类和一个Customer类。为了演示,将这两个类定义放入一个名为chap_04_oop_extends.php的单个文件中,并添加以下代码:

$customer = new Customer();
$customer->setId(100);
$customer->setName('Fred');
var_dump($customer);

请注意,$id属性和getId()setId()方法从父类Base继承到子类Customer

它是如何工作的...

为了说明abstract方法的使用,想象一下你希望为任何扩展Base的类添加某种验证能力。问题是不知道在继承类中可能会验证什么。唯一确定的是你必须有验证能力。

使用前面解释中提到的相同的Base类,并添加一个新的方法validate()。将该方法标记为abstract,不定义任何代码。注意当子Customer类扩展Base时会发生什么。

它是如何工作的...

如果你将Base类标记为abstract,但未在子类中定义validate()方法,将生成相同的错误。最后,继续在子Customer类中实现validate()方法:

class Customer extends Base
{
  protected $name;
  public function getName()
  {
    return $this->name;
  }
  public function setName($name)
  {
    $this->name = $name;
  }
  public function validate()
  {
    $valid = 0;
    $count = count(get_object_vars($this));
    if (!empty($this->id) &&is_int($this->id)) $valid++;
    if (!empty($this->name) 
    &&preg_match('/[a-z0-9 ]/i', $this->name)) $valid++;
    return ($valid == $count);
  }
}

然后你可以添加以下过程代码来测试结果:

$customer = new Customer();

$customer->setId(100);
$customer->setName('Fred');
echo "Customer [id]: {$customer->getName()}" .
     . "[{$customer->getId()}]\n";
echo ($customer->validate()) ? 'VALID' : 'NOT VALID';
$customer->setId('XXX');
$customer->setName('$%£&*()');
echo "Customer [id]: {$customer->getName()}"
  . "[{$customer->getId()}]\n";
echo ($customer->validate()) ? 'VALID' : 'NOT VALID';

这是输出:

它是如何工作的...

展示单行继承,将一个新的Member类添加到前面步骤 1 中显示的BaseCustomer的第一个示例中:

class Member extends Customer
{
  protected $membership;
  public function getMembership()
  {
    return $this->membership;
  }
  public function setMembership($memberId)
  {
    $this->membership = $memberId;
  }
}

创建一个Member的实例,并注意在下面的代码中,所有属性和方法都可以从每个继承的类中使用,即使不是直接继承的。

$member = new Member();
$member->setId(100);
$member->setName('Fred');
$member->setMembership('A299F322');
var_dump($member);

这是输出:

它是如何工作的...

现在定义一个名为test()的函数,该函数以Base的实例作为参数:

function test(Base $object)
{
  return $object->getId();
}

注意BaseCustomerMember的实例都是可以接受的参数:

$base = new Base();
$base->setId(100);

$customer = new Customer();
$customer->setId(101);

$member = new Member();
$member->setId(102);

// all 3 classes work in test()
echo test($base)     . PHP_EOL;
echo test($customer) . PHP_EOL;
echo test($member)   . PHP_EOL;

这是输出:

它是如何工作的...

然而,如果你尝试使用不在继承线上的对象实例运行test(),将抛出一个TypeError

class Orphan
{
  protected $id;
  public function getId()
  {
    return $this->id;
  }
  public function setId($id)
  {
    $this->id = $id;
  }
}
try {
    $orphan = new Orphan();
    $orphan->setId(103);
    echo test($orphan) . PHP_EOL;
} catch (TypeError $e) {
    echo 'Does not work!' . PHP_EOL;
    echo $e->getMessage();
}

我们可以在下面的图片中观察到这一点:

它是如何工作的...

使用静态属性和方法

PHP 允许你访问属性或方法,而不必创建类的实例。用于此目的的关键字是static

如何做...

  1. 最简单的方法是在声明普通属性或方法时,在声明可见级别后添加static关键字。使用self关键字在内部引用属性:
class Test
{
  public static $test = 'TEST';
  public static function getTest()
  {
    return self::$test;
  }
}
  1. self关键字将会提前绑定,这会在访问子类中的静态信息时造成问题。如果你绝对需要访问子类的信息,使用static关键字代替self。这个过程被称为后期静态绑定

  2. 在下面的示例中,如果你输出Child::getEarlyTest(),输出将是TEST。另一方面,如果你运行Child::getLateTest(),输出将是CHILD。原因是当使用self时,PHP 将绑定到最早的定义,而对于static关键字,将使用最新的绑定:

class Test2
{
  public static $test = 'TEST2';
  public static function getEarlyTest()
  {
    return self::$test;
  }
  public static function getLateTest()
  {
    return static::$test;
  }
}

class Child extends Test2
{
  public static $test = 'CHILD';
}
  1. 在许多情况下,工厂设计模式与静态方法一起使用,以根据不同的参数生成对象的实例。在这个例子中,定义了一个静态方法factory(),它返回一个 PDO 连接:
public static function factory(
  $driver,$dbname,$host,$user,$pwd,array $options = [])
  {
    $dsn = sprintf('%s:dbname=%s;host=%s', 
    $driver, $dbname, $host);
    try {
        return new PDO($dsn, $user, $pwd, $options);
    } catch (PDOException $e) {
        error_log($e->getMessage);
    }
  }

它是如何工作的...

你可以使用类解析运算符"::"来引用静态属性和方法。给定之前显示的Test类,如果你运行这段代码:

echo Test::$test;
echo PHP_EOL;
echo Test::getTest();
echo PHP_EOL;

你会看到这个输出:

它是如何工作的...

为了说明后期静态绑定,基于之前显示的Test2Child类,尝试这段代码:

echo Test2::$test;
echo Child::$test;
echo Child::getEarlyTest();
echo Child::getLateTest();

输出说明了selfstatic之间的区别。

它是如何工作的...

最后,为了测试之前显示的factory()方法,将代码保存到Application\Database\Connection类中,保存在Application\Database文件夹中的Connection.php文件中。然后你可以尝试这样做:

include __DIR__ . '/../Application/Database/Connection.php';
use Application\Database\Connection;
$connection = Connection::factory(
'mysql', 'php7cookbook', 'localhost', 'test', 'password');
$stmt = $connection->query('SELECT name FROM iso_country_codes');
while ($country = $stmt->fetch(PDO::FETCH_COLUMN)) 
echo $country . '';

你将看到从示例数据库中提取的国家列表:

它是如何工作的...

另请参阅

有关后期静态绑定的更多信息,请参阅 PHP 文档中的解释:

php.net/manual/en/language.oop5.late-static-bindings.php

使用命名空间

对于高级 PHP 开发来说,关键的一点是使用命名空间。任意定义的命名空间成为类名的前缀,从而避免了意外类重复的问题,并允许您在开发中拥有非凡的自由度。另一个使用命名空间的好处是,假设它与目录结构匹配,它可以促进自动加载,如第一章中所讨论的构建基础

操作步骤...

  1. 要在命名空间中定义一个类,只需在代码文件顶部添加关键字namespace
namespace Application\Entity;

注意

最佳实践

与每个文件只有一个类的建议类似,您应该每个文件只有一个命名空间。

  1. 在关键字namespace之前应该只有一个注释和/或关键字declare
<?php
declare(strict_types=1);
namespace Application\Entity;
/**
 * Address
 *
 */
class Address
{
  // some code
}
  1. 在 PHP 5 中,如果您需要访问外部命名空间中的类,可以添加一个只包含命名空间的use语句。然后,您需要使用命名空间的最后一个组件作为前缀来引用该命名空间内的任何类:
use Application\Entity;
$name = new Entity\Name();
$addr = new Entity\Address();
$prof = new Entity\Profile();
  1. 或者,您可以明确指定所有三个类:
use Application\Entity\Name;
use Application\Entity\Address;
use Application\Entity\Profile;
$name = new Name();
$addr = new Address();
$prof = new Profile();
  1. PHP 7 引入了一种称为group use的语法改进,大大提高了代码的可读性:
use Application\Entity\ {
  Name,
  Address,
  Profile
};
$name = new Name();
$addr = new Address();
$prof = new Profile();
  1. 如第一章中所述,构建基础,命名空间是自动加载过程的一个组成部分。此示例显示了一个演示自动加载程序,它会回显传递的参数,然后尝试根据命名空间和类名包含一个文件。这假设目录结构与命名空间匹配:
function __autoload($class)
{
  echo "Argument Passed to Autoloader = $class\n";
  include __DIR__ . '/../' . str_replace('\\', DIRECTORY_SEPARATOR, $class) . '.php';
}

工作原理...

为了举例说明,定义一个与Application\*命名空间匹配的目录结构。创建一个基础文件夹Application,以及一个子文件夹Entity。您还可以根据需要包含任何其他章节中使用的子文件夹,比如DatabaseGeneric

工作原理...

接下来,在Application/Entity文件夹下分别创建三个entity类,每个类都在自己的文件中:Name.phpAddress.phpProfile.php。这里只展示Application\Entity\NameApplication\Entity\AddressApplication\Entity\Profile将是相同的,只是Address有一个$address属性,而Profile有一个$profile属性,每个属性都有适当的getset方法:

<?php
declare(strict_types=1);
namespace Application\Entity;
/**
 * Name
 *
 */
class Name
{

  protected $name = '';

  /**
   * This method returns the current value of $name
   *
   * @return string $name
   */
  public function getName() : string
  {
    return $this->name;
  }

  /**
   * This method sets the value of $name
   *
   * @param string $name
   * @return name $this
   */
  public function setName(string $name)
  {
    $this->name = $name;
    return $this;
  }
}

然后,您可以使用第一章中定义的自动加载程序,或者使用之前提到的简单自动加载程序。将设置自动加载的命令放在一个文件chap_04_oop_namespace_example_1.php中。在此文件中,您可以指定一个use语句,只引用命名空间,而不是类名。通过使用命名空间的最后一部分Entity作为类名的前缀,创建三个实体类NameAddressProfile的实例:

use Application\Entity;
$name = new Entity\Name();
$addr = new Entity\Address();
$prof = new Entity\Profile();

var_dump($name);
var_dump($addr);
var_dump($prof);

输出如下:

工作原理...

接下来,使用另存为将文件复制到一个名为chap_04_oop_namespace_example_2.php的新文件中。将use语句更改为以下内容:

use Application\Entity\Name;
use Application\Entity\Address;
use Application\Entity\Profile;

现在,您可以仅使用类名创建类实例:

$name = new Name();
$addr = new Address();
$prof = new Profile();

当您运行此脚本时,输出如下:

工作原理...

最后,再次使用另存为创建一个新文件chap_04_oop_namespace_example_3.php。您现在可以测试 PHP 7 中引入的group use功能:

use Application\Entity\ {
  Name,
  Address,
  Profile
};
$name = new Name();
$addr = new Address();
$prof = new Profile();

同样,当您运行此代码块时,输出将与前面的输出相同:

工作原理...

定义可见性

欺骗地,可见性一词与应用程序安全无关!相反,它只是一种控制代码使用的机制。它可以用来引导经验不足的开发人员远离应该仅在类定义内部调用的方法的public使用。

如何做...

  1. 通过在任何属性或方法定义的前面添加publicprotectedprivate关键字来指示可见性级别。您可以将属性标记为protectedprivate,以强制仅通过公共“getter”和“setter”访问。

  2. 在此示例中,定义了一个带有受保护属性$idBase类。为了访问此属性,定义了“getId()”和“setId()”公共方法。受保护方法“generateRandId()”可以在内部使用,并且在Customer子类中继承。此方法不能直接在类定义之外调用。请注意使用新的 PHP 7“random_bytes()”函数创建随机 ID。

class Base
{
  protected $id;
  private $key = 12345;
  public function getId()
  {
    return $this->id;
  }
  public function setId()
  {
    $this->id = $this->generateRandId();
  }
  protected function generateRandId()
  {
    return unpack('H*', random_bytes(8))[1];
  }
}

class Customer extends Base
{
  protected $name;
  public function getName()
  {
    return $this->name;
  }
  public function setName($name)
  {
    $this->name = $name;
  }
}

注意

最佳实践

将属性标记为protected,并定义“publicgetNameOfProperty()”和“setNameOfProperty()”方法来控制对属性的访问。这些方法被称为“getter”和“setter”。

  1. 将属性或方法标记为private以防止其被继承或从类定义之外可见。这是创建类作为单例的好方法。

  2. 下一个代码示例显示了一个名为Registry的类,其中只能有一个实例。因为构造函数标记为private,所以唯一可以创建实例的方法是通过静态方法“getInstance()”:

class Registry
{
  protected static $instance = NULL;
  protected $registry = array();
  private function __construct()
  {
    // nobody can create an instance of this class
  }
  public static function getInstance()
  {
    if (!self::$instance) {
      self::$instance = new self();
    }
    return self::$instance;
  }
  public function __get($key)
  {
    return $this->registry[$key] ?? NULL;
  }
  public function __set($key, $value)
  {
    $this->registry[$key] = $value;
  }
}

注意

您可以将方法标记为final以防止其被覆盖。将类标记为final以防止其被扩展。

  1. 通常,类常量被认为具有public的可见性级别。从 PHP 7.1 开始,您可以将类常量声明为protectedprivate。在以下示例中,TEST_WHOLE_WORLD类常量的行为与 PHP 5 中完全相同。接下来的两个常量,TEST_INHERITEDTEST_LOCAL,遵循与任何protectedprivate属性或方法相同的规则:
class Test
{

  public const TEST_WHOLE_WORLD  = 'visible.everywhere';

  // NOTE: only works in PHP 7.1 and above
  protected const TEST_INHERITED = 'visible.in.child.classes';

  // NOTE: only works in PHP 7.1 and above
  private const TEST_LOCAL= 'local.to.class.Test.only';

  public static function getTestInherited()
  {
    return static::TEST_INHERITED;
  }

  public static function getTestLocal()
  {
    return static::TEST_LOCAL;
  }

}

它是如何工作的...

创建一个名为chap_04_basic_visibility.php的文件,并定义两个类:BaseCustomer。接下来,编写代码以创建每个实例:

$base     = new Base();
$customer = new Customer();

请注意,以下代码可以正常工作,并且实际上被认为是最佳实践:

$customer->setId();
$customer->setName('Test');
echo 'Welcome ' . $customer->getName() . PHP_EOL;
echo 'Your new ID number is: ' . $customer->getId() . PHP_EOL;

尽管$idprotected,但相应的方法“getId()”和“setId()”都是public,因此可以从类定义外部访问。以下是输出:

它是如何工作的...

然而,以下代码行将无法工作,因为privateprotected属性无法从类定义之外访问:

echo 'Key (does not work): ' . $base->key;
echo 'Key (does not work): ' . $customer->key;
echo 'Name (does not work): ' . $customer->name;
echo 'Random ID (does not work): ' . $customer->generateRandId();

以下输出显示了预期的错误:

它是如何工作的...

另请参阅

有关“getter”和“setter”的更多信息,请参见本章中标题为“使用 getter 和 setter”的配方。有关 PHP 7.1 类常量可见性设置的更多信息,请参见wiki.php.net/rfc/class_const_visibility

使用接口

接口是系统架构师的有用工具,通常用于原型设计应用程序编程接口API)。接口不包含实际代码,但可以包含方法的名称以及方法签名。

注意

所有在“接口”中标识的方法都具有public的可见性级别。

如何做...

  1. 由接口标识的方法不能包含实际代码实现。但是,您可以指定方法参数的数据类型。

  2. 在此示例中,ConnectionAwareInterface标识了一个方法“setConnection()”,该方法需要一个Connection的实例作为参数:

interface ConnectionAwareInterface
{
  public function setConnection(Connection $connection);
}
  1. 要使用接口,请在定义类的开放行之后添加关键字implements。我们定义了两个类,CountryListCustomerList,它们都需要通过setConnection()方法访问Connection类。为了识别这种依赖关系,这两个类都实现了ConnectionAwareInterface
class CountryList implements ConnectionAwareInterface
{
  protected $connection;
  public function setConnection(Connection $connection)
  {
    $this->connection = $connection;
  }
  public function list()
  {
    $list = [];
    $stmt = $this->connection->pdo->query(
      'SELECT iso3, name FROM iso_country_codes');
    while ($country = $stmt->fetch(PDO::FETCH_ASSOC)) {
      $list[$country['iso3']] =  $country['name'];
    }
    return $list;
  }

}
class CustomerList implements ConnectionAwareInterface
{
  protected $connection;
  public function setConnection(Connection $connection)
  {
    $this->connection = $connection;
  }
  public function list()
  {
    $list = [];
    $stmt = $this->connection->pdo->query(
      'SELECT id, name FROM customer');
    while ($customer = $stmt->fetch(PDO::FETCH_ASSOC)) {
      $list[$customer['id']] =  $customer['name'];
    }
    return $list;
  }

}
  1. 接口可用于满足类型提示。以下类ListFactory包含一个factory()方法,该方法初始化任何实现ConnectionAwareInterface的类。接口是setConnection()方法被定义的保证。将类型提示设置为接口而不是特定类实例使factory方法更通用:
namespace Application\Generic;

use PDO;
use Exception;
use Application\Database\Connection;
use Application\Database\ConnectionAwareInterface;

class ListFactory
{
  const ERROR_AWARE = 'Class must be Connection Aware';
  public static function factory(
    ConnectionAwareInterface $class, $dbParams)
  {
    if ($class instanceofConnectionAwareInterface) {
        $class->setConnection(new Connection($dbParams));
        return $class;
    } else {
        throw new Exception(self::ERROR_AWARE);
    }
    return FALSE;
  }
}
  1. 如果一个类实现多个接口,如果方法签名不匹配,则会发生命名冲突。在这个例子中,有两个接口,DateAwareTimeAware。除了定义setDate()setTime()方法之外,它们都定义了setBoth()。具有重复的方法名称不是问题,尽管这不被认为是最佳实践。问题在于方法签名不同:
interface DateAware
{
  public function setDate($date);
  public function setBoth(DateTime $dateTime);
}

interface TimeAware
{
  public function setTime($time);
  public function setBoth($date, $time);
}

class DateTimeHandler implements DateAware, TimeAware
{
  protected $date;
  protected $time;
  public function setDate($date)
  {
    $this->date = $date;
  }
  public function setTime($time)
  {
    $this->time = $time;
  }
  public function setBoth(DateTime $dateTime)
  {
    $this->date = $date;
  }
}
  1. 代码块的当前状态将生成致命错误(无法捕获!)。要解决问题,首选方法是从一个接口中删除setBoth()的定义。或者,您可以调整方法签名以匹配。

注意

最佳实践

不要定义具有重复或重叠方法定义的接口。

它是如何工作的...

Application/Database文件夹中,创建一个文件ConnectionAwareInterface.php。插入前面步骤 2 中讨论的代码。

接下来,在Application/Generic文件夹中,创建两个文件,CountryList.phpCustomerList.php。插入步骤 3 中讨论的代码。

接下来,在与Application目录平行的目录中,创建一个源代码文件chap_04_oop_simple_interfaces_example.php,该文件初始化自动加载程序并包含数据库参数:

<?php
define('DB_CONFIG_FILE', '/../config/db.config.php');
require __DIR__ . '/../Application/Autoload/Loader.php';
Application\Autoload\Loader::init(__DIR__ . '/..');
$params = include __DIR__ . DB_CONFIG_FILE;

在这个例子中,假定数据库参数在由DB_CONFIG_FILE常量指示的数据库配置文件中。

现在,您可以使用ListFactory::factory()生成CountryListCustomerList对象。请注意,如果这些类没有实现ConnectionAwareInterface,将会抛出错误:

  $list = Application\Generic\ListFactory::factory(
    new Application\Generic\CountryList(), $params);
  foreach ($list->list() as $item) echo $item . '';

这是国家列表的输出:

它是如何工作的...

您还可以使用factory方法生成CustomerList对象并使用它:

  $list = Application\Generic\ListFactory::factory(
    new Application\Generic\CustomerList(), $params);
  foreach ($list->list() as $item) echo $item . '';

这是CustomerList的输出:

它是如何工作的...

如果您想要检查实现多个接口但方法签名不同的情况发生了什么,请将步骤 4 中显示的代码输入到文件chap_04_oop_interfaces_collisions.php中。当您尝试运行该文件时,将生成错误,如下所示:

它是如何工作的...

如果您在TimeAware接口中进行以下调整,将不会产生错误:

interface TimeAware
{
  public function setTime($time);
  // this will cause a problem
  public function setBoth(DateTime $dateTime);
}

使用特征

如果您曾经进行过 C 编程,您可能熟悉宏。宏是预定义的代码块,在指定的行处展开。类似地,特征可以包含代码块,在 PHP 解释器指定的行处复制并粘贴到类中。

如何做...

  1. 特征用关键字trait标识,可以包含属性和/或方法。在上一个示例中,当检查CountryListCustomerList类时,您可能已经注意到代码的重复。在这个例子中,我们将重构这两个类,并将list()方法的功能移入Trait。请注意,list()方法在两个类中是相同的。

  2. 特征在类之间存在代码重复的情况下使用。然而,请注意,使用传统的创建抽象类并扩展它的方法可能比使用特征具有某些优势。特征不能用于标识继承线,而抽象父类可以用于此目的。

  3. 现在我们将list()复制到一个名为ListTrait的特征中:

trait ListTrait
{
  public function list()
  {
    $list = [];
    $sql  = sprintf('SELECT %s, %s FROM %s', 
      $this->key, $this->value, $this->table);
    $stmt = $this->connection->pdo->query($sql);
    while ($item = $stmt->fetch(PDO::FETCH_ASSOC)) {
      $list[$item[$this->key]] = $item[$this->value];
    }
    return $list;
  }
}
  1. 然后我们可以将ListTrait中的代码插入到一个新的类CountryListUsingTrait中,如下面的代码片段所示。现在可以从这个类中删除整个list()方法:
class CountryListUsingTrait implements ConnectionAwareInterface
{

  use ListTrait;

  protected $connection;
  protected $key   = 'iso3';
  protected $value = 'name';
  protected $table = 'iso_country_codes';

  public function setConnection(Connection $connection)
  {
    $this->connection = $connection;
  }

}

注意

每当存在代码重复时,当您需要进行更改时,可能会出现潜在问题。您可能会发现自己需要进行太多的全局搜索和替换操作,或者剪切和粘贴代码,通常会导致灾难性的结果。特征是避免这种维护噩梦的好方法。

  1. 特征受命名空间的影响。在第 1 步中所示的示例中,如果我们的新CountryListUsingTrait类放置在一个名为Application\Generic的命名空间中,我们还需要将ListTrait移动到该命名空间中:
namespace Application\Generic;

use PDO;

trait ListTrait
{
  public function list()
  {
    // code as shown above
  }
}
  1. 特征中的方法会覆盖继承的方法。

  2. 在下面的示例中,您会注意到setId()方法的返回值在Base父类和Test特征之间不同。Customer类继承自Base,但也使用Test。在这种情况下,特征中定义的方法将覆盖Base父类中定义的方法:

trait Test
{
  public function setId($id)
  {
    $obj = new stdClass();
    $obj->id = $id;
    $this->id = $obj;
  }
}

class Base
{
  protected $id;
  public function getId()
  {
    return $this->id;
  }
  public function setId($id)
  {
    $this->id = $id;
  }
}

class Customer extends Base
{
  use Test;
  protected $name;
  public function getName()
  {
    return $this->name;
  }
  public function setName($name)
  {
    $this->name = $name;
  }
}

注意

在 PHP 5 中,特征也可以覆盖属性。在 PHP 7 中,如果特征中的属性初始化值与父类中的不同,将生成致命错误。

  1. 在类中直接定义使用特征的方法会覆盖特征中定义的重复方法。

  2. 在这个例子中,Test特征定义了一个$id属性以及getId()方法和setId()。特征还定义了setName(),与Customer类中定义的相同方法冲突。在这种情况下,Customer中直接定义的setName()方法将覆盖特征中定义的setName()

trait Test
{
  protected $id;
  public function getId()
  {
    return $this->id;
  }
  public function setId($id)
  {
    $this->id = $id;
  }
  public function setName($name)
  {
    $obj = new stdClass();
    $obj->name = $name;
    $this->name = $obj;
  }
}

class Customer
{
  use Test;
  protected $name;
  public function getName()
  {
    return $this->name;
  }
  public function setName($name)
  {
    $this->name = $name;
  }
}
  1. 在使用多个特征时,使用insteadof关键字解决方法名称冲突。此外,使用as关键字为方法名称创建别名。

  2. 在这个例子中,有两个特征,IdTraitNameTrait。两个特征都定义了一个setKey()方法,但是以不同的方式表示键。Test类使用了这两个特征。请注意insteadof关键字,它允许我们区分冲突的方法。因此,当从Test类调用setKey()时,源将来自NameTrait。此外,IdTrait中的setKey()仍然可用,但是在别名setKeyDate()下:

trait IdTrait
{
  protected $id;
  public $key;
  public function setId($id)
  {
    $this->id = $id;
  }
  public function setKey()
  {
    $this->key = date('YmdHis') 
    . sprintf('%04d', rand(0,9999));
  }
}

trait NameTrait
{
  protected $name;
  public $key;
  public function setName($name)
  {
    $this->name = $name;
  }
  public function setKey()
  {
    $this->key = unpack('H*', random_bytes(18))[1];
  }
}

class Test
{
  use IdTrait, NameTrait {
    NameTrait::setKeyinsteadofIdTrait;
    IdTrait::setKey as setKeyDate;
  }
}

它是如何工作的...

从第 1 步中,您了解到特征在存在代码重复的情况下使用。您需要评估是否可以简单地定义一个基类并扩展它,或者使用特征更好地满足您的目的。特征在逻辑上不相关的类中看到代码重复时特别有用。

为了说明特征方法如何覆盖继承的方法,请将第 7 步提到的代码块复制到一个单独的文件chap_04_oop_traits_override_inherited.php中。添加以下代码:

$customer = new Customer();
$customer->setId(100);
$customer->setName('Fred');
var_dump($customer);

从输出中可以看到(如下所示),$id属性存储为stdClass()的实例,这是特征中定义的行为:

它是如何工作的...

为了说明直接定义的类方法如何覆盖特征方法,请将第 9 步提到的代码块复制到一个单独的文件chap_04_oop_trait_methods_do_not_override_class_methods.php中。添加以下代码:

$customer = new Customer();
$customer->setId(100);
$customer->setName('Fred');
var_dump($customer);

从下面的输出中可以看到,$id属性存储为整数,如Customer类中定义的那样,而特征将$id定义为stdClass的实例:

它是如何工作的...

在第 10 步中,您学会了如何在使用多个特征时解决重复方法名称冲突。将步骤 11 中显示的代码块复制到一个单独的文件chap_04_oop_trait_multiple.php中。添加以下代码:

$a = new Test();
$a->setId(100);
$a->setName('Fred');
$a->setKey();
var_dump($a);

$a->setKeyDate();
var_dump($a);

请注意,在下面的输出中,setKey()产生了从新的 PHP 7 函数random_bytes()(在NameTrait中定义)产生的输出,而setKeyDate()使用date()rand()函数(在IdTrait中定义)产生一个密钥:

它是如何工作的...

实现匿名类

PHP 7 引入了一个新特性,匿名类。就像匿名函数一样,匿名类可以作为表达式的一部分来定义,创建一个没有名称的类。匿名类用于需要临时创建并使用然后丢弃对象的情况。

如何做...

  1. stdClass的替代方案是定义一个匿名类。

在定义中,您可以定义任何属性和方法(包括魔术方法)。在这个例子中,我们定义了一个具有两个属性和一个魔术方法__construct()的匿名类:

$a = new class (123.45, 'TEST') {
  public $total = 0;
  public $test  = '';
  public function __construct($total, $test)
  {
    $this->total = $total;
    $this->test  = $test;
  }
};
  1. 匿名类可以扩展任何类。

在这个例子中,一个匿名类扩展了FilterIterator,并覆盖了__construct()accept()方法。作为参数,它接受了ArrayIterator $b,它代表了一个 10 到 100 的增量为 10 的数组。第二个参数作为输出的限制:

$b = new ArrayIterator(range(10,100,10));
$f = new class ($b, 50) extends FilterIterator {
  public $limit = 0;
  public function __construct($iterator, $limit)
  {
    $this->limit = $limit;
    parent::__construct($iterator);
  }
  public function accept()
  {
    return ($this->current() <= $this->limit);
  }
};
  1. 匿名类可以实现一个接口。

在这个例子中,一个匿名类用于生成 HTML 颜色代码图表。该类实现了内置的 PHP Countable接口。定义了一个count()方法,当这个类与需要Countable的方法或函数一起使用时调用:

define('MAX_COLORS', 256 ** 3);

$d = new class () implements Countable {
  public $current = 0;
  public $maxRows = 16;
  public $maxCols = 64;
  public function cycle()
  {
    $row = '';
    $max = $this->maxRows * $this->maxCols;
    for ($x = 0; $x < $this->maxRows; $x++) {
      $row .= '<tr>';
      for ($y = 0; $y < $this->maxCols; $y++) {
        $row .= sprintf(
          '<td style="background-color: #%06X;"', 
          $this->current);
        $row .= sprintf(
          'title="#%06X">&nbsp;</td>', 
          $this->current);
        $this->current++;
        $this->current = ($this->current >MAX_COLORS) ? 0 
             : $this->current;
      }
      $row .= '</tr>';
    }
    return $row;
  }
  public function count()
  {
    return MAX_COLORS;
  }
};
  1. 匿名类可以使用特征。

  2. 这个最后的例子是对前面立即定义的修改。我们不是定义一个Test类,而是定义一个匿名类:

$a = new class() {
  use IdTrait, NameTrait {
    NameTrait::setKeyinsteadofIdTrait;
    IdTrait::setKey as setKeyDate;
  }
};

它是如何工作的...

在匿名类中,您可以定义任何属性或方法。使用前面的例子,您可以定义一个接受构造函数参数的匿名类,并且可以访问属性。将步骤 2 中描述的代码放入一个名为chap_04_oop_anonymous_class.php的测试脚本中。添加这些echo语句:

echo "\nAnonymous Class\n";
echo $a->total .PHP_EOL;
echo $a->test . PHP_EOL;

以下是匿名类的输出:

它是如何工作的...

为了使用FilterIterator,您必须覆盖accept()方法。在这个方法中,您定义了迭代的元素被包括在输出中的标准。现在继续并将步骤 4 中显示的代码添加到测试脚本中。然后您可以添加这些echo语句来测试匿名类:

echo "\nAnonymous Class Extends FilterIterator\n";
foreach ($f as $item) echo $item . '';
echo PHP_EOL;

在这个例子中,建立了一个 50 的限制。原始的ArrayIterator包含一个值数组,从 10 到 100,增量为 10,如下面的输出所示:

它是如何工作的...

要查看实现接口的匿名类,请考虑步骤 5 和 6 中显示的例子。将这段代码放入一个文件chap_04_oop_anonymous_class_interfaces.php中。

接下来,添加代码,让您可以通过 HTML 颜色图表进行分页:

$d->current = $_GET['current'] ?? 0;
$d->current = hexdec($d->current);
$factor = ($d->maxRows * $d->maxCols);
$next = $d->current + $factor;
$prev = $d->current - $factor;
$next = ($next <MAX_COLORS) ? $next : MAX_COLORS - $factor;
$prev = ($prev>= 0) ? $prev : 0;
$next = sprintf('%06X', $next);
$prev = sprintf('%06X', $prev);
?>

最后,继续并将 HTML 颜色图表呈现为一个网页:

<h1>Total Possible Color Combinations: <?= count($d); ?></h1>
<hr>
<table>
<?= $d->cycle(); ?>
</table>	
<a href="?current=<?= $prev ?>"><<PREV</a>
<a href="?current=<?= $next ?>">NEXT >></a>

请注意,您可以通过将匿名类的实例传递给count()函数(在<H1>标签之间显示)来利用Countable接口。以下是在浏览器窗口中显示的输出:

它是如何工作的...

最后,为了说明匿名类中使用特征,将前面一篇文章中提到的chap_04_oop_trait_multiple.php文件复制到一个新文件chap_04_oop_trait_anonymous_class.php中。删除Test类的定义,并用匿名类替换它:

$a = new class() {
  use IdTrait, NameTrait {
    NameTrait::setKeyinsteadofIdTrait;
    IdTrait::setKey as setKeyDate;
  }
};

删除这一行:

$a = new Test();

当您运行代码时,您将看到与前面截图中完全相同的输出,只是类引用将是匿名的:

它是如何工作的...

第五章:与数据库交互

在本章中,我们将涵盖以下主题:

  • 使用 PDO 连接到数据库

  • 构建面向对象的 SQL 查询生成器

  • 处理分页

  • 定义实体以匹配数据库表

  • 将实体类与 RDBMS 查询绑定

  • 将辅助查找嵌入到查询结果中

  • 实现 jQuery DataTables PHP 查找

介绍

在本章中,我们将介绍一系列利用PHP 数据对象PDO)扩展的数据库连接配方。将解决常见的编程问题,如结构化查询语言SQL)生成,分页和将对象与数据库表绑定。最后,我们将呈现处理嵌入式匿名函数形式的辅助查找的代码,并使用 jQuery DataTables 进行 AJAX 请求。

使用 PDO 连接到数据库

PDO是一个高性能且积极维护的数据库扩展,具有与特定供应商扩展不同的独特优势。它具有一个通用的应用程序编程接口API),与几乎十几种不同的关系数据库管理系统RDBMS)兼容。学习如何使用此扩展将节省您大量时间,因为您无需尝试掌握等效的各个特定供应商数据库扩展的命令子集。

PDO 分为四个主要类,如下表所示:

功能
PDO 维护与数据库的实际连接,并处理低级功能,如事务支持
PDOStatement 处理结果
PDOException 特定于数据库的异常
PDODriver 与实际特定供应商数据库通信

如何做...

  1. 通过创建PDO实例建立数据库连接。

  2. 您需要构建一个数据源名称DSN)。DSN 中包含的信息根据使用的数据库驱动程序而变化。例如,这是一个用于连接到MySQL数据库的 DSN:

$params = [
  'host' => 'localhost',
  'user' => 'test',
  'pwd'  => 'password',
  'db'   => 'php7cookbook'
];

try {
  $dsn  = sprintf(**'mysql:host=%s;dbname=%s',**
 **$params['host'], $params['db']);**
  $pdo  = new PDO($dsn, $params['user'], $params['pwd']);
} catch (PDOException $e) {
  echo $e->getMessage();
} catch (Throwable $e) {
  echo $e->getMessage();
}
  1. 另一方面,SQlite,一个更简单的扩展,只需要以下命令:
$params = [
  'db'   => __DIR__ . '/../data/db/php7cookbook.db.sqlite'
];
$dsn  = sprintf('sqlite:' . $params['db']);
  1. 另一方面,PostgreSQL直接在 DSN 中包括用户名和密码:
$params = [
  'host' => 'localhost',
  'user' => 'test',
  'pwd'  => 'password',
  'db'   => 'php7cookbook'
];
$dsn  = sprintf(**'pgsql:host=%s;dbname=%s;user=%s;password=%s',** 
               $params['host'], 
               $params['db'],
               $params['user'],
               $params['pwd']);
  1. DSN 还可以包括特定于服务器的指令,例如unix_socket,如下例所示:
$params = [
  'host' => 'localhost',
  'user' => 'test',
  'pwd'  => 'password',
  'db'   => 'php7cookbook',
  'sock' => '/var/run/mysqld/mysqld.sock'
];

try {
  $dsn  = sprintf('mysql:host=%s;dbname=%s;**unix_socket=%s',** 
                  $params['host'], $params['db'], $params['sock']);
  $opts = [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION];
  $pdo  = new PDO($dsn, $params['user'], $params['pwd'], $opts);
} catch (PDOException $e) {
  echo $e->getMessage();
} catch (Throwable $e) {
  echo $e->getMessage();
}

注意

最佳实践

将创建 PDO 实例的语句包装在try {} catch {}块中。在发生故障时,捕获PDOException以获取特定于数据库的信息。捕获Throwable以处理错误或任何其他异常。将 PDO 错误模式设置为PDO::ERRMODE_EXCEPTION以获得最佳结果。有关错误模式的更多详细信息,请参见第 8 步。

在 PHP 5 中,如果无法构造 PDO 对象(例如,使用无效参数),则实例将被赋予NULL值。在 PHP 7 中,会抛出一个Exception。如果将 PDO 对象的构造包装在try {} catch {}块中,并且将PDO::ATTR_ERRMODE设置为PDO::ERRMODE_EXCEPTION,则可以捕获并记录此类错误,而无需测试NULL

  1. 使用PDO::query()发送 SQL 命令。返回一个PDOStatement实例,您可以针对其获取结果。在此示例中,我们正在查找按 ID 排序的前 20 个客户:
$stmt = $pdo->query(
'SELECT * FROM customer ORDER BY id LIMIT 20');

注意

PDO 还提供了一个方便的方法PDO::exec(),它不返回结果迭代,只返回受影响的行数。此方法最适用于诸如ALTER TABLEDROP TABLE等管理操作。

  1. 迭代PDOStatement实例以处理结果。将获取模式设置为PDO::FETCH_NUMPDO::FETCH_ASSOC,以返回以数字或关联数组形式的结果。在此示例中,我们使用while()循环处理结果。当获取到最后一个结果时,结果为布尔值FALSE,结束循环:
while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) {
  printf('%4d | %20s | %5s' . PHP_EOL, $row['id'], 
  $row['name'], $row['level']);
}

注意

PDO 获取操作涉及定义迭代方向(即向前或向后)的游标PDOStatement::fetch()的第二个参数可以是PDO::FETCH_ORI_*常量中的任何一个。游标方向包括 prior、first、last、absolute 和 relative。默认游标方向是PDO::FETCH_ORI_NEXT

  1. 将获取模式设置为PDO::FETCH_OBJ以将结果作为stdClass实例返回。在这里,您会注意到while()循环利用了获取模式PDO::FETCH_OBJ。请注意,printf()语句引用了对象属性,与前面的示例相反,前者引用了数组元素。
while ($row = $stmt->fetch(PDO::FETCH_OBJ)) {
  printf('%4d | %20s | %5s' . PHP_EOL, 
 **$row->id, $row->name, $row->level);**
}
  1. 如果要在处理查询时创建特定类的实例,请将获取模式设置为PDO::FETCH_CLASS。您还必须有类定义可用,并且PDO::query()应该设置类名。如下面的代码片段中所示,我们定义了一个名为Customer的类,具有公共属性$id$name$level。属性需要是public,以使获取注入正常工作:
class Customer
{
  public $id;
  public $name;
  public $level;
}

$stmt = $pdo->query($sql, PDO::FETCH_CLASS, 'Customer');
  1. 在获取对象时,与步骤 5 中显示的技术相比,更简单的替代方法是使用PDOStatement::fetchObject()
while ($row = $stmt->**fetchObject('Customer')**) {
  printf('%4d | %20s | %5s' . PHP_EOL, 
  $row->id, $row->name, $row->level);
}
  1. 您还可以使用PDO::FETCH_INTO,它本质上与PDO::FETCH_CLASS相同,但您需要一个活动对象实例,而不是一个类引用。通过循环的每次迭代,都会使用当前信息集重新填充相同的对象实例。此示例假定与步骤 5 中相同的类Customer,以及与步骤 1 中定义的相同的数据库参数和 PDO 连接:
$cust = new Customer();
while ($stmt->fetch(**PDO::FETCH_INTO**)) {
  printf('%4d | %20s | %5s' . PHP_EOL, 
 **$cust**->id, **$cust**->name, **$cust**->level);
}
  1. 如果您没有指定错误模式,默认的 PDO 错误模式是PDO::ERRMODE_SILENT。您可以使用PDO::ATTR_ERRMODE键设置错误模式,以及PDO::ERRMODE_WARNINGPDO::ERRMODE_EXCEPTION值。错误模式可以作为关联数组的第四个参数指定给 PDO 构造函数。或者,您可以在现有实例上使用PDO::setAttribute()

  2. 假设您有以下 DSN 和 SQL(在您开始认为这是一种新形式的 SQL 之前,请放心:这个 SQL 语句不起作用!):

$params = [
  'host' => 'localhost',
  'user' => 'test',
  'pwd'  => 'password',
  'db'   => 'php7cookbook'
];
$dsn  = sprintf('mysql:host=%s;dbname=%s', $params['host'], $params['db']);
$sql  = 'THIS SQL STATEMENT WILL NOT WORK';
  1. 然后,如果您使用默认错误模式制定 PDO 连接,出现问题的唯一线索是,PDO::query()将返回一个布尔值FALSE,而不是生成PDOStatement实例:
$pdo1  = new PDO($dsn, $params['user'], $params['pwd']);
$stmt = $pdo1->query($sql);
$row = ($stmt) ? $stmt->fetch(PDO::FETCH_ASSOC) : 'No Good';
  1. 下一个示例显示了使用构造函数方法将错误模式设置为WARNING
$pdo2 = new PDO(
  $dsn, 
  $params['user'], 
  $params['pwd'], 
  [PDO::ATTR_ERRMODE => PDO::ERRMODE_WARNING]);
  1. 如果您需要完全分离准备和执行阶段,请使用PDO::prepare()PDOStatement::execute()。然后将语句发送到数据库服务器进行预编译。然后可以根据需要执行语句,很可能是在循环中。

  2. PDO::prepare()的第一个参数可以是带有占位符的 SQL 语句,而不是实际值。然后可以向PDOStatement::execute()提供值数组。PDO 自动提供数据库引用,有助于防止SQL 注入

注意

最佳实践

任何应用程序中,如果外部输入(即来自表单提交)与 SQL 语句结合在一起,都会受到 SQL 注入攻击的影响。所有外部输入必须首先经过适当的过滤、验证和其他清理。不要直接将外部输入放入 SQL 语句中。而是使用占位符,并在执行阶段提供实际(经过清理的)值。

  1. 要以相反的顺序迭代结果,可以更改可滚动游标的方向。或者,更简单地,将ORDER BYASC更改为DESC。以下代码行设置了一个请求可滚动游标的PDOStatement对象:
$dsn  = sprintf('pgsql:charset=UTF8;host=%s;dbname=%s', $params['host'], $params['db']);
$opts = [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]; 
$pdo  = new PDO($dsn, $params['user'], $params['pwd'], $opts);
$sql  = 'SELECT * FROM customer '
    . 'WHERE balance > :min AND balance < :max '
    . 'ORDER BY id LIMIT 20';
$stmt = $pdo->prepare($sql, **[PDO::ATTR_CURSOR  => PDO::CURSOR_SCROLL]**);
  1. 在执行获取操作期间,您还需要指定游标指令。此示例获取结果集中的最后一行,然后向后滚动:
$stmt->execute(['min' => $min, 'max' => $max]);
$row = $stmt->fetch(PDO::FETCH_ASSOC, **PDO::FETCH_ORI_LAST**);
do {
  printf('%4d | %20s | %5s | %8.2f' . PHP_EOL, 
       $row['id'], 
       $row['name'], 
       $row['level'], 
       $row['balance']);
} while ($row = $stmt->fetch(PDO::FETCH_ASSOC, **PDO::FETCH_ORI_PRIOR**));
  1. MySQL 和 SQLite 都不支持可滚动的游标!要实现相同的结果,请尝试对上述代码进行以下修改:
$dsn  = sprintf('mysql:charset=UTF8;host=%s;dbname=%s', $params['host'], $params['db']);
$opts = [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]; 
$pdo  = new PDO($dsn, $params['user'], $params['pwd'], $opts);
$sql  = 'SELECT * FROM customer '
    . 'WHERE balance > :min AND balance < :max '
    . 'ORDER BY id **DESC** 
       . 'LIMIT 20';
$stmt = $pdo->prepare($sql);
while ($row = $stmt->fetch(PDO::FETCH_ASSOC));
printf('%4d | %20s | %5s | %8.2f' . PHP_EOL, 
       $row['id'], 
       $row['name'], 
       $row['level'], 
       $row['balance']);
} 
  1. PDO 提供了对事务的支持。借用第 9 步的代码,我们可以将INSERT系列命令包装到一个事务块中:
try {
    $pdo->beginTransaction();
    $sql  = "INSERT INTO customer ('" 
    . implode("','", $fields) . "') VALUES (**?,?,?,?,?,?**)";
    $stmt = $pdo->prepare($sql);
    foreach ($data as $row) $stmt->execute($row);
    $pdo->commit();
} catch (PDOException $e) {
    error_log($e->getMessage());
    $pdo->rollBack();
}
  1. 最后,为了保持一切模块化和可重用,我们可以将 PDO 连接封装到一个单独的类Application\Database\Connection中。在这里,我们通过构造函数建立连接。另外,还有一个静态的factory()方法,让我们生成一系列 PDO 实例:
namespace Application\Database;
use Exception;
use PDO;
class Connection
{
    const ERROR_UNABLE = 'ERROR: no database connection';
    public $pdo;
    public function __construct(array $config)
    {
        if (!isset($config['driver'])) {
            $message = __METHOD__ . ' : ' 
            . self::ERROR_UNABLE . PHP_EOL;
            throw new Exception($message);
        }
        $dsn = $this->makeDsn($config);        
        try {
            $this->pdo = new PDO(
                $dsn, 
                $config['user'], 
                $config['password'], 
                [PDO::ATTR_ERRMODE => $config['errmode']]);
            return TRUE;
        } catch (PDOException $e) {
            error_log($e->getMessage());
            return FALSE;
        }
    }

    public static function factory(
      $driver, $dbname, $host, $user, 
      $pwd, array $options = array())
    {
        $dsn = $this->makeDsn($config);

        try {
            return new PDO($dsn, $user, $pwd, $options);
        } catch (PDOException $e) {
            error_log($e->getMessage);
        }
    }
  1. 这个Connection类的一个重要组成部分是一个通用方法,用于构造 DSN。我们需要的一切就是将PDODriver作为前缀,后面跟着“:”。之后,我们只需从配置数组中追加键值对。每个键值对之间用分号分隔。我们还需要使用substr()来去掉末尾的分号,为此目的使用了负限制:
  public function makeDsn($config)
  {
    $dsn = $config['driver'] . ':';
    unset($config['driver']);
    foreach ($config as $key => $value) {
      $dsn .= $key . '=' . $value . ';';
    }
    return substr($dsn, 0, -1);
  }
}

它是如何工作...

首先,您可以将第 1 步中的初始连接代码复制到一个名为chap_05_pdo_connect_mysql.php的文件中。为了说明的目的,我们假设您已经创建了一个名为php7cookbook的 MySQL 数据库,用户名为 cook,密码为 book。接下来,我们使用PDO::query()方法向数据库发送一个简单的 SQL 语句。最后,我们使用生成的语句对象以关联数组的形式获取结果。不要忘记将您的代码放在try {} catch {}块中:

<?php
$params = [
  'host' => 'localhost',
  'user' => 'test',
  'pwd'  => 'password',
  'db'   => 'php7cookbook'
];
try {
  $dsn  = sprintf('mysql:charset=UTF8;host=%s;dbname=%s',
    $params['host'], $params['db']);
  $pdo  = new PDO($dsn, $params['user'], $params['pwd']);
  $stmt = $pdo->query(
    'SELECT * FROM customer ORDER BY id LIMIT 20');
  printf('%4s | %20s | %5s | %7s' . PHP_EOL, 
    'ID', 'NAME', 'LEVEL', 'BALANCE');
  printf('%4s | %20s | %5s | %7s' . PHP_EOL, 
    '----', str_repeat('-', 20), '-----', '-------');
  while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) {
    printf('%4d | %20s | %5s | %7.2f' . PHP_EOL, 
    $row['id'], $row['name'], $row['level'], $row['balance']);
  }
} catch (PDOException $e) {
  error_log($e->getMessage());
} catch (Throwable $e) {
  error_log($e->getMessage());
}

以下是生成的输出:

它是如何工作的...

将选项添加到 PDO 构造函数中,将错误模式设置为EXCEPTION。现在修改 SQL 语句并观察生成的错误消息:

$opts = [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION];
$pdo  = new PDO($dsn, $params['user'], $params['pwd'], $opts);
$stmt = $pdo->query('THIS SQL STATEMENT WILL NOT WORK');

您会看到类似这样的东西:

它是如何工作的...

占位符可以是命名的或位置的。命名占位符在准备的 SQL 语句中以冒号(:)开头,并且在提供给execute()的关联数组中作为键引用。位置占位符在准备的 SQL 语句中表示为问号(?)。

在以下示例中,使用命名占位符来表示WHERE子句中的值:

try {
  $dsn  = sprintf('mysql:host=%s;dbname=%s', 
                  $params['host'], $params['db']);
  $pdo  = new PDO($dsn, 
                  $params['user'], 
                  $params['pwd'], 
                  [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]);
  $sql  = 'SELECT * FROM customer '
      . 'WHERE balance < **:val** AND level = **:level** '
      . 'ORDER BY id LIMIT 20'; echo $sql . PHP_EOL;
  $stmt = $pdo->prepare($sql);
  $stmt->execute(['**val**' => 100, '**level**' => 'BEG']);
  while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) {
    printf('%4d | %20s | %5s | %5.2f' . PHP_EOL, 
      	$row['id'], $row['name'], $row['level'], $row['balance']);
  }
} catch (PDOException $e) {
  echo $e->getMessage();
} catch (Throwable $e) {
  echo $e->getMessage();
}

这个例子展示了在INSERT操作中使用位置占位符。请注意,要插入的作为第四个客户的数据包括潜在的 SQL 注入攻击。您还会注意到,需要对正在使用的数据库的 SQL 语法有一定的了解。在这种情况下,MySQL 列名使用反引号(')引用:

$fields = ['name', 'balance', 'email', 
           'password', 'status', 'level'];
$data = [
  ['Saleen',0,'saleen@test.com', 'password',0,'BEG'],
  ['Lada',55.55,'lada@test.com',   'password',0,'INT'],
  ['Tonsoi',999.99,'tongsoi@test.com','password',1,'ADV'],
  ['SQL Injection',0.00,'bad','bad',1,
   'BEG\';DELETE FROM customer;--'],
];

try {
  $dsn  = sprintf('mysql:host=%s;dbname=%s', 
    $params['host'], $params['db']);
  $pdo  = new PDO($dsn, 
                  $params['user'], 
                  $params['pwd'], 
                  [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]);
  $sql  = "INSERT INTO customer ('" 
   . implode("','", $fields) 
   . "') VALUES (**?,?,?,?,?,?**)";
  $stmt = $pdo->prepare($sql);
  foreach ($data as $row) $stmt->execute($row);
} catch (PDOException $e) {
  echo $e->getMessage();
} catch (Throwable $e) {
  echo $e->getMessage();
}

为了测试使用带有命名参数的准备语句,修改 SQL 语句以添加一个WHERE子句,检查余额小于某个特定金额的客户,以及级别等于BEGINTADV(即初级、中级或高级)。不要使用PDO::query(),而是使用PDO::prepare()。在获取结果之前,您必须执行PDOStatement::execute(),提供余额和级别的值:

$sql  = 'SELECT * FROM customer '
     . 'WHERE balance < :val AND level = :level '
     . 'ORDER BY id LIMIT 20';
$stmt = $pdo->prepare($sql);
$stmt->execute(['val' => 100, 'level' => 'BEG']);

以下是生成的输出:

它是如何工作的...

在调用PDOStatement::execute()时,您可以选择绑定参数。这允许您将变量分配给占位符。在执行时,将使用变量的当前值。

在这个例子中,我们将变量$min$max$level绑定到准备好的语句中:

$min   = 0;
$max   = 0;
$level = '';

try {
  $dsn  = sprintf('mysql:host=%s;dbname=%s', $params['host'], $params['db']);
  $opts = [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION];
  $pdo  = new PDO($dsn, $params['user'], $params['pwd'], $opts);
  $sql  = 'SELECT * FROM customer '
      . 'WHERE balance > :min '
      . 'AND balance < :max AND level = :level '
      . 'ORDER BY id LIMIT 20';
  $stmt = $pdo->prepare($sql);
  **$stmt->bindParam('min',   $min);**
 **$stmt->bindParam('max',   $max);**
 **$stmt->bindParam('level', $level);**

  $min   =  5000;
  $max   = 10000;
  $level = 'ADV';
  $stmt->execute();
  showResults($stmt, $min, $max, $level);

  $min   = 0;
  $max   = 100;
  $level = 'BEG';
  $stmt->execute();
  showResults($stmt, $min, $max, $level);

} catch (PDOException $e) {
  echo $e->getMessage();
} catch (Throwable $e) {
  echo $e->getMessage();
}

当这些变量的值发生变化时,下一次执行将反映修改后的条件。

提示

最佳实践

对于一次性数据库命令,请使用PDO::query()。当您需要多次处理相同的语句但使用不同的值时,请使用PDO::prepare()PDOStatement::execute()

另请参阅

有关与不同供应商特定 PDO 驱动程序相关的语法和独特行为的信息,请参阅本文:

有关 PDO 预定义常量的摘要,包括获取模式、游标方向和属性,请参阅以下文章:

构建面向对象的 SQL 查询构建器

PHP 7 实现了一种称为上下文敏感词法分析器的东西。这意味着通常保留的单词可以在上下文允许的情况下使用。因此,当构建面向对象的 SQL 构建器时,我们可以使用命名为andornot等的方法。

如何做...

  1. 我们定义一个Application\Database\Finder类。在这个类中,我们定义与我们喜欢的 SQL 操作相匹配的方法:
namespace Application\Database;
class Finder
{
  public static $sql      = '';
  public static $instance = NULL;
  public static $prefix   = '';
  public static $where    = array();
  public static $control  = ['', ''];

    // $a == name of table
    // $cols = column names
    public static function select($a, $cols = NULL)
    {
      self::$instance  = new Finder();
      if ($cols) {
           self::$prefix = 'SELECT ' . $cols . ' FROM ' . $a;
      } else {
        self::$prefix = 'SELECT * FROM ' . $a;
      }
      return self::$instance;
    }

    public static function where($a = NULL)
    {
        self::$where[0] = ' WHERE ' . $a;
        return self::$instance;
    }

    public static function like($a, $b)
    {
        self::$where[] = trim($a . ' LIKE ' . $b);
        return self::$instance;
    }

    public static function and($a = NULL)
    {
        self::$where[] = trim('AND ' . $a);
        return self::$instance;
    }

    public static function or($a = NULL)
    {
        self::$where[] = trim('OR ' . $a);
        return self::$instance;
    }

    public static function in(array $a)
    {
        self::$where[] = 'IN ( ' . implode(',', $a) . ' )';
        return self::$instance;
    }

    public static function not($a = NULL)
    {
        self::$where[] = trim('NOT ' . $a);
        return self::$instance;
    }

    public static function limit($limit)
    {
        self::$control[0] = 'LIMIT ' . $limit;
        return self::$instance;
    }

    public static function offset($offset)
    {
        self::$control[1] = 'OFFSET ' . $offset;
        return self::$instance;
    }

  public static function getSql()
  {
    self::$sql = self::$prefix
       . implode(' ', self::$where)
               . ' '
               . self::$control[0]
               . ' '
               . self::$control[1];
    preg_replace('/  /', ' ', self::$sql);
    return trim(self::$sql);
  }
}
  1. 用于生成 SQL 片段的每个函数都返回相同的属性$instance。这使我们能够使用流畅的接口来表示代码,例如:
$sql = Finder::select('project')->where('priority > 9') ... etc.

它是如何工作的...

将前面定义的代码复制到Application\Database文件夹中的Finder.php文件中。然后,您可以创建一个调用程序chap_05_oop_query_builder.php,该程序初始化了第一章中定义的自动加载程序,建立基础。然后,您可以运行Finder::select()来生成一个对象,从中可以呈现 SQL 字符串:

<?php
require __DIR__ . '/../Application/Autoload/Loader.php';
Application\Autoload\Loader::init(__DIR__ . '/..');
use Application\Database\Finder;

$sql = Finder::select('project')
  ->where()
  ->like('name', '%secret%')
  ->and('priority > 9')
  ->or('code')->in(['4', '5', '7'])
  ->and()->not('created_at')
  ->limit(10)
  ->offset(20);

echo Finder::getSql();

这是上述代码的结果:

它是如何工作的...

另请参阅

有关上下文敏感词法分析器的更多信息,请参阅本文:

wiki.php.net/rfc/context_sensitive_lexer

处理分页

分页涉及提供数据库查询结果的有限子集。这通常是为了显示目的,但也可以轻松应用于其他情况。乍一看,似乎LimitIterator类非常适合分页的目的。然而,在潜在结果集可能非常庞大的情况下,LimitIterator并不是一个理想的选择,因为您需要提供整个结果集作为内部迭代器,这很可能会超出内存限制。LimitIterator类构造函数的第二个和第三个参数是偏移和计数。这表明我们将采用的分页解决方案,这是 SQL 本身的一部分:向给定的 SQL 语句添加LIMITOFFSET子句。

如何做...

  1. 首先,我们创建一个名为Application\Database\Paginate的类来保存分页逻辑。我们添加属性来表示与分页相关的值,$sql$page$linesPerPage
namespace Application\Database;

class Paginate
{

  const DEFAULT_LIMIT  = 20;
  const DEFAULT_OFFSET = 0;

  protected $sql;
  protected $page;
  protected $linesPerPage;

}
  1. 接下来,我们定义一个__construct()方法,它接受基本 SQL 语句、当前页码和每页行数作为参数。然后,我们需要重构 SQL 字符串,修改或添加LIMITOFFSET子句。

  2. 在构造函数中,我们需要使用当前页码和每页行数来计算偏移量。我们还需要检查 SQL 语句中是否已经存在LIMITOFFSET。最后,我们需要使用每页行数作为我们的LIMIT,使用重新计算的OFFSET来修改语句:

public function __construct($sql, $page, $linesPerPage)
{
  $offset = $page * $linesPerPage;
  switch (TRUE) {
    case (stripos($sql, 'LIMIT') && strpos($sql, 'OFFSET')) :
      // no action needed
      break;
    case (stripos($sql, 'LIMIT')) :
      $sql .= ' LIMIT ' . self::DEFAULT_LIMIT;
      break;
    case (stripos($sql, 'OFFSET')) :
      $sql .= ' OFFSET ' . self::DEFAULT_OFFSET;
      break;
    default :
      $sql .= ' LIMIT ' . self::DEFAULT_LIMIT;
      $sql .= ' OFFSET ' . self::DEFAULT_OFFSET;
      break;
  }
  $this->sql = preg_replace('/LIMIT \d+.*OFFSET \d+/Ui', 
     'LIMIT ' . $linesPerPage . ' OFFSET ' . $offset, 
     $sql);
}
  1. 现在,我们已经准备好使用第一篇食谱中讨论的Application\Database\Connection类来执行查询。

  2. 在我们的新分页类中,我们添加了一个paginate()方法,它以Connection实例作为参数。我们还需要 PDO 获取模式和可选的准备好的语句参数:

use PDOException;
public function paginate(
  Connection $connection, 
  $fetchMode, 
  $params = array())
  {
  try {
    $stmt = $connection->pdo->prepare($this->sql);
    if (!$stmt) return FALSE;
    if ($params) {
      $stmt->execute($params);
    } else {
      $stmt->execute();
    }
    while ($result = $stmt->fetch($fetchMode)) yield $result;
  } catch (PDOException $e) {
    error_log($e->getMessage());
    return FALSE;
  } catch (Throwable $e) {
    error_log($e->getMessage());
    return FALSE;
  }
}
  1. 为了提供对前面一篇食谱中提到的查询构建器类的支持可能是个好主意。这将使更新LIMITOFFSET变得更容易。我们需要做的就是为Application\Database\Finder提供支持,使用该类并修改__construct()方法以检查传入的 SQL 是否是这个类的实例:
  if ($sql instanceof Finder) {
    $sql->limit($linesPerPage);
    $sql->offset($offset);
    $this->sql = $sql::getSql();
  } elseif (is_string($sql)) {
    switch (TRUE) {
      case (stripos($sql, 'LIMIT') 
      && strpos($sql, 'OFFSET')) :
          // remaining code as shown in bullet #3 above
      }
   }
  1. 现在剩下要做的就是添加一个getSql()方法,以便在需要确认 SQL 语句是否正确形成时使用:
public function getSql()
{
  return $this->sql;
}

它是如何工作的...

将上述代码复制到 Application/Database 文件夹中的 Paginate.php 文件中。然后可以创建一个 chap_05_pagination.php 调用程序,该程序初始化了第一章中定义的自动加载程序,建立基础

<?php
define('DB_CONFIG_FILE', '/../config/db.config.php');
define('LINES_PER_PAGE', 10);
define('DEFAULT_BALANCE', 1000);
require __DIR__ . '/../Application/Autoload/Loader.php';
Application\Autoload\Loader::init(__DIR__ . '/..');

接下来,使用 Application\Database\FinderConnectionPaginate 类,创建一个 Application\Database\Connection 实例,并使用 Finder 生成 SQL:

use Application\Database\ { Finder, Connection, Paginate};
$conn = new Connection(include __DIR__ . DB_CONFIG_FILE);
$sql = Finder::select('customer')->where('balance < :bal');

现在,我们可以从 $_GET 参数中获取页码和余额,并创建 Paginate 对象,结束 PHP 代码块:

$page = (int) ($_GET['page'] ?? 0);
$bal  = (float) ($_GET['balance'] ?? DEFAULT_BALANCE);
$paginate = new Paginate($sql::getSql(), $page, LINES_PER_PAGE);
?>

在脚本的输出部分,我们只需使用简单的 foreach() 循环迭代通过分页:

<h3><?= $paginate->getSql(); ?></h3>	
<hr>
<pre>
<?php
printf('%4s | %20s | %5s | %7s' . PHP_EOL, 
  'ID', 'NAME', 'LEVEL', 'BALANCE');
printf('%4s | %20s | %5s | %7s' . PHP_EOL, 
  '----', str_repeat('-', 20), '-----', '-------');
foreach ($paginate->paginate($conn, PDO::FETCH_ASSOC, 
  ['bal' => $bal]) as $row) {
  printf('%4d | %20s | %5s | %7.2f' . PHP_EOL, 
      $row['id'],$row['name'],$row['level'],$row['balance']);
}
printf('%4s | %20s | %5s | %7s' . PHP_EOL, 
  '----', str_repeat('-', 20), '-----', '-------');
?>
<a href="?page=<?= $page - 1; ?>&balance=<?= $bal ?>">
<< Prev </a>&nbsp;&nbsp;
<a href="?page=<?= $page + 1; ?>&balance=<?= $bal ?>">
Next >></a>
</pre>

以下是输出的第 3 页,余额小于 1,000:

工作原理...

另请参阅

有关 LimitIterator 类的更多信息,请参阅本文:

定义与数据库表匹配的实体

PHP 开发人员中非常常见的做法是创建代表数据库表的类。这些类通常被称为实体类,并且构成领域模型软件设计模式的核心。

如何做...

  1. 首先,我们将确定一系列实体类的一些共同特征。这些可能包括共同的属性和共同的方法。我们将把这些放入 Application\Entity\Base 类中。然后,所有未来的实体类都将扩展 Base

  2. 为了说明的目的,让我们假设所有实体都有两个共同的属性:$mapping(稍后讨论)和 $id(及其相应的 getter 和 setter):

namespace Application\Entity;

class Base
{

  protected $id = 0;
  protected $mapping = ['id' => 'id'];

  public function getId() : int
  {
    return $this->id;
  }

  public function setId($id)
  {
    $this->id = (int) $id;
  }
}
  1. 定义一个 arrayToEntity() 方法并不是一个坏主意,它将数组转换为实体类的实例,反之亦然(entityToArray())。这些方法实现了一个经常被称为水合的过程。由于这些方法应该是通用的,因此最好将它们放在 Base 类中。

  2. 在以下方法中,$mapping 属性用于在数据库列名和对象属性名之间进行转换。arrayToEntity() 从数组中填充此对象实例的值。我们可以定义此方法为静态方法,以防需要在活动实例之外调用它:

public static function arrayToEntity($data, Base $instance)
{
  if ($data && is_array($data)) {
    foreach ($instance->mapping as $dbColumn => $propertyName) {
      $method = 'set' . ucfirst($propertyName);
      $instance->$method($data[$dbColumn]);
    }
    return $instance;
  }
  return FALSE;
}
  1. entityToArray() 从当前实例属性值生成数组:
public function entityToArray()
{
  $data = array();
  foreach ($this->mapping as $dbColumn => $propertyName) {
    $method = 'get' . ucfirst($propertyName);
    $data[$dbColumn] = $this->$method() ?? NULL;
  }
  return $data;
}
  1. 要构建特定的实体,您需要手头有要建模的数据库表的结构。创建映射到数据库列的属性。分配的初始值应反映数据库列的最终数据类型。

  2. 在此示例中,我们将使用 customer 表。以下是来自 MySQL 数据转储的 CREATE 语句,说明了其数据结构:

CREATE TABLE 'customer' (
  'id' int(11) NOT NULL AUTO_INCREMENT,
  'name' varchar(256) CHARACTER SET latin1 COLLATE latin1_general_cs NOT NULL,
  'balance' decimal(10,2) NOT NULL,
  'email' varchar(250) NOT NULL,
  'password' char(16) NOT NULL,
  'status' int(10) unsigned NOT NULL DEFAULT '0',
  'security_question' varchar(250) DEFAULT NULL,
  'confirm_code' varchar(32) DEFAULT NULL,
  'profile_id' int(11) DEFAULT NULL,
  'level' char(3) NOT NULL,
  PRIMARY KEY ('id'),
  UNIQUE KEY 'UNIQ_81398E09E7927C74' ('email')
);
  1. 现在我们可以填充类属性。这也是确定相应表的好地方。在这种情况下,我们将使用 TABLE_NAME 类常量:
namespace Application\Entity;

class Customer extends Base
{
  const TABLE_NAME = 'customer';
  protected $name = '';
  protected $balance = 0.0;
  protected $email = '';
  protected $password = '';
  protected $status = '';
  protected $securityQuestion = '';
  protected $confirmCode = '';
  protected $profileId = 0;
  protected $level = '';
}
  1. 将属性定义为 protected 被认为是最佳实践。为了访问这些属性,您需要设计 public 方法来 getset 属性。这是一个很好的地方,可以利用 PHP 7 对返回值进行数据类型定义。

  2. 在以下代码块中,我们已经为 $name$balance 定义了 getter 和 setter。您可以想象其余这些方法将如何定义:

  public function getName() : string
  {
    return $this->name;
  }
  public function setName($name)
  {
    $this->name = $name;
  }
  public function getBalance() : float
  {
    return $this->balance;
  }
  public function setBalance($balance)
  {
    $this->balance = (float) $balance;
  }
}

提示

在 setter 中对传入值进行数据类型检查并不是一个好主意。原因是来自 RDBMS 数据库查询的返回值都将是 string 数据类型。

  1. 如果属性名称与相应的数据库列不完全匹配,您应该考虑创建一个 mapping 属性,一个键/值对数组,其中键表示数据库列名,值表示属性名。

  2. 您会注意到,三个属性$securityQuestion$confirmCode$profileId与它们对应的列名security_questionconfirm_codeprofile_id不对应。$mapping属性将确保适当的转换发生:

protected $mapping = [
  'id'                => 'id',
  'name'              => 'name',
  'balance'           => 'balance',
  'email'             => 'email',
  'password'          => 'password',
  'status'            => 'status',
  'security_question' => 'securityQuestion',
  'confirm_code'      => 'confirmCode',
  'profile_id'        => 'profileId',
  'level'             => 'level'
];

它是如何工作的...

将步骤 2、4 和 5 中的代码复制到Application/Entity文件夹中的Base.php文件中。将步骤 8 到 12 中的代码复制到Application/Entity文件夹中的Customer.php文件中。然后,您需要为步骤 10 中未显示的剩余属性emailpasswordstatussecurityQuestionconfirmCodeprofileIdlevel创建 getter 和 setter。

然后,您可以创建一个chap_05_matching_entity_to_table.php调用程序,该程序初始化了第一章中定义的自动加载程序,使用Application\Database\Connection和新创建的Application\Entity\Customer类:

<?php
define('DB_CONFIG_FILE', '/../config/db.config.php');
require __DIR__ . '/../Application/Autoload/Loader.php';
Application\Autoload\Loader::init(__DIR__ . '/..');
use Application\Database\Connection;
use Application\Entity\Customer;

接下来,获取一个数据库连接,并使用连接随机获取一个客户的数据的关联数组:

$conn = new Connection(include __DIR__ . DB_CONFIG_FILE);
$id = rand(1,79);
$stmt = $conn->pdo->prepare(
  'SELECT * FROM customer WHERE id = :id');
$stmt->execute(['id' => $id]);
$result = $stmt->fetch(PDO::FETCH_ASSOC);

最后,您可以从数组中创建一个新的Customer实体实例,并使用var_dump()查看结果:

$cust = Customer::arrayToEntity($result, new Customer());
var_dump($cust);

以下是前面代码的输出:

它是如何工作的...

另请参阅

有许多描述领域模型的好作品。可能最有影响力的是 Martin Fowler 的企业应用架构模式(参见martinfowler.com/books/eaa.html)。还有一份很好的研究,也可以免费下载,名为快速领域驱动设计的 InfoQ(参见www.infoq.com/minibooks/domain-driven-design-quickly)。

将实体类与 RDBMS 查询联系起来

大多数商业上可行的 RDBMS 系统是在过程式编程处于前沿时演变而来的。想象一下 RDBMS 世界是二维的、方形的、过程化的。相比之下,实体可以被认为是圆形的、三维的、面向对象的。这给了你一个关于我们想要通过将 RDBMS 查询的结果与实体实例的迭代联系起来实现的想法。

注意

关系模型,现代 RDBMS 系统所基于的模型,是由数学家 Edgar F. Codd 在 1969 年首次描述的。第一个商业上可行的系统是在 70 年代中期至 70 年代末期演变而来的。换句话说,RDBMS 技术已经有 40 多年的历史了!

如何做...

  1. 首先,我们需要设计一个类,用于容纳我们的查询逻辑。如果你遵循领域模型,这个类可能被称为存储库。或者,为了保持简单和通用,我们可以简单地将新类称为Application\Database\CustomerService。该类将接受一个Application\Database\Connection实例作为参数:
namespace Application\Database;

use Application\Entity\Customer;

class CustomerService
{

    protected $connection;

    public function __construct(Connection $connection)
    {
      $this->connection = $connection;
    }

}
  1. 现在我们将定义一个fetchById()方法,它以客户 ID 作为参数,并在失败时返回单个Application\Entity\Customer实例或布尔值FALSE。乍一看,似乎很简单,只需简单地使用PDOStatement::fetchObject()并将实体类指定为参数:
public function fetchById($id)
{
  $stmt = $this->connection->pdo
               ->prepare(Finder::select('customer')
               ->where('id = :id')::getSql());
  $stmt->execute(['id' => (int) $id]);
  return $stmt->fetchObject('Application\Entity\Customer');
}

注意

然而,这里的危险是fetchObject()实际上在调用构造函数之前填充属性(即使它们是受保护的)!因此,存在构造函数可能意外覆盖值的危险。如果你没有定义构造函数,或者如果你可以接受这种危险,那就完成了。否则,正确实现 RDBMS 查询和 OOP 结果之间的联系就开始变得更加困难。

  1. fetchById()方法的另一种方法是首先创建对象实例,从而运行其构造函数,并将获取模式设置为PDO::FETCH_INTO,如下例所示:
public function fetchById($id)
{
  $stmt = $this->connection->pdo
               ->prepare(Finder::select('customer')
               ->where('id = :id')::getSql());
  $stmt->execute(['id' => (int) $id]);
  $stmt->setFetchMode(PDO::FETCH_INTO, new Customer());
  return $stmt->fetch();
}
  1. 然而,我们在这里又遇到了一个问题:fetch()fetchObject()不同,它无法覆盖受保护的属性;如果尝试覆盖,将生成以下错误消息。这意味着我们要么将所有属性定义为public,要么考虑另一种方法。如何做...

  2. 我们将考虑的最后一种方法是以数组形式获取结果,并手动hydrate实体。尽管这种方法在性能方面略微昂贵,但它允许任何潜在的实体构造函数正常运行,并且可以安全地将属性定义为privateprotected

public function fetchById($id)
{
  $stmt = $this->connection->pdo
               ->prepare(Finder::select('customer')
               ->where('id = :id')::getSql());
  $stmt->execute(['id' => (int) $id]);
  return Customer::arrayToEntity(
    $stmt->fetch(PDO::FETCH_ASSOC));
}
  1. 要处理产生多个结果的查询,我们只需要生成填充的实体对象的迭代。在这个例子中,我们实现了一个fetchByLevel()方法,它以Application\Entity\Customer实例的形式返回给定级别的所有客户:
public function fetchByLevel($level)
{
  $stmt = $this->connection->pdo->prepare(
            Finder::select('customer')
            ->where('level = :level')::getSql());
  $stmt->execute(['level' => $level]);
  while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) {
    yield Customer::arrayToEntity($row, new Customer());
  }
}
  1. 我们希望实现的下一个方法是save()。然而,在我们继续之前,必须考虑如果发生INSERT,将返回什么值。

  2. 通常,我们会在INSERT后返回新完成的实体类。有一个方便的PDO::lastInsertId()方法,乍一看似乎可以解决问题。然而,进一步阅读文档后发现,并非所有的数据库扩展都支持这个特性,而且支持的数据库扩展在实现上也不一致。因此,最好有一个除了$id之外的唯一列,可以用来唯一标识新客户。

  3. 在这个例子中,我们选择了email列,因此需要实现一个fetchByEmail()服务方法:

public function fetchByEmail($email)
{
  $stmt = $this->connection->pdo->prepare(
    Finder::select('customer')
    ->where('email = :email')::getSql());
  $stmt->execute(['email' => $email]);
  return Customer::arrayToEntity(
    $stmt->fetch(PDO::FETCH_ASSOC), new Customer());
}
  1. 现在我们准备定义save()方法。我们不再区分INSERTUPDATE,而是将这个方法设计为如果 ID 已经存在,则更新,否则进行插入。

  2. 首先,我们定义一个基本的save()方法,它接受一个Customer实体作为参数,并使用fetchById()来确定此条目是否已经存在。如果存在,我们调用一个doUpdate()更新方法;否则,我们调用一个doInsert()插入方法:

public function save(Customer $cust)
{
  // check to see if customer ID > 0 and exists
  if ($cust->getId() && $this->fetchById($cust->getId())) {
    return $this->doUpdate($cust);
  } else {
    return $this->doInsert($cust);
  }
}
  1. 接下来,我们定义doUpdate(),它将Customer实体对象的属性提取到一个数组中,构建一个初始的 SQL 语句,并调用flush()方法,将数据推送到数据库。我们不希望 ID 字段被更新,因为它是主键。另外,我们需要指定要更新的行,这意味着要添加一个WHERE子句:
protected function doUpdate($cust)
{
  // get properties in the form of an array
  $values = $cust->entityToArray();
  // build the SQL statement
  $update = 'UPDATE ' . $cust::TABLE_NAME;
  $where = ' WHERE id = ' . $cust->getId();
  // unset ID as we want do not want this to be updated
  unset($values['id']);
  return $this->flush($update, $values, $where);
}
  1. doInsert()方法类似,只是初始的 SQL 需要以INSERT INTO ...开头,并且需要取消id数组元素。后者的原因是我们希望这个属性由数据库自动生成。如果成功,我们使用我们新定义的fetchByEmail()方法查找新客户并返回一个完成的实例:
protected function doInsert($cust)
{
  $values = $cust->entityToArray();
  $email  = $cust->getEmail();
  unset($values['id']);
  $insert = 'INSERT INTO ' . $cust::TABLE_NAME . ' ';
  if ($this->flush($insert, $values)) {
    return $this->fetchByEmail($email);
  } else {
    return FALSE;
  }
}
  1. 最后,我们可以定义flush(),它执行实际的准备和执行:
protected function flush($sql, $values, $where = '')
{
  $sql .=  ' SET ';
  foreach ($values as $column => $value) {
    $sql .= $column . ' = :' . $column . ',';
  }
  // get rid of trailing ','
  $sql     = substr($sql, 0, -1) . $where;
  $success = FALSE;
  try {
    $stmt = $this->connection->pdo->prepare($sql);
    $stmt->execute($values);
    $success = TRUE;
  } catch (PDOException $e) {
    error_log(__METHOD__ . ':' . __LINE__ . ':' 
    . $e->getMessage());
    $success = FALSE;
  } catch (Throwable $e) {
    error_log(__METHOD__ . ':' . __LINE__ . ':' 
    . $e->getMessage());
    $success = FALSE;
  }
  return $success;
}
  1. 为了结束讨论,我们需要定义一个remove()方法,它可以从数据库中删除一个客户。与之前定义的save()方法一样,我们再次使用fetchById()来确保操作成功:
public function remove(Customer $cust)
{
  $sql = 'DELETE FROM ' . $cust::TABLE_NAME . ' WHERE id = :id';
  $stmt = $this->connection->pdo->prepare($sql);
  $stmt->execute(['id' => $cust->getId()]);
  return ($this->fetchById($cust->getId())) ? FALSE : TRUE;
}

它是如何工作的...

将步骤 1 到 5 中描述的代码复制到Application/Database文件夹中的CustomerService.php文件中。定义一个chap_05_entity_to_query.php调用程序。让调用程序初始化自动加载器,使用适当的类:

<?php
define('DB_CONFIG_FILE', '/../config/db.config.php');
require __DIR__ . '/../Application/Autoload/Loader.php';
Application\Autoload\Loader::init(__DIR__ . '/..');
use Application\Database\Connection;
use Application\Database\CustomerService;

现在,您可以创建一个服务的实例,并随机获取一个客户。然后服务将返回一个客户实体作为结果:

// get service instance
$service = new CustomerService(new Connection(include __DIR__ . DB_CONFIG_FILE));

echo "\nSingle Result\n";
var_dump($service->fetchById(rand(1,79)));

这是输出:

它是如何工作的...

现在将步骤 6 到 15 中显示的代码复制到服务类中。将要插入的数据添加到chap_05_entity_to_query.php调用程序中。然后使用这些数据生成一个Customer实体实例:

// sample data
$data = [
  'name'              => 'Doug Bierer',
  'balance'           => 326.33,
  'email'             => 'doug' . rand(0,999) . '@test.com',
  'password'          => 'password',
  'status'            => 1,
  'security_question' => 'Who\'s on first?',
  'confirm_code'      => 12345,
  'level'             => 'ADV'
];

// create new Customer
$cust = Customer::arrayToEntity($data, new Customer());

然后我们可以在调用save()之前和之后检查 ID:

echo "\nCustomer ID BEFORE Insert: {$cust->getId()}\n";
$cust = $service->save($cust);
echo "Customer ID AFTER Insert: {$cust->getId()}\n";

最后,我们修改余额,然后再次调用save(),查看结果:

echo "Customer Balance BEFORE Update: {$cust->getBalance()}\n";
$cust->setBalance(999.99);
$service->save($cust);
echo "Customer Balance AFTER Update: {$cust->getBalance()}\n";
var_dump($cust);

这是调用程序的输出:

工作原理...

还有更多...

有关关系模型的更多信息,请参阅en.wikipedia.org/wiki/Relational_model。有关 RDBMS 的更多信息,请参阅en.wikipedia.org/wiki/Relational_database_management_system。有关PDOStatement::fetchObject()在构造函数之前插入属性值的信息,请查看 php.net 文档参考中关于fetchObject()的"rasmus at mindplay dot dk"的评论(php.net/manual/en/pdostatement.fetchobject.php)。

将辅助查找嵌入到查询结果中

在实现实体类之间的关系之路上,让我们首先看一下如何嵌入执行辅助查找所需的代码。这样一个查找的示例是,在显示客户信息时,视图逻辑执行第二次查找,获取该客户的购买列表。

注意

这种方法的优势在于,处理被推迟直到实际视图逻辑被执行。这将最终平滑性能曲线,工作负载在客户信息的初始查询和后来的购买信息查询之间更均匀地分布。另一个好处是避免了大量的JOIN及其固有的冗余数据。

如何做...

  1. 首先,定义一个根据其 ID 查找客户的函数。为了说明这一点,我们将简单地使用PDO::FETCH_ASSOC的获取模式获取一个数组。我们还将继续使用第一章中讨论的Application\Database\Connection类,建立基础
function findCustomerById($id, Connection $conn)
{
  $stmt = $conn->pdo->query(
    'SELECT * FROM customer WHERE id = ' . (int) $id);
  $results = $stmt->fetch(PDO::FETCH_ASSOC);
  return $results;
}
  1. 接下来,我们分析购买表,看看customerproduct表是如何关联的。从这个表的CREATE语句中可以看出,customer_idproduct_id外键形成了关系:
CREATE TABLE 'purchases' (
  'id' int(11) NOT NULL AUTO_INCREMENT,
  'transaction' varchar(8) NOT NULL,
  'date' datetime NOT NULL,
  'quantity' int(10) unsigned NOT NULL,
  'sale_price' decimal(8,2) NOT NULL,
  'customer_id' int(11) DEFAULT NULL,
  'product_id' int(11) DEFAULT NULL,
  PRIMARY KEY ('id'),
  KEY 'IDX_C3F3' ('customer_id'),
  KEY 'IDX_665A' ('product_id'),
  CONSTRAINT 'FK_665A' FOREIGN KEY ('product_id') 
  REFERENCES 'products' ('id'),
  CONSTRAINT 'FK_C3F3' FOREIGN KEY ('customer_id') 
  REFERENCES 'customer' ('id')
);
  1. 我们现在扩展原始的findCustomerById()函数,定义形式为匿名函数的辅助查找,然后可以在视图脚本中执行。将匿名函数分配给$results['purchases']元素:
function findCustomerById($id, Connection $conn)
{
  $stmt = $conn->pdo->query(
       'SELECT * FROM customer WHERE id = ' . (int) $id);
  $results = $stmt->fetch(PDO::FETCH_ASSOC);
  if ($results) {
    $results['purchases'] = 
      // define secondary lookup
 **function ($id, $conn) {**
 **$sql = 'SELECT * FROM purchases AS u '**
 **. 'JOIN products AS r '**
 **. 'ON u.product_id = r.id '**
 **. 'WHERE u.customer_id = :id '**
 **. 'ORDER BY u.date';**
 **$stmt = $conn->pdo->prepare($sql);**
 **$stmt->execute(['id' => $id]);**
 **while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) {**
 **yield $row;**
 **}**
 **};**
  }
  return $results;
}
  1. 假设我们已成功将客户信息检索到$results数组中,在视图逻辑中,我们所需要做的就是循环遍历匿名函数的返回值。在这个例子中,我们随机检索客户信息:
$result = findCustomerById(rand(1,79), $conn);
  1. 在视图逻辑中,我们循环遍历辅助查找返回的结果。嵌入的匿名函数的调用在以下代码中突出显示:
<table>
  <tr>
<th>Transaction</th><th>Date</th><th>Qty</th>
<th>Price</th><th>Product</th>
  </tr>
<?php 
foreach (**$result'purchases' as $purchase) : ?>
  <tr>
    <td><?= $purchase['transaction'] ?></td>
    <td><?= $purchase['date'] ?></td>
    <td><?= $purchase['quantity'] ?></td>
    <td><?= $purchase['sale_price'] ?></td>
    <td><?= $purchase['title'] ?></td>
  </tr>
<?php endforeach; ?>
</table>

工作原理...

创建一个chap_05_secondary_lookups.php调用程序,并插入所需的代码以创建Application\Database\Connection的实例:

<?php
define('DB_CONFIG_FILE', '/../config/db.config.php');
include __DIR__ . '/../Application/Database/Connection.php';
use Application\Database\Connection;
$conn = new Connection(include __DIR__ . DB_CONFIG_FILE);

接下来,在步骤 3 中显示的findCustomerById()函数中添加。然后,您可以获取随机客户的信息,结束调用程序的 PHP 部分:

function findCustomerById($id, Connection $conn)
{
  // code shown in bullet #3 above
}
$result = findCustomerById(rand(1,79), $conn);
?>

对于视图逻辑,您可以显示核心客户信息,就像在前面的几个示例中所示的那样:

<h1><?= $result['name'] ?></h1>
<div class="row">
<div class="left">Balance</div>
<div class="right"><?= $result['balance']; ?></div>
</div>
<!-- etc.l -->

您可以这样显示购买信息:

<table>
<tr><th>Transaction</th><th>Date</th><th>Qty</th>
<th>Price</th><th>Product</th></tr>
  <?php 
  foreach **($result'purchases' as $purchase)** : ?>
  <tr>
    <td><?= $purchase['transaction'] ?></td>
    <td><?= $purchase['date'] ?></td>
    <td><?= $purchase['quantity'] ?></td>
    <td><?= $purchase['sale_price'] ?></td>
    <td><?= $purchase['title'] ?></td>
  </tr>
<?php endforeach; ?>
</table>

关键的一点是,通过调用嵌入的匿名函数$result'purchases',辅助查找作为视图逻辑的一部分执行。这是输出:

工作原理...

实现 jQuery DataTables PHP 查找

进行次要查找的另一种方法是让前端生成请求。在这个食谱中,我们将对前面食谱中介绍的次要查找代码进行轻微修改,将次要查找嵌入到 QueryResults 中。在以前的食谱中,即使视图逻辑执行查找,所有处理仍然在服务器上完成。但是,当使用jQuery DataTables时,次要查找实际上是由客户端直接执行的,以异步 JavaScript 和 XMLAJAX)请求的形式由浏览器发出。

如何做...

  1. 首先,我们需要将上面讨论的次要查找逻辑(在上面的食谱中讨论)分离到一个单独的 PHP 文件中。这个新脚本的目的是执行次要查找并返回一个 JSON 数组。

  2. 新的脚本我们将称之为chap_05_jquery_datatables_php_lookups_ajax.php。它寻找一个$_GET参数,id。请注意,SELECT语句非常具体,以确定传递了哪些列。您还会注意到,提取模式已更改为PDO::FETCH_NUM。您可能还会注意到,最后一行将结果取出并将其分配给 JSON 编码数组中的data键。

提示

在处理零配置 jQuery DataTables 时,非常重要的一点是只返回与标题匹配的确切列数。

$id  = $_GET['id'] ?? 0;
sql = 'SELECT u.transaction,u.date, **u.quantity,u.sale_price,r.title '**
   . 'FROM purchases AS u '
   . 'JOIN products AS r '
   . 'ON u.product_id = r.id '
   . 'WHERE u.customer_id = :id';
$stmt = $conn->pdo->prepare($sql);
$stmt->execute(['id' => (int) $id]);
$results = array();
while ($row = $stmt->fetch(**PDO::FETCH_NUM**)) {
  $results[] = $row;
}
echo json_encode(['data' => $results]); 
  1. 接下来,我们需要修改通过 ID 检索客户信息的函数,删除在前面食谱中嵌入的次要查找:
function findCustomerById($id, Connection $conn)
{
  $stmt = $conn->pdo->query(
    'SELECT * FROM customer WHERE id = ' . (int) $id);
  $results = $stmt->fetch(PDO::FETCH_ASSOC);
  return $results;
}
  1. 之后,在视图逻辑中,我们导入最少的 jQuery,DataTables 和样式表,以实现零配置。至少,您将需要 jQuery 本身(在本例中为jquery-1.12.0.min.js)和 DataTables(jquery.dataTables.js)。我们还添加了一个方便的与 DataTables 关联的样式表,jquery.dataTables.css
<!DOCTYPE html>
<head>
  <script src="https://code.jquery.com/jquery-1.12.0.min.js">
  </script>
    <script type="text/javascript" 
      charset="utf8" 
      src="//cdn.datatables.net/1.10.11/js/jquery.dataTables.js">
    </script>
  <link rel="stylesheet" 
    type="text/css" 
    href="//cdn.datatables.net/1.10.11/css/jquery.dataTables.css">
</head>
  1. 然后我们定义一个 jQuery 文档ready函数,将一个表格与 DataTables 关联起来。在这种情况下,我们将 id 属性customerTable分配给将分配给 DataTables 的表元素。您还会注意到,我们将 AJAX 数据源指定为步骤 1 中定义的脚本chap_05_jquery_datatables_php_lookups_ajax.php。由于我们有$id可用,因此将其附加到数据源 URL 中:
<script>
$(document).ready(function() {
  $('#customerTable').DataTable(
    { "ajax": '/chap_05_jquery_datatables_php_lookups_ajax.php?id=<?= $id ?>' 
  });
} );
</script>
  1. 在视图逻辑的主体中,我们定义表格,确保id属性与前面的代码中指定的一致。我们还需要定义标题,以匹配响应 AJAX 请求呈现的数据:
<table id="customerTable" class="display" cellspacing="0" width="100%">
  <thead>
    <tr>
      <th>Transaction</th>
      <th>Date</th>
      <th>Qty</th>
      <th>Price</th>
      <th>Product</th>
    </tr>
  </thead>
</table>
  1. 现在,剩下的就是加载页面,选择客户 ID(在这种情况下是随机选择),并让 jQuery 发出次要查找的请求。

工作原理...

创建一个chap_05_jquery_datatables_php_lookups_ajax.php脚本,用于响应 AJAX 请求。在其中,放置初始化自动加载和创建Connection实例的代码。然后,您可以附加前面食谱中步骤 2 中显示的代码:

<?php
define('DB_CONFIG_FILE', '/../config/db.config.php');
include __DIR__ . '/../Application/Database/Connection.php';
use Application\Database\Connection;
$conn = new Connection(include __DIR__ . DB_CONFIG_FILE);

接下来,创建一个chap_05_jquery_datatables_php_lookups.php调用程序,将随机客户的信息提取出来。添加前面代码中描述的步骤 3 中的函数:

<?php
define('DB_CONFIG_FILE', '/../config/db.config.php');
include __DIR__ . '/../Application/Database/Connection.php';
use Application\Database\Connection;
$conn = new Connection(include __DIR__ . DB_CONFIG_FILE);
// add function findCustomerById() here
$id     = random_int(1,79);
$result = findCustomerById($id, $conn);
?>

调用程序还将包含导入最少 JavaScript 以实现 jQuery DataTables 的视图逻辑。您可以添加前面代码中显示的步骤 3 中的代码。然后,添加文档ready函数和显示逻辑,如步骤 5 和 6 中所示。这是输出:

工作原理...

还有更多...

有关 jQuery 的更多信息,请访问他们的网站jquery.com/。要了解有关 jQuery 的 DataTables 插件的信息,请参阅此文章www.datatables.net/。零配置数据表的讨论在datatables.net/examples/basic_init/zero_configuration.html。有关 AJAX 数据来源的更多信息,请查看datatables.net/examples/data_sources/ajax.html

第六章:构建可扩展的网站

在本章中,我们将涵盖以下主题:

  • 创建一个通用的表单元素生成器

  • 创建一个 HTML 单选按钮元素生成器

  • 创建一个 HTML 选择元素生成器

  • 实现一个表单工厂

  • 链接$_POST过滤器

  • 链接$_POST验证器

  • 将验证与表单绑定

介绍

在本章中,我们将向您展示如何构建生成 HTML 表单元素的类。通用元素生成器可用于文本、文本区域、密码和类似的 HTML 输入类型。之后,我们将展示允许您使用值数组预配置元素的变体。表单工厂配方将把所有这些生成器结合在一起,允许您使用单个配置数组渲染整个表单。最后,我们介绍允许过滤和验证传入$_POST数据的配方。

创建一个通用的表单元素生成器

创建一个简单的函数来输出一个表单输入标签,比如<input type="text" name="whatever" >。然而,为了使表单生成器通用有用,我们需要考虑更大的问题。除了基本的输入标签之外,我们还需要考虑一些其他方面:

  • 表单input标签及其相关的 HTML 属性

  • 告诉用户他们正在输入什么信息的标签

  • 显示验证后的输入错误的能力(稍后详细介绍!)

  • 某种包装器,比如<div>标签,或者 HTML 表格<td>标签

如何做...

  1. 首先,我们定义一个Application\Form\Generic类。这也将作为专门的表单元素的基类:
namespace Application\Form;

class Generic
{
  // some code ...
}
  1. 接下来,我们定义一些类常量,这些常量在表单元素生成中通常很有用。

  2. 前三个将成为与单个表单元素的主要组件相关联的键。然后我们定义支持的输入类型和默认值:

const ROW = 'row';
const FORM = 'form';
const INPUT = 'input';
const LABEL = 'label';
const ERRORS = 'errors';
const TYPE_FORM = 'form';
const TYPE_TEXT = 'text';
const TYPE_EMAIL = 'email';
const TYPE_RADIO = 'radio';
const TYPE_SUBMIT = 'submit';
const TYPE_SELECT = 'select';
const TYPE_PASSWORD = 'password';
const TYPE_CHECKBOX = 'checkbox';
const DEFAULT_TYPE = self::TYPE_TEXT;
const DEFAULT_WRAPPER = 'div';
  1. 接下来,我们可以定义属性和设置它们的构造函数。

  2. 在这个例子中,我们需要两个属性$name$type,因为没有这些属性我们无法有效地使用元素。其他构造函数参数是可选的。此外,为了基于另一个表单元素,我们包括一个规定,第二个参数$type可以替代地是Application\Form\Generic的实例,这样我们只需运行getters(稍后讨论)来填充属性:

protected $name;
protected $type    = self::DEFAULT_TYPE;
protected $label   = '';
protected $errors  = array();
protected $wrappers;
protected $attributes;    // HTML form attributes
protected $pattern =  '<input type="%s" name="%s" %s>';

public function __construct($name, 
                $type, 
                $label = '',
                array $wrappers = array(), 
                array $attributes = array(),
                array $errors = array())
{
  $this->name = $name;
  if ($type instanceof Generic) {
      $this->type       = $type->getType();
      $this->label      = $type->getLabelValue();
      $this->errors     = $type->getErrorsArray();
      $this->wrappers   = $type->getWrappers();
      $this->attributes = $type->getAttributes();
  } else {
      $this->type       = $type ?? self::DEFAULT_TYPE;
      $this->label      = $label;
      $this->errors     = $errors;
      $this->attributes = $attributes;
      if ($wrappers) {
          $this->wrappers = $wrappers;
      } else {
          $this->wrappers[self::INPUT]['type'] =
            self::DEFAULT_WRAPPER;
          $this->wrappers[self::LABEL]['type'] = 
            self::DEFAULT_WRAPPER;
          $this->wrappers[self::ERRORS]['type'] = 
            self::DEFAULT_WRAPPER;
    }
  }
  $this->attributes['id'] = $name;
}

注意

请注意,$wrappers有三个主要的子键:INPUTLABELERRORS。这使我们能够为标签、输入标签和错误定义单独的包装器。

  1. 在定义将生成标签、输入标签和错误的 HTML 的核心方法之前,我们应该定义一个getWrapperPattern()方法,它将为标签、输入和错误显示产生适当的包装标签。

  2. 例如,如果包装器被定义为<div>,并且它的属性包括['class' => 'label'],这个方法将返回一个看起来像这样的sprintf()格式模式:<div class="label">%s</div>。例如,标签的最终 HTML 将替换%s

  3. getWrapperPattern()方法可能如下所示:

public function getWrapperPattern($type)
{
  $pattern = '<' . $this->wrappers[$type]['type'];
  foreach ($this->wrappers[$type] as $key => $value) {
    if ($key != 'type') {
      $pattern .= ' ' . $key . '="' . $value . '"';
    }
  }
  $pattern .= '>%s</' . $this->wrappers[$type]['type'] . '>';
  return $pattern;
}
  1. 现在我们准备定义getLabel()方法。这个方法所需要做的就是使用sprintf()将标签插入包装器中:
public function getLabel()
{
  return sprintf($this->getWrapperPattern(self::LABEL), 
                 $this->label);
}
  1. 为了生成核心的input标签,我们需要一种方法来组装属性。幸运的是,只要它们以关联数组的形式提供给构造函数,这是很容易实现的。在这种情况下,我们只需要定义一个getAttribs()方法,它产生由空格分隔的键值对的字符串。我们使用trim()来去除多余的空格并返回最终值。

  2. 如果元素包括valuehref属性,出于安全原因,我们应该对值进行转义,假设它们是用户提供的(或可能是)(因此可疑)。因此,我们需要添加一个if语句来检查,然后使用htmlspecialchars()urlencode()

public function getAttribs()
{
  foreach ($this->attributes as $key => $value) {
    $key = strtolower($key);
    if ($value) {
      if ($key == 'value') {
        if (is_array($value)) {
            foreach ($value as $k => $i) 
              $value[$k] = htmlspecialchars($i);
        } else {
            $value = htmlspecialchars($value);
        }
      } elseif ($key == 'href') {
          $value = urlencode($value);
      }
      $attribs .= $key . '="' . $value . '" ';
    } else {
        $attribs .= $key . ' ';
    }
  }
  return trim($attribs);
}
  1. 对于核心输入标记,我们将逻辑分为两个独立的方法。主要方法getInputOnly()仅生成 HTML 输入标记。第二个方法getInputWithWrapper()生成包含在包装器中的输入。拆分的原因是在创建分支类时,例如生成单选按钮的类,我们将不需要包装器:
public function getInputOnly()
{
  return sprintf($this->pattern, $this->type, $this->name, 
                 $this->getAttribs());
}

public function getInputWithWrapper()
{
  return sprintf($this->getWrapperPattern(self::INPUT), 
                 $this->getInputOnly());
}
  1. 现在我们定义一个显示元素验证错误的方法。我们假设错误将以数组的形式提供。如果没有错误,我们返回一个空字符串。否则,错误将呈现为<ul><li>error 1</li><li>error 2</li></ul>等等:
public function getErrors()
{
  if (!$this->errors || count($this->errors == 0)) return '';
  $html = '';
  $pattern = '<li>%s</li>';
  $html .= '<ul>';
  foreach ($this->errors as $error)
  $html .= sprintf($pattern, $error);
  $html .= '</ul>';
  return sprintf($this->getWrapperPattern(self::ERRORS), $html);
}
  1. 对于某些属性,我们可能需要更精细的控制属性的各个方面。例如,我们可能需要将一个错误添加到已经存在的错误数组中。此外,设置单个属性可能很有用:
public function setSingleAttribute($key, $value)
{
  $this->attributes[$key] = $value;
}
public function addSingleError($error)
{
  $this->errors[] = $error;
}
  1. 最后,我们定义获取器和设置器,允许我们检索或设置属性的值。例如,您可能已经注意到$pattern的默认值是<input type="%s" name="%s" %s>。对于某些标签(例如selectform标签),我们需要将此属性设置为不同的值:
public function setPattern($pattern)
{
  $this->pattern = $pattern;
}
public function setType($type)
{
  $this->type = $type;
}
public function getType()
{
  return $this->type;
}
public function addSingleError($error)
{
  $this->errors[] = $error;
}
// define similar get and set methods
// for name, label, wrappers, errors and attributes
  1. 我们还需要添加一些方法,用于提供标签值(而不是 HTML)以及错误数组:
public function getLabelValue()
{
  return $this->label;
}
public function getErrorsArray()
{
  return $this->errors;
}

工作原理...

确保将所有前面的代码复制到一个单独的Application\Form\Generic类中。然后,您可以定义一个chap_06_form_element_generator.php调用脚本,设置自动加载并锚定新类:

<?php
require __DIR__ . '/../Application/Autoload/Loader.php';
Application\Autoload\Loader::init(__DIR__ . '/..');
use Application\Form\Generic;

接下来,定义包装器。作为示例,我们将使用 HTML 表数据和标题标签。请注意,标签使用TH,而输入和错误使用TD

$wrappers = [
  Generic::INPUT => ['type' => 'td', 'class' => 'content'],
  Generic::LABEL => ['type' => 'th', 'class' => 'label'],
  Generic::ERRORS => ['type' => 'td', 'class' => 'error']
];

现在,您可以通过向构造函数传递参数来定义电子邮件元素:

$email = new Generic('email', Generic::TYPE_EMAIL, 'Email', $wrappers,
                    ['id' => 'email',
                     'maxLength' => 128,
                     'title' => 'Enter address',
                     'required' => '']);

或者,使用 setter 定义密码元素:

$password = new Generic('password', $email);
$password->setType(Generic::TYPE_PASSWORD);
$password->setLabel('Password');
$password->setAttributes(['id' => 'password',
                          'title' => 'Enter your password',
                          'required' => '']);

最后,请确保定义一个提交按钮:

$submit = new Generic('submit', 
  Generic::TYPE_SUBMIT,
  'Login',
  $wrappers,
  ['id' => 'submit','title' => 'Click to login','value' => 'Click Here']);

实际的显示逻辑可能如下所示:

<div class="container">
  <!-- Login Form -->
  <h1>Login</h1>
  <form name="login" method="post">
  <table id="login" class="display" 
    cellspacing="0" width="100%">
    <tr><?= $email->render(); ?></tr>
    <tr><?= $password->render(); ?></tr>
    <tr><?= $submit->render(); ?></tr>
    <tr>
      <td colspan=2>
        <br>
        <?php var_dump($_POST); ?>
      </td>
    </tr>
  </table>
  </form>
</div>

这是实际的输出:

工作原理...

创建 HTML 单选按钮元素生成器

单选按钮元素生成器将与通用 HTML 表单元素生成器共享相似之处。与任何通用元素一样,一组单选按钮需要能够显示整体标签和错误。然而,有两个主要区别:

  • 通常,您会希望有两个或更多的单选按钮

  • 每个按钮都需要有自己的标签

如何做...

  1. 首先,创建一个新的Application\Form\Element\Radio类,它扩展了Application\Form\Generic
**namespace Application\Form\Element;**
**use Application\Form\Generic;**
**class Radio extends Generic**
**{**
 **// code**
**}**

  1. 接下来,定义与一组单选按钮的特殊需求相关的类常量和属性。

  2. 在这个示例中,我们需要一个spacer,它将放置在单选按钮和其标签之间。我们还需要决定是在实际按钮之前还是之后放置单选按钮标签,因此我们使用$after标志。如果我们需要一个默认值,或者如果我们重新显示现有的表单数据,我们需要一种指定选定键的方法。最后,我们需要一个选项数组,我们将从中填充按钮列表:

const DEFAULT_AFTER = TRUE;
const DEFAULT_SPACER = '&nbps;';
const DEFAULT_OPTION_KEY = 0;
const DEFAULT_OPTION_VALUE = 'Choose';

protected $after = self::DEFAULT_AFTER;
protected $spacer = self::DEFAULT_SPACER;
protected $options = array();
protected $selectedKey = DEFAULT_OPTION_KEY;
  1. 鉴于我们正在扩展Application\Form\Generic,我们可以扩展__construct()方法,或者简单地定义一个可用于设置特定选项的方法。对于本示例,我们选择了后者。

  2. 为了确保属性$this->options被填充,第一个参数($options)被定义为强制性的(没有默认值)。所有其他参数都是可选的。

public function setOptions(array $options, 
  $selectedKey = self::DEFAULT_OPTION_KEY, 
  $spacer = self::DEFAULT_SPACER,
  $after  = TRUE)
{
  $this->after = $after;
  $this->spacer = $spacer;
  $this->options = $options;
  $this->selectedKey = $selectedKey;
}  
  1. 最后,我们准备覆盖核心的getInputOnly()方法。

  2. 我们将id属性保存到一个独立的变量$baseId中,然后将其与$count组合,以便每个id属性都是唯一的。如果与选定键关联的选项已定义,则将其分配为值;否则,我们使用默认值:

public function getInputOnly()
{
  $count  = 1;
  $baseId = $this->attributes['id'];
  1. foreach()循环中,我们检查键是否是所选的键。如果是,就为该单选按钮添加checked属性。然后,我们调用父类的getInputOnly()方法来返回每个按钮的 HTML。请注意,输入元素的value属性是选项数组键。按钮标签是选项数组元素值:
foreach ($this->options as $key => $value) {
  $this->attributes['id'] = $baseId . $count++;
  $this->attributes['value'] = $key;
  if ($key == $this->selectedKey) {
      $this->attributes['checked'] = '';
  } elseif (isset($this->attributes['checked'])) {
            unset($this->attributes['checked']);
  }
  if ($this->after) {
      $html = parent::getInputOnly() . $value;
  } else {
      $html = $value . parent::getInputOnly();
  }
  $output .= $this->spacer . $html;
  }
  return $output;
}

它是如何工作的...

将前面的代码复制到Application/Form/Element文件夹中的新Radio.php文件中。然后,您可以定义一个chap_06_form_element_radio.php调用脚本,设置自动加载并锚定新类:

<?php
require __DIR__ . '/../Application/Autoload/Loader.php';
Application\Autoload\Loader::init(__DIR__ . '/..');
use Application\Form\Generic;
use Application\Form\Element\Radio;

接下来,使用前一个配方中定义的$wrappers数组来定义包装器。

然后,您可以定义一个$status数组,并通过向构造函数传递参数来创建一个元素实例:

$statusList = [
  'U' => 'Unconfirmed',
  'P' => 'Pending',
  'T' => 'Temporary Approval',
  'A' => 'Approved'
];

$status = new Radio('status', 
          Generic::TYPE_RADIO, 
          'Status',
          $wrappers,
          ['id' => 'status']);

现在您可以查看是否有来自$_GET的状态输入,并设置选项。任何输入都将成为选定的键。否则,选定的键是默认值:

$checked = $_GET['status'] ?? 'U';
$status->setOptions($statusList, $checked, '<br>', TRUE);          

最后,不要忘记定义一个提交按钮:

$submit = new Generic('submit', 
          Generic::TYPE_SUBMIT,
          'Process',
          $wrappers,
          ['id' => 'submit','title' => 
          'Click to process','value' => 'Click Here']);

显示逻辑可能如下所示:

<form name="status" method="get">
<table id="status" class="display" cellspacing="0" width="100%">
  <tr><?= $status->render(); ?></tr>
  <tr><?= $submit->render(); ?></tr>
  <tr>
    <td colspan=2>
      <br>
      <pre><?php var_dump($_GET); ?></pre>
    </td>
  </tr>
</table>
</form>

以下是实际输出:

它是如何工作的...

还有更多...

复选框元素生成器几乎与 HTML 单选按钮生成器相同。主要区别在于一组复选框可以有多个选中的值。因此,您将使用 PHP 数组表示法来表示元素名称。元素类型应为Generic::TYPE_CHECKBOX

创建 HTML 选择元素生成器

生成 HTML 单选元素类似于生成单选按钮的过程。然而,标签的结构不同,因为需要生成SELECT标签和一系列OPTION标签。

如何做到这一点...

  1. 首先,创建一个新的Application\Form\Element\Select类,它扩展了Application\Form\Generic

  2. 我们之所以扩展Generic而不是Radio是因为元素的结构完全不同:

namespace Application\Form\Element;

use Application\Form\Generic;

class Select extends Generic
{
  // code
}
  1. 类常量和属性只需要稍微添加到Application\Form\Generic。与单选按钮或复选框不同,不需要考虑间隔符或所选文本的放置:
const DEFAULT_OPTION_KEY = 0;
const DEFAULT_OPTION_VALUE = 'Choose';

protected $options;
protected $selectedKey = DEFAULT_OPTION_KEY;
  1. 现在我们将注意力转向设置选项。由于 HTML 选择元素可以选择单个或多个值,因此$selectedKey属性可以是字符串或数组。因此,我们不为此属性添加类型提示。然而,重要的是要确定是否已设置multiple属性。这可以通过从父类继承的$this->attributes属性获得。

  2. 如果已设置multiple属性,则将name属性构造为数组非常重要。因此,如果是这种情况,我们将在名称后附加[]

public function setOptions(array $options, $selectedKey = self::DEFAULT_OPTION_KEY)
{
  $this->options = $options;
  $this->selectedKey = $selectedKey;
  if (isset($this->attributes['multiple'])) {
    $this->name .= '[]';
  } 
}

注意

在 PHP 中,如果已设置 HTML 选择multiple属性,并且name属性未指定为数组,则只会返回单个值!

  1. 在我们可以定义核心的getInputOnly()方法之前,我们需要定义一个方法来生成select标签。然后,我们使用sprintf()返回最终的 HTML,使用$pattern$namegetAttribs()的返回值作为参数。

  2. 我们用<select name="%s" %s>替换了$pattern的默认值。然后,我们循环遍历属性,将它们作为键值对添加到空格之间:

protected function getSelect()
{
  $this->pattern = '<select name="%s" %s> ' . PHP_EOL;
  return sprintf($this->pattern, $this->name, 
  $this->getAttribs());
}
  1. 接下来,我们定义一个方法来获取与select标签关联的option标签。

  2. 正如您所记得的,$this->options数组中的表示返回值,而数组的部分表示将显示在屏幕上的文本。如果$this->selectedKey是数组形式,我们检查值是否在数组中。否则,我们假设$this-> selectedKey是一个字符串,我们只需确定它是否等于键。如果选定的键匹配,我们添加selected属性:

protected function getOptions()
{
  $output = '';
  foreach ($this->options as $key => $value) {
    if (is_array($this->selectedKey)) {
        $selected = (in_array($key, $this->selectedKey)) 
        ? ' selected' : '';
    } else {
        $selected = ($key == $this->selectedKey) 
        ? ' selected' : '';
    }
        $output .= '<option value="' . $key . '"' 
        . $selected  . '>' 
        . $value 
        . '</option>';
  }
  return $output;
}
  1. 最后,我们准备覆盖核心的getInputOnly()方法。

  2. 您会注意到,此方法的逻辑只需要捕获前面代码中描述的getSelect()getOptions()方法的返回值。我们还需要添加闭合的</select>标签:

public function getInputOnly()
{
  $output = $this->getSelect();
  $output .= $this->getOptions();
  $output .= '</' . $this->getType() . '>'; 
  return $output;
}

它是如何工作的...

将上述代码复制到Application/Form/Element文件夹中的新Select.php文件中。然后定义一个chap_06_form_element_select.php调用脚本,设置自动加载并锚定新类:

<?php
require __DIR__ . '/../Application/Autoload/Loader.php';
Application\Autoload\Loader::init(__DIR__ . '/..');
use Application\Form\Generic;
use Application\Form\Element\Select;

接下来,使用第一个配方中定义的数组$wrappers来定义包装器。您还可以使用创建 HTML 单选按钮元素生成器配方中定义的$statusList数组。然后,您可以创建SELECT元素的实例。第一个实例是单选,第二个是多选:

$status1 = new Select('status1', 
           Generic::TYPE_SELECT, 
           'Status 1',
           $wrappers,
           ['id' => 'status1']);
$status2 = new Select('status2', 
           Generic::TYPE_SELECT, 
           'Status 2',
           $wrappers,
           ['id' => 'status2', 
            'multiple' => '', 
            'size' => '4']);

查看$_GET中是否有状态输入,并设置选项。任何输入都将成为选定的键。否则,选定的键是默认的。正如您所记得的,第二个实例是多选,因此从$_GET获取的值和默认设置都应该是数组形式:

$checked1 = $_GET['status1'] ?? 'U';
$checked2 = $_GET['status2'] ?? ['U'];
$status1->setOptions($statusList, $checked1);
$status2->setOptions($statusList, $checked2);

最后,确保定义一个提交按钮(如本章创建通用表单元素生成器配方中所示)。

实际的显示逻辑与单选按钮配方相同,只是我们需要呈现两个单独的 HTML 选择实例:

<form name="status" method="get">
<table id="status" class="display" cellspacing="0" width="100%">
  <tr><?= $status1->render(); ?></tr>
  <tr><?= $status2->render(); ?></tr>
  <tr><?= $submit->render(); ?></tr>
  <tr>
    <td colspan=2>
      <br>
      <pre>
        <?php var_dump($_GET); ?>
      </pre>
    </td>
  </tr>
</table>
</form>

这是实际输出:

它是如何工作的...

此外,您可以看到元素如何出现在查看源代码页面中:

它是如何工作的...

实施表单工厂

表单工厂的目的是从单个配置数组生成可用的表单对象。表单对象应具有检索其包含的各个元素的能力,以便可以生成输出。

如何做...

  1. 首先,让我们创建一个名为Application\Form\Factory的类来包含工厂代码。它只有一个属性$elements,带有一个 getter:
namespace Application\Form;

class Factory
{
  protected $elements;
  public function getElements()
  {
    return $this->elements;
  }
  // remaining code
}
  1. 在定义主要的表单生成方法之前,重要的是考虑我们计划接收的配置格式,以及表单生成将产生什么。在本示例中,我们假设生成将产生一个具有$elements属性的Factory实例。该属性将是Application\Form\GenericApplication\Form\Element类的数组。

  2. 我们现在准备着手编写generate()方法。这将循环遍历配置数组,创建适当的Application\Form\GenericApplication\Form\Element\*对象,然后将其存储在$elements数组中。新方法将接受配置数组作为参数。将此方法定义为静态的是方便的,这样我们可以使用不同的配置块生成所需的实例。

  3. 我们创建一个Application\Form\Factory的实例,然后开始循环遍历配置数组:

public static function generate(array $config)
{
  $form = new self();
  foreach ($config as $key => $p) {
  1. 接下来,我们检查Application\Form\Generic类构造函数中的可选参数:
  $p['errors'] = $p['errors'] ?? array();
  $p['wrappers'] = $p['wrappers'] ?? array();
  $p['attributes'] = $p['attributes'] ?? array();
  1. 现在,所有构造函数参数都就位了,我们可以创建表单元素实例,然后将其存储在$elements中:
  $form->elements[$key] = new $p['class']
  (
    $key, 
    $p['type'],
    $p['label'],
    $p['wrappers'],
    $p['attributes'],
    $p['errors']
  );
  1. 接下来,我们将注意力转向选项。如果设置了options参数,我们使用list()将数组值提取到变量中。然后,我们使用switch()测试元素类型,并使用适当数量的参数运行setOptions()
    if (isset($p['options'])) {
      list($a,$b,$c,$d) = $p['options'];
      switch ($p['type']) {
        case Generic::TYPE_RADIO    :
        case Generic::TYPE_CHECKBOX :
          $form->elements[$key]->setOptions($a,$b,$c,$d);
          break;
        case Generic::TYPE_SELECT   :
          $form->elements[$key]->setOptions($a,$b);
          break;
        default                     :
          $form->elements[$key]->setOptions($a,$b);
          break;
      }
    }
  }
  1. 最后,我们返回表单对象并关闭方法:
  return $form;
} 
  1. 理论上,在这一点上,我们可以通过简单地迭代元素数组并运行render()方法来在视图逻辑中轻松呈现表单。视图逻辑可能如下所示:
<form name="status" method="get">
  <table id="status" class="display" cellspacing="0" width="100%">
    <?php foreach ($form->getElements() as $element) : ?>
      <?php echo $element->render(); ?>
    <?php endforeach; ?>
  </table>
</form>
  1. 最后,我们返回表单对象并关闭方法。

  2. 接下来,我们需要在Application\Form\Element下定义一个独立的Form类:

namespace Application\Form\Element;
class Form extends Generic
{
  public function getInputOnly()
  {
    $this->pattern = '<form name="%s" %s> ' . PHP_EOL;
    return sprintf($this->pattern, $this->name, 
                   $this->getAttribs());
  }
  public function closeTag()
  {
    return '</' . $this->type . '>';
  }
}
  1. 返回到Application\Form\Factory类,现在我们需要定义一个简单的方法,返回一个sprintf()包装器模式,作为输入的信封。例如,如果包装器是带有属性class="test"div,我们将产生这个模式:<div class="test">%s</div>。然后我们的内容将被sprintf()函数替换为%s
protected function getWrapperPattern($wrapper)
{
  $type = $wrapper['type'];
  unset($wrapper['type']);
  $pattern = '<' . $type;
  foreach ($wrapper as $key => $value) {
    $pattern .= ' ' . $key . '="' . $value . '"';
  }
  $pattern .= '>%s</' . $type . '>';
  return $pattern;
}
  1. 最后,我们准备定义一个执行整体表单渲染的方法。我们为每个表单行获取包装器sprintf()模式。然后我们循环遍历元素,渲染每个元素,并将输出包装在行模式中。接下来,我们生成一个Application\Form\Element\Form实例。然后我们检索表单包装器sprintf()模式,并检查form_tag_inside_wrapper标志,该标志告诉我们是否需要将表单标签放在表单包装器内部还是外部:
public static function render($form, $formConfig)
{
  $rowPattern = $form->getWrapperPattern(
  $formConfig['row_wrapper']);
  $contents   = '';
  foreach ($form->getElements() as $element) {
    $contents .= sprintf($rowPattern, $element->render());
  }
  $formTag = new Form($formConfig['name'], 
                  Generic::TYPE_FORM, 
                  '', 
                  array(), 
                  $formConfig['attributes']); 

  $formPattern = $form->getWrapperPattern(
  $formConfig['form_wrapper']);
  if (isset($formConfig['form_tag_inside_wrapper']) 
      && !$formConfig['form_tag_inside_wrapper']) {
        $formPattern = '%s' . $formPattern . '%s';
        return sprintf($formPattern, $formTag->getInputOnly(), 
        $contents, $formTag->closeTag());
  } else {
        return sprintf($formPattern, $formTag->getInputOnly() 
        . $contents . $formTag->closeTag());
  }
}

它是如何工作...

参考前面的代码,创建Application\Form\FactoryApplication\Form\Element\Form类。

接下来,您可以定义一个chap_06_form_factor.php调用脚本,设置自动加载并锚定新类:

<?php
require __DIR__ . '/../Application/Autoload/Loader.php';
Application\Autoload\Loader::init(__DIR__ . '/..');
use Application\Form\Generic;
use Application\Form\Factory;

接下来,使用第一个配方中定义的$wrappers数组来定义包装器。您还可以使用第二个配方中定义的$statusList数组。

查看是否有来自$_POST的状态输入。任何输入都将成为所选键。否则,所选键是默认值。

$email    = $_POST['email']   ?? '';
$checked0 = $_POST['status0'] ?? 'U';
$checked1 = $_POST['status1'] ?? 'U';
$checked2 = $_POST['status2'] ?? ['U'];
$checked3 = $_POST['status3'] ?? ['U'];

现在您可以定义整体表单配置。nameattributes参数用于配置form标签本身。另外两个参数代表表单级别和行级别的包装器。最后,我们提供一个form_tag_inside_wrapper标志,以指示表单标签不应出现在包装器内部(即<table>)。如果包装器是<div>,我们将将此标志设置为TRUE

$formConfig = [ 
  'name'         => 'status_form',
  'attributes'   => ['id'=>'statusForm','method'=>'post', 'action'=>'chap_06_form_factory.php'],
  'row_wrapper'  => ['type' => 'tr', 'class' => 'row'],
  'form_wrapper' => ['type'=>'table','class'=>'table', 'id'=>'statusTable',
                     'class'=>'display','cellspacing'=>'0'],
                     'form_tag_inside_wrapper' => FALSE,
];

接下来,定义一个数组,其中包含由工厂创建的每个表单元素的参数。数组键成为表单元素的名称,并且必须是唯一的:

$config = [
  'email' => [  
    'class'     => 'Application\Form\Generic',
    'type'      => Generic::TYPE_EMAIL, 
    'label'     => 'Email', 
    'wrappers'  => $wrappers,
    'attributes'=> ['id'=>'email','maxLength'=>128, 'title'=>'Enter address',
                    'required'=>'','value'=>strip_tags($email)]
  ],
  'password' => [
    'class'      => 'Application\Form\Generic',
    'type'       => Generic::TYPE_PASSWORD,
    'label'      => 'Password',
    'wrappers'   => $wrappers,
    'attributes' => ['id'=>'password',
    'title'      => 'Enter your password',
    'required'   => '']
  ],
  // etc.
];

最后,确保生成表单:

$form = Factory::generate($config);

实际的显示逻辑非常简单,因为我们只需调用表单级别的render()方法:

<?= $form->render($form, $formConfig); ?>

这是实际输出:

它是如何工作的...

使用$_POST 过滤器链接

当处理用户从在线表单提交的数据时,适当的过滤和验证是一个常见的问题。这可以说也是网站的头号安全漏洞。此外,将过滤器和验证器分散在整个应用程序中可能会相当尴尬。链接机制将整洁地解决这些问题,并且还允许您控制过滤器和验证器处理的顺序。

如何做...

  1. 有一个鲜为人知的 PHP 函数filter_input_array(),乍一看似乎非常适合这个任务。然而,深入了解其功能后,很快就会发现这个函数是在早期设计的,并不符合对抗攻击和灵活性的现代要求。因此,我们将提出一个基于执行过滤和验证的回调数组的更加灵活的机制。

注意

过滤验证之间的区别在于,过滤可能会删除或转换值。另一方面,验证使用与数据性质相适应的标准测试数据,并返回布尔结果。

  1. 为了增加灵活性,我们将使我们的基本过滤器和验证类相对轻量。这意味着定义任何特定的过滤器或验证方法。相反,我们将完全基于回调的配置数组进行操作。为了确保过滤和验证结果的兼容性,我们还将定义一个特定的结果对象Application\Filter\Result

  2. Result类的主要功能将是保存$item值,这将是过滤后的值或验证的布尔结果。另一个属性$messages将保存在过滤或验证操作期间填充的消息数组。在构造函数中,为$messages提供的值被制定为一个数组。您可能会注意到这两个属性都被定义为public。这是为了方便访问:

namespace Application\Filter;

class Result
{

  public $item;  // (mixed) filtered data | (bool) result of validation
  public $messages = array();  // [(string) message, (string) message ]

  public function __construct($item, $messages)
  {
    $this->item = $item;
    if (is_array($messages)) {
        $this->messages = $messages;
    } else {
        $this->messages = [$messages];
    }
  }
  1. 我们还定义了一种方法,允许我们将这个Result实例与另一个实例合并。这很重要,因为在某个时候,我们将通过一系列过滤器处理相同的值。在这种情况下,我们希望新过滤的值覆盖现有的值,但希望消息被合并:
public function mergeResults(Result $result)
{
  $this->item = $result->item;
  $this->mergeMessages($result);
}

public function mergeMessages(Result $result)
{
  if (isset($result->messages) && is_array($result->messages)) {
    $this->messages = array_merge($this->messages, $result->messages);
  }
}
  1. 最后,为了完成这个类的方法,我们添加了一个合并验证结果的方法。这里需要考虑的重要问题是,任何值为FALSE,无论是在验证链上还是下来,都必须导致整个结果为FALSE
public function mergeValidationResults(Result $result)
{
  if ($this->item === TRUE) {
    $this->item = (bool) $result->item;
  }
  $this->mergeMessages($result);
  }

}
  1. 接下来,为了确保回调产生兼容的结果,我们将定义一个Application\Filter\CallbackInterface接口。您会注意到我们正在利用 PHP 7 能够对返回值进行数据类型化,以确保我们得到一个Result实例作为返回值:
namespace Application\Filter;
interface CallbackInterface
{
  public function __invoke ($item, $params) : Result;
}
  1. 每个回调应引用相同的消息集。因此,我们使用Application\Filter\Messages类定义了一系列静态属性。我们提供了设置所有消息或只设置一个消息的方法。$messages属性已经被设置为public以便更容易访问:
namespace Application\Filter;
class Messages
{
  const MESSAGE_UNKNOWN = 'Unknown';
  public static $messages;
  public static function setMessages(array $messages)
  {
    self::$messages = $messages;
  }
  public static function setMessage($key, $message)
  {
    self::$messages[$key] = $message;
  }
  public static function getMessage($key)
  {
    return self::$messages[$key] ?? self::MESSAGE_UNKNOWN;
  }
}
  1. 现在,我们可以定义一个实现核心功能的Application\Web\AbstractFilter类。如前所述,这个类将相对轻量级,我们不需要担心特定的过滤器或验证器,因为它们将通过配置提供。我们使用UnexpectedValueException类,作为 PHP 7 标准 PHP 库SPL)的一部分,以便在其中一个回调没有实现CallbackInterface时抛出一个描述性异常:
namespace Application\Filter;
use UnexpectedValueException;
abstract class AbstractFilter
{
  // code described in the next several bullets
  1. 首先,我们定义了一些有用的类常量,其中包含各种管理值。这里显示的最后四个控制要显示的消息格式,以及如何描述缺失数据:
const BAD_CALLBACK = 'Must implement CallbackInterface';
const DEFAULT_SEPARATOR = '<br>' . PHP_EOL;
const MISSING_MESSAGE_KEY = 'item.missing';
const DEFAULT_MESSAGE_FORMAT = '%20s : %60s';
const DEFAULT_MISSING_MESSAGE = 'Item Missing';
  1. 接下来,我们定义了核心属性。$separator与过滤和验证消息一起使用。$callbacks表示执行过滤和验证的回调数组。$assignments将数据字段映射到过滤器和/或验证器。$missingMessage表示为属性,以便可以覆盖它(即用于多语言网站)。最后,$results是一个Application\Filter\Result对象数组,并由过滤或验证操作填充:
protected $separator;    // used for message display
protected $callbacks;
protected $assignments;
protected $missingMessage;
protected $results = array();
  1. 在这一点上,我们可以构建__construct()方法。它的主要功能是设置回调和赋值的数组。它还设置值或接受分隔符(用于消息显示)和missing消息的默认值:
public function __construct(array $callbacks, array $assignments, 
                            $separator = NULL, $message = NULL)
{
  $this->setCallbacks($callbacks);
  $this->setAssignments($assignments);
  $this->setSeparator($separator ?? self::DEFAULT_SEPARATOR);
  $this->setMissingMessage($message 
                           ?? self::DEFAULT_MISSING_MESSAGE);
}
  1. 接下来,我们定义了一系列方法,允许我们设置或移除回调。请注意,我们允许获取和设置单个回调。如果您有一组通用的回调,并且只需要修改一个回调,这将非常有用。您还会注意到setOneCall()检查回调是否实现了CallbackInterface。如果没有,将抛出UnexpectedValueException
public function getCallbacks()
{
  return $this->callbacks;
}

public function getOneCallback($key)
{
  return $this->callbacks[$key] ?? NULL;
}

public function setCallbacks(array $callbacks)
{
  foreach ($callbacks as $key => $item) {
    $this->setOneCallback($key, $item);
  }
}

public function setOneCallback($key, $item)
{
  if ($item instanceof CallbackInterface) {
      $this->callbacks[$key] = $item;
  } else {
      throw new UnexpectedValueException(self::BAD_CALLBACK);
  }
}

public function removeOneCallback($key)
{
  if (isset($this->callbacks[$key])) 
  unset($this->callbacks[$key]);
}
  1. 结果处理的方法非常简单。为了方便起见,我们添加了getItemsAsArray(),否则getResults()将返回一个Result对象数组:
public function getResults()
{
  return $this->results;
}

public function getItemsAsArray()
{
  $return = array();
  if ($this->results) {
    foreach ($this->results as $key => $item) 
    $return[$key] = $item->item;
  }
  return $return;
}
  1. 检索消息只是循环遍历$this ->results数组并提取$messages属性。为了方便起见,我们还添加了getMessageString(),其中包含一些格式选项。为了轻松生成消息数组,我们使用了 PHP 7 的yield from语法。这使得getMessages()成为一个委托生成器。消息数组成为一个子生成器
public function getMessages()
{
  if ($this->results) {
      foreach ($this->results as $key => $item) 
      if ($item->messages) yield from $item->messages;
  } else {
      return array();
  }
}

public function getMessageString($width = 80, $format = NULL)
{
  if (!$format)
  $format = self::DEFAULT_MESSAGE_FORMAT . $this->separator;
  $output = '';
  if ($this->results) {
    foreach ($this->results as $key => $value) {
      if ($value->messages) {
        foreach ($value->messages as $message) {
          $output .= sprintf(
            $format, $key, trim($message));
        }
      }
    }
  }
  return $output;
}
  1. 最后,我们定义一组有用的 getter 和 setter:
  public function setMissingMessage($message)
  {
    $this->missingMessage = $message;
  }
  public function setSeparator($separator)
  {
    $this->separator = $separator;
  }
  public function getSeparator()
  {
    return $this->separator;
  }
  public function getAssignments()
  {
    return $this->assignments;
  }
  public function setAssignments(array $assignments)
  {
    $this->assignments = $assignments;
  }
  // closing bracket for class AbstractFilter
}
  1. 过滤和验证,虽然经常一起执行,但同样经常分开执行。因此,我们为每个定义离散的类。我们将从Application\Filter\Filter开始。我们使这个类扩展AbstractFilter,以提供先前描述的核心功能:
namespace Application\Filter;
class Filter extends AbstractFilter
{
  // code
}
  1. 在这个类中,我们定义了一个核心的process()方法,它扫描数据数组并根据分配数组应用过滤器。如果对这个数据集没有分配过滤器,我们只需返回NULL
public function process(array $data)
{
  if (!(isset($this->assignments) 
      && count($this->assignments))) {
        return NULL;
  }
  1. 否则,我们将$this->results初始化为一个Result对象数组,其中$item属性是来自$data的原始值,$messages属性是一个空数组:
foreach ($data as $key => $value) {
  $this->results[$key] = new Result($value, array());
}
  1. 然后,我们复制$this->assignments并检查是否有任何全局过滤器(由'*'键标识)。如果有,我们运行processGlobal()然后取消'*'键:
$toDo = $this->assignments;
if (isset($toDo['*'])) {
  $this->processGlobalAssignment($toDo['*'], $data);
  unset($toDo['*']);
}
  1. 最后,我们循环遍历任何剩余的分配,调用processAssignment()
foreach ($toDo as $key => $assignment) {
  $this->processAssignment($assignment, $key);
}
  1. 正如您所记得的,每个assignment都是针对数据字段的键,并表示该字段的回调数组。因此,在processGlobalAssignment()中,我们需要循环遍历回调数组。然而,在这种情况下,因为这些分配是全局的,我们还需要循环遍历整个数据集,并依次应用每个全局过滤器:
protected function processGlobalAssignment($assignment, $data)
{
  foreach ($assignment as $callback) {
    if ($callback === NULL) continue;
    foreach ($data as $k => $value) {
      $result = $this->callbacks[$callback['key']]($this->results[$k]->item,
      $callback['params']);
      $this->results[$k]->mergeResults($result);
    }
  }
}

注意

棘手的部分是这行代码:

$result = $this->callbacks[$callback['key']]($this ->results[$k]->item, $callback['params']);

记住,每个回调实际上是一个匿名类,定义了 PHP 魔术__invoke()方法。提供的参数是要过滤的实际数据项和参数数组。通过运行$this->callbacks[$callback['key']](),我们实际上是在神奇地调用__invoke()

  1. 当我们定义processAssignment()时,类似于processGlobalAssignment()的方式,我们需要执行分配给每个数据键的每个剩余回调:
  protected function processAssignment($assignment, $key)
  {
    foreach ($assignment as $callback) {
      if ($callback === NULL) continue;
      $result = $this->callbacks[$callback['key']]($this->results[$key]->item, 
                                 $callback['params']);
      $this->results[$key]->mergeResults($result);
    }
  }
}  // closing brace for Application\Filter\Filter

注意

任何改变原始用户提供的数据的过滤操作都应显示一个消息,指示已进行更改,这可以成为审计跟踪的一部分,以保护您免受潜在的法律责任,当更改在用户不知情或未经同意的情况下进行时。

它是如何工作的...

创建一个Application\Filter文件夹。在这个文件夹中,使用前面步骤中的代码创建以下类文件:

Application\Filter*类文件 在这些步骤中描述的代码
Result.php 3 - 5
CallbackInterface.php 6
Messages.php 7
AbstractFilter.php 8 - 15
Filter.php 16 - 22

接下来,获取步骤 5 中讨论的代码,并将其用于在chap_06_post_data_config_messages.php文件中配置消息数组。每个回调引用Messages::$messages属性。以下是一个示例配置:

<?php
use Application\Filter\Messages;
Messages::setMessages(
  [
    'length_too_short' => 'Length must be at least %d',
    'length_too_long'  => 'Length must be no more than %d',
    'required'         => 'Please be sure to enter a value',
    'alnum'            => 'Only letters and numbers allowed',
    'float'            => 'Only numbers or decimal point',
    'email'            => 'Invalid email address',
    'in_array'         => 'Not found in the list',
    'trim'             => 'Item was trimmed',
    'strip_tags'       => 'Tags were removed from this item',
    'filter_float'     => 'Converted to a decimal number',
    'phone'            => 'Phone number is [+n] nnn-nnn-nnnn',
    'test'             => 'TEST',
    'filter_length'    => 'Reduced to specified length',
  ]
);

接下来,创建一个chap_06_post_data_config_callbacks.php回调配置文件,其中包含过滤回调的配置,如步骤 4 中所述。每个回调应遵循这个通用模板:

'callback_key' => new class () implements CallbackInterface 
{
  public function __invoke($item, $params) : Result
  {
    $changed  = array();
    $filtered = /* perform filtering operation on $item */
    if ($filtered !== $item) $changed = Messages::$messages['callback_key'];
    return new Result($filtered, $changed);
  }
}

回调本身必须实现接口并返回一个Result实例。我们可以利用 PHP 7 的匿名类功能,让我们的回调返回一个实现CallbackInterface的匿名类。以下是一个过滤回调数组可能的样子:

use Application\Filter\ { Result, Messages, CallbackInterface };
$config = [ 'filters' => [
  'trim' => new class () implements CallbackInterface 
  {
    public function __invoke($item, $params) : Result
    {
      $changed  = array();
      $filtered = trim($item);
      if ($filtered !== $item) 
      $changed = Messages::$messages['trim'];
      return new Result($filtered, $changed);
    }
  },
  'strip_tags' => new class () 
  implements CallbackInterface 
  {
    public function __invoke($item, $params) : Result
    {
      $changed  = array();
      $filtered = strip_tags($item);
      if ($filtered !== $item)     
      $changed = Messages::$messages['strip_tags'];
      return new Result($filtered, $changed);
    }
  },
  // etc.
]
];

为了测试目的,我们将使用 prospects 表作为目标。我们将构建一个数据的数组,而不是从$_POST提供数据:

它是如何工作的...

现在,您可以创建一个chap_06_post_data_filtering.php脚本,设置自动加载,包括消息和回调配置文件:

<?php
require __DIR__ . '/../Application/Autoload/Loader.php';
Application\Autoload\Loader::init(__DIR__ . '/..');
include __DIR__ . '/chap_06_post_data_config_messages.php';
include __DIR__ . '/chap_06_post_data_config_callbacks.php';

然后,您需要定义assignments,表示数据字段和过滤回调之间的映射关系。使用*键来定义适用于所有数据的全局过滤器:

$assignments = [
  '*'   => [ ['key' => 'trim', 'params' => []], 
          ['key' => 'strip_tags', 'params' => []] ],
  'first_name'  => [ ['key' => 'length', 
   'params' => ['length' => 128]] ],
  'last_name'  => [ ['key' => 'length', 
   'params' => ['length' => 128]] ],
  'city'          => [ ['key' => 'length', 
   'params' => ['length' => 64]] ],
  'budget'     => [ ['key' => 'filter_float', 'params' => []] ],
];

接下来,定义的测试数据:

$goodData = [
  'first_name'      => 'Your Full',
  'last_name'       => 'Name',
  'address'         => '123 Main Street',
  'city'            => 'San Francisco',
  'state_province'  => 'California',
  'postal_code'     => '94101',
  'phone'           => '+1 415-555-1212',
  'country'         => 'US',
  'email'           => 'your@email.address.com',
  'budget'          => '123.45',
];
$badData = [
  'first_name'      => 'This+Name<script>bad tag</script>Valid!',
  'last_name'       => 'ThisLastNameIsWayTooLongAbcdefghijklmnopqrstuvwxyz0123456789Abcdefghijklmnopqrstuvwxyz0123456789Abcdefghijklmnopqrstuvwxyz0123456789Abcdefghijklmnopqrstuvwxyz0123456789',
  //'address'       => '',    // missing
  'city'            => '  ThisCityNameIsTooLong012345678901234567890123456789012345678901234567890123456789  ',
  //'state_province'=> '',    // missing
  'postal_code'     => '!"£$%^Non Alpha Chars',
  'phone'           => ' 12345 ',
  'country'         => 'XX',
  'email'           => 'this.is@not@an.email',
  'budget'          => 'XXX',
];

最后,您可以创建一个Application\Filter\Filter实例,并测试数据:

$filter = new Application\Filter\Filter(
$config['filters'], $assignments);
$filter->setSeparator(PHP_EOL);
  $filter->process($goodData);
echo $filter->getMessageString();
  var_dump($filter->getItemsAsArray());

$filter->process($badData);
echo $filter->getMessageString();
var_dump($filter->getItemsAsArray());

处理的数据除了指示float字段的值从字符串转换为float之外,不会产生任何消息。另一方面,的数据产生以下输出:

它是如何工作的...

您还会注意到first_name中的标记已被移除,并且last_namecity都被截断。

还有更多...

filter_input_array()函数接受两个参数:输入源(以预定义的常量形式表示$_* PHP 超全局变量之一,即$_POST),以及匹配字段定义的数组作为键和过滤器或验证器作为值。此函数不仅执行过滤操作,还执行验证操作。标记为sanitize的标志实际上是过滤器。

另请参阅

可以在php.net/manual/en/function.filter-input-array.php找到filter_input_array()的文档和示例。您还可以查看php.net/manual/en/filter.filters.php上可用的不同类型的过滤器

将$_POST 验证器链接在一起

这个示例的重要工作已经在前面的示例中完成。核心功能由Application\Filter\AbstractFilter定义。实际的验证是由一系列验证回调执行的。

如何做...

  1. 查看前面的示例,链接$_POST 过滤器。我们将在这个示例中使用所有的类和配置文件,除非在这里另有说明。

  2. 首先,我们定义一个验证回调的配置数组。与前面的示例一样,每个回调都应实现Application\Filter\CallbackInterface,并应返回Application\Filter\Result的实例。验证器将采用这种通用形式:

use Application\Filter\ { Result, Messages, CallbackInterface };
$config = [
  // validator callbacks
  'validators' => [
    'key' => new class () implements CallbackInterface 
    {
      public function __invoke($item, $params) : Result
      {
        // validation logic goes here
        return new Result($valid, $error);
      }
    },
    // etc.
  1. 接下来,我们定义了一个Application\Filter\Validator类,它循环遍历赋值数组,测试每个数据项是否符合其分配的验证器回调。我们使这个类扩展AbstractFilter,以提供先前描述的核心功能:
namespace Application\Filter;
class Validator extends AbstractFilter
{
  // code
}
  1. 在这个类中,我们定义了一个核心的process()方法,它扫描数据数组并根据赋值数组应用验证器。如果对于这个数据集没有分配验证器,我们只需返回$valid的当前状态(即TRUE):
public function process(array $data)
{
  $valid = TRUE;
  if (!(isset($this->assignments) 
      && count($this->assignments))) {
        return $valid;
  }
  1. 否则,我们将$this->results初始化为一个Result对象数组,其中$item属性设置为TRUE$messages属性为空数组:
foreach ($data as $key => $value) {
  $this->results[$key] = new Result(TRUE, array());
}
  1. 然后,我们复制$this->assignments并检查是否有全局过滤器(由'*'键标识)。如果有,我们运行processGlobal(),然后取消'*'键:
$toDo = $this->assignments;
if (isset($toDo['*'])) {
  $this->processGlobalAssignment($toDo['*'], $data);
  unset($toDo['*']);
}
  1. 最后,我们循环遍历任何剩余的赋值,调用processAssignment()。这是一个理想的地方,用来检查赋值数组中是否缺少数据中存在的任何字段。请注意,如果任何验证回调返回FALSE,我们将$valid设置为FALSE
foreach ($toDo as $key => $assignment) {
  if (!isset($data[$key])) {
      $this->results[$key] = 
      new Result(FALSE, $this->missingMessage);
  } else {
      $this->processAssignment(
        $assignment, $key, $data[$key]);
  }
  if (!$this->results[$key]->item) $valid = FALSE;
  }
  return $valid;
}
  1. 正如您所记得的,每个赋值都与数据字段相关联,并表示该字段的回调数组。因此,在processGlobalAssignment()中,我们需要循环遍历回调数组。然而,在这种情况下,因为这些赋值是全局的,我们还需要循环遍历整个数据集,并依次应用每个全局过滤器。

  2. 与等效的Application\Filter\Fiter::processGlobalAssignment()方法相比,我们需要调用mergeValidationResults()。原因是,如果$result->item的值已经是FALSE,我们需要确保它不会随后被TRUE的值覆盖。链中返回FALSE的任何验证器都必须覆盖任何其他验证结果:

protected function processGlobalAssignment($assignment, $data)
{
  foreach ($assignment as $callback) {
    if ($callback === NULL) continue;
    foreach ($data as $k => $value) {
      $result = $this->callbacks[$callback['key']]
      ($value, $callback['params']);
      $this->results[$k]->mergeValidationResults($result);
    }
  }
}
  1. 当我们定义processAssignment()时,类似于processGlobalAssignment(),我们需要执行分配给每个数据键的每个剩余回调,再次调用mergeValidationResults()
protected function processAssignment($assignment, $key, $value)
{
  foreach ($assignment as $callback) {
    if ($callback === NULL) continue;
        $result = $this->callbacks[$callback['key']]
       ($value, $callback['params']);
        $this->results[$key]->mergeValidationResults($result);
    }
  }

它是如何工作的...

与前面的配方一样,请确保定义以下类:

  • Application\Filter\Result

  • Application\Filter\CallbackInterface

  • Application\Filter\Messages

  • Application\Filter\AbstractFilter

您可以使用前一配方中描述的chap_06_post_data_config_messages.php文件。

接下来,在Application\Filter文件夹中创建一个Validator.php文件。放置步骤 3 到 10 中描述的代码。

接下来,创建一个包含验证回调配置的chap_06_post_data_config_callbacks.php回调配置文件,如步骤 2 中描述的那样。每个回调应遵循这个通用模板:

'validation_key' => new class () implements CallbackInterface 
{
  public function __invoke($item, $params) : Result
  {
    $error = array();
    $valid = /* perform validation operation on $item */
    if (!$valid) 
    $error[] = Messages::$messages['validation_key'];
    return new Result($valid, $error);
  }
}

现在,您可以创建一个chap_06_post_data_validation.php调用脚本,该脚本初始化自动加载并包含配置脚本:

<?php
require __DIR__ . '/../Application/Autoload/Loader.php';
Application\Autoload\Loader::init(__DIR__ . '/..');
include __DIR__ . '/chap_06_post_data_config_messages.php';
include __DIR__ . '/chap_06_post_data_config_callbacks.php';

接下来,定义一个分配数组,将数据字段映射到验证器回调键:

$assignments = [
  'first_name'       => [ ['key' => 'length',  
  'params'   => ['min' => 1, 'max' => 128]], 
                ['key' => 'alnum',   
  'params'   => ['allowWhiteSpace' => TRUE]],
                ['key'   => 'required','params' => []] ],
  'last_name'=> [ ['key' => 'length',  
  'params'   => ['min'   => 1, 'max' => 128]],
                ['key'   => 'alnum',   
  'params'   => ['allowWhiteSpace' => TRUE]],
                ['key'   => 'required','params' => []] ],
  'address'       => [ ['key' => 'length',  
  'params'        => ['max' => 256]] ],
  'city'          => [ ['key' => 'length',  
  'params'        => ['min' => 1, 'max' => 64]] ], 
  'state_province'=> [ ['key' => 'length',  
  'params'        => ['min' => 1, 'max' => 32]] ], 
  'postal_code'   => [ ['key' => 'length',  
  'params'        => ['min' => 1, 'max' => 16] ], 
                     ['key' => 'alnum',   
  'params'        => ['allowWhiteSpace' => TRUE]],
                     ['key' => 'required','params' => []] ],
  'phone'         => [ ['key' => 'phone', 'params' => []] ],
  'country'       => [ ['key' => 'in_array',
  'params'        => $countries ], 
                     ['key' => 'required','params' => []] ],
  'email'         => [ ['key' => 'email', 'params' => [] ],
                     ['key' => 'length',  
  'params'        => ['max' => 250] ], 
                     ['key' => 'required','params' => [] ] ],
  'budget'        => [ ['key' => 'float', 'params' => []] ]
];

对于测试数据,请使用在前一配方中描述的chap_06_post_data_filtering.php文件中定义的相同的goodbad数据。之后,您可以创建一个Application\Filter\Validator实例,并测试数据:

$validator = new Application\Filter\Validator($config['validators'], $assignments);
$validator->setSeparator(PHP_EOL);
$validator->process($badData);
echo $validator->getMessageString(40, '%14s : %-26s' . PHP_EOL);
var_dump($validator->getItemsAsArray());
$validator->process($goodData);
echo $validator->getMessageString(40, '%14s : %-26s' . PHP_EOL);
var_dump($validator->getItemsAsArray());

如预期的那样,good数据不会产生任何验证错误。另一方面,bad数据会生成以下输出:

它是如何工作的...

请注意,missing字段addressstate_province验证为FALSE,并返回缺少项目消息。

将验证与表单绑定

当表单首次呈现时,将表单类(如前一配方中描述的Application\Form\Factory)与可以执行过滤或验证的类(如前一配方中描述的Application\Filter\*)绑定是没有太大价值的。但是,一旦表单数据被提交,兴趣就增加了。如果表单数据未通过验证,可以对值进行过滤,然后重新显示。验证错误消息可以与表单元素绑定,并在表单字段旁边呈现。

如何做...

  1. 首先,确保实现实现表单工厂链接\(_POST 过滤器*和*链接\)_POST 验证器配方中定义的类。

  2. 现在,我们将注意力转向Application\Form\Factory类,并添加属性和 setter,允许我们附加Application\Filter\FilterApplication\Filter\Validator的实例。我们还需要定义$data,用于保留过滤和/或验证的数据:

const DATA_NOT_FOUND = 'Data not found. Run setData()';
const FILTER_NOT_FOUND = 'Filter not found. Run setFilter()';
const VALIDATOR_NOT_FOUND = 'Validator not found. Run setValidator()';

protected $filter;
protected $validator;
protected $data;

public function setFilter(Filter $filter)
{
  $this->filter = $filter;
}

public function setValidator(Validator $validator)
{
  $this->validator = $validator;
}

public function setData($data)
{
  $this->data = $data;
}
  1. 接下来,我们定义一个validate()方法,该方法调用嵌入的Application\Filter\Validator实例的process()方法。我们检查$data$validator是否存在。如果不存在,将抛出适当的异常,并提供哪个方法需要首先运行的说明:
public function validate()
{
  if (!$this->data)
  throw new RuntimeException(self::DATA_NOT_FOUND);

  if (!$this->validator)
  throw new RuntimeException(self::VALIDATOR_NOT_FOUND);
  1. 调用process()方法后,我们将验证结果消息与表单元素消息关联起来。请注意,process()方法返回一个布尔值,表示数据集的整体验证状态。当表单在验证失败后重新显示时,错误消息将出现在每个元素旁边:
$valid = $this->validator->process($this->data);

foreach ($this->elements as $element) {
  if (isset($this->validator->getResults()
      [$element->getName()])) {
        $element->setErrors($this->validator->getResults()
        [$element->getName()]->messages);
      }
    }
    return $valid;
  }
  1. 类似地,我们定义了一个filter()方法,该方法调用嵌入的Application\Filter\Filter实例的process()方法。与步骤 3 中描述的validate()方法一样,我们需要检查$data$filter的存在。如果缺少任一项,我们将抛出一个带有适当消息的RuntimeException
public function filter()
{
  if (!$this->data)
  throw new RuntimeException(self::DATA_NOT_FOUND);

  if (!$this->filter)
  throw new RuntimeException(self::FILTER_NOT_FOUND);
  1. 然后我们运行process()方法,该方法生成一个Result对象数组,其中$item属性表示过滤器链的最终结果。然后我们遍历结果,如果相应的$element键匹配,将value属性设置为过滤后的值。我们还添加了过滤过程中产生的任何消息。然后重新显示表单时,所有值属性都将显示过滤后的结果:
$this->filter->process($this->data);
foreach ($this->filter->getResults() as $key => $result) {
  if (isset($this->elements[$key])) {
    $this->elements[$key]
    ->setSingleAttribute('value', $result->item);
    if (isset($result->messages) 
        && count($result->messages)) {
      foreach ($result->messages as $message) {
        $this->elements[$key]->addSingleError($message);
      }
    }
  }      
}
}

它是如何工作的...

您可以从对Application\Form\Factory进行上述更改开始。作为测试目标,您可以使用如何工作...部分中显示的潜在客户数据库表的内容。各种列设置应该让您了解要定义哪些表单元素、过滤器和验证器。

例如,您可以定义一个chap_06_tying_filters_to_form_definitions.php文件,其中包含表单包装、元素和过滤器分配的定义。以下是一些示例:

<?php
use Application\Form\Generic;

define('VALIDATE_SUCCESS', 'SUCCESS: form submitted ok!');
define('VALIDATE_FAILURE', 'ERROR: validation errors detected');

$wrappers = [
  Generic::INPUT  => ['type' => 'td', 'class' => 'content'],
  Generic::LABEL  => ['type' => 'th', 'class' => 'label'],
  Generic::ERRORS => ['type' => 'td', 'class' => 'error']
];

$elements = [
  'first_name' => [  
     'class'     => 'Application\Form\Generic',
     'type'      => Generic::TYPE_TEXT, 
     'label'     => 'First Name', 
     'wrappers'  => $wrappers,
     'attributes'=> ['maxLength'=>128,'required'=>'']
  ],
  'last_name'   => [  
    'class'     => 'Application\Form\Generic',
    'type'      => Generic::TYPE_TEXT, 
    'label'     => 'Last Name', 
    'wrappers'  => $wrappers,
    'attributes'=> ['maxLength'=>128,'required'=>'']
  ],
    // etc.
];

// overall form config
$formConfig = [ 
  'name'       => 'prospectsForm',
  'attributes' => [
'method'=>'post',
'action'=>'chap_06_tying_filters_to_form.php'
],
  'row_wrapper'  => ['type' => 'tr', 'class' => 'row'],
  'form_wrapper' => [
    'type'=>'table',
    'class'=>'table',
    'id'=>'prospectsTable',
    'class'=>'display','cellspacing'=>'0'
  ],
  'form_tag_inside_wrapper' => FALSE,
];

$assignments = [
  'first_name'    => [ ['key' => 'length',  
  'params'        => ['min' => 1, 'max' => 128]], 
                     ['key' => 'alnum',   
  'params'        => ['allowWhiteSpace' => TRUE]],
                     ['key' => 'required','params' => []] ],
  'last_name'     => [ ['key' => 'length',  
  'params'        => ['min' => 1, 'max' => 128]],
                     ['key' => 'alnum',   
  'params'        => ['allowWhiteSpace' => TRUE]],
                     ['key' => 'required','params' => []] ],
  'address'       => [ ['key' => 'length',  
  'params'        => ['max' => 256]] ],
  'city'          => [ ['key' => 'length',  
  'params'        => ['min' => 1, 'max' => 64]] ], 
  'state_province'=> [ ['key' => 'length',  
  'params'        => ['min' => 1, 'max' => 32]] ], 
  'postal_code'   => [ ['key' => 'length',  
  'params'        => ['min' => 1, 'max' => 16] ], 
                     ['key' => 'alnum',   
  'params'        => ['allowWhiteSpace' => TRUE]],
                     ['key' => 'required','params' => []] ],
  'phone'         => [ ['key' => 'phone',   'params' => []] ],
  'country'       => [ ['key' => 'in_array',
  'params'        => $countries ], 
                     ['key' => 'required','params' => []] ],
  'email'         => [ ['key' => 'email',   'params' => [] ],
                     ['key' => 'length',  
  'params'        => ['max' => 250] ], 
                     ['key' => 'required','params' => [] ] ],
  'budget'        => [ ['key' => 'float',   'params' => []] ]
];

您可以使用先前配方中描述的已经存在的chap_06_post_data_config_callbacks.phpchap_06_post_data_config_messages.php文件。最后,定义一个chap_06_tying_filters_to_form.php文件,设置自动加载并包含这三个配置文件:

<?php
require __DIR__ . '/../Application/Autoload/Loader.php';
Application\Autoload\Loader::init(__DIR__ . '/..');
include __DIR__ . '/chap_06_post_data_config_messages.php';
include __DIR__ . '/chap_06_post_data_config_callbacks.php';
include __DIR__ . '/chap_06_tying_filters_to_form_definitions.php';

接下来,您可以创建表单工厂、过滤器和验证器类的实例:

use Application\Form\Factory;
use Application\Filter\ { Validator, Filter };
$form = Factory::generate($elements);
$form->setFilter(new Filter($callbacks['filters'], $assignments['filters']));
$form->setValidator(new Validator($callbacks['validators'], $assignments['validators']));

然后,您可以检查是否有任何$_POST数据。如果有,执行验证和过滤:

$message = '';
if (isset($_POST['submit'])) {
  $form->setData($_POST);
  if ($form->validate()) {
    $message = VALIDATE_SUCCESS;
  } else {
    $message = VALIDATE_FAILURE;
  }
  $form->filter();
}
?>

视图逻辑非常简单:只需呈现表单。任何验证消息和各种元素的值都将作为验证和过滤的一部分分配:

  <?= $form->render($form, $formConfig); ?>

这是一个使用不良表单数据的示例:

它是如何工作的...

注意过滤和验证消息。还要注意不良标签:

它是如何工作的...

第七章:访问 Web 服务

在本章中,我们将涵盖以下主题:

  • 在 PHP 和 XML 之间进行转换

  • 创建一个简单的 REST 客户端

  • 创建一个简单的 REST 服务器

  • 创建一个简单的 SOAP 客户端

  • 创建一个简单的 SOAP 服务器

介绍

向外部 Web 服务进行后台查询正成为任何 PHP Web 实践的日益重要的一部分。提供适当、及时和丰富的数据意味着为您的客户和您开发的网站带来更多的业务。我们首先介绍了一些旨在在可扩展标记语言XML)和本机 PHP 之间进行数据转换的配方。接下来,我们将向您展示如何实现一个简单的表述状态转移REST)客户端和服务器。之后,我们将把注意力转向SOAP客户端和服务器。

在 PHP 和 XML 之间进行转换

在考虑 PHP 本机数据类型和 XML 之间的转换时,我们通常将数组视为主要目标。基于此,从 PHP 数组转换为 XML 的过程与需要执行相反操作的方法有很大的不同。

注意

对象也可以考虑进行转换;然而,将对象方法呈现为 XML 是困难的。属性可以通过使用get_object_vars()函数来表示,该函数将对象属性读入数组中。

如何做...

  1. 首先,我们定义一个Application\Parse\ConvertXml类。这个类将包含将从 XML 转换为 PHP 数组,反之亦然的方法。我们将需要 SPL 中的SimpleXMLElementSimpleXMLIterator类:
namespace Application\Parse;
use SimpleXMLIterator;
use SimpleXMLElement;
class ConvertXml
{
}
  1. 接下来,我们定义一个xmlToArray()方法,它将接受一个SimpleXMLIterator实例作为参数。它将被递归调用,并将从 XML 文档生成一个 PHP 数组。我们利用SimpleXMLIterator能够通过 XML 文档前进的能力,使用key()current()next()rewind()方法进行导航:
public function xmlToArray(SimpleXMLIterator $xml) : array
{
  $a = array();
  for( $xml->rewind(); $xml->valid(); $xml->next() ) {
    if(!array_key_exists($xml->key(), $a)) {
      $a[$xml->key()] = array();
    }
    if($xml->hasChildren()){
      $a[$xml->key()][] = $this->xmlToArray($xml->current());
    }
    else{
      $a[$xml->key()] = (array) $xml->current()->attributes();
      $a[$xml->key()]['value'] = strval($xml->current());
    }
  }
  return $a;
}
  1. 对于相反的过程,也称为递归,我们定义了两种方法。第一种方法arrayToXml()设置了一个初始的SimpleXMLElement实例,然后调用第二种方法phpToXml()
public function arrayToXml(array $a)
{
  $xml = new SimpleXMLElement(
  '<?xml version="1.0" standalone="yes"?><root></root>');
  $this->phpToXml($a, $xml);
  return $xml->asXML();
}
  1. 请注意,在第二种方法中,我们使用get_object_vars(),以防数组元素是对象。您还会注意到,单独的数字不允许作为 XML 标签,这意味着在数字前面添加一些文本:
protected function phpToXml($value, &$xml)
{
  $node = $value;
  if (is_object($node)) {
    $node = get_object_vars($node);
  }
  if (is_array($node)) {
    foreach ($node as $k => $v) {
      if (is_numeric($k)) {
        $k = 'number' . $k;
      }
      if (is_array($v)) {
          $newNode = $xml->addChild($k);
          $this->phpToXml($v, $newNode);
      } elseif (is_object($v)) {
          $newNode = $xml->addChild($k);
          $this->phpToXml($v, $newNode);
      } else {
          $xml->addChild($k, $v);
      }
    }
  } else  {
      $xml->addChild(self::UNKNOWN_KEY, $node);
  }
}

它是如何工作的...

作为一个示例 XML 文档,您可以使用美国国家气象局的Web 服务定义语言WSDL)。这是一个描述 SOAP 服务的 XML 文档,可以在graphical.weather.gov/xml/SOAP_server/ndfdXMLserver.php?wsdl找到。

我们将使用SimpleXMLIterator类来提供迭代机制。然后,您可以配置自动加载,并使用xmlToArray()获取Application\Parse\ConvertXml的实例,将 WSDL 转换为 PHP 数组:

require __DIR__ . '/../Application/Autoload/Loader.php';
Application\Autoload\Loader::init(__DIR__ . '/..');
use Application\Parse\ConvertXml;
$wsdl = 'http://graphical.weather.gov/xml/'
. 'SOAP_server/ndfdXMLserver.php?wsdl';
$xml = new SimpleXMLIterator($wsdl, 0, TRUE);
$convert = new ConvertXml();
var_dump($convert->xmlToArray($xml));

结果数组显示如下:

它是如何工作的...

要执行相反的操作,请使用本配方中描述的arrayToXml()方法。作为源文档,您可以使用一个包含通过 O'Reilly Media 提供的 MongoDB 培训视频大纲的source/data/mongo.db.global.php文件(免责声明:由本作者提供!)。使用相同的自动加载程序配置和Application\Parse\ConvertXml的实例,这是您可以使用的示例代码:

$convert = new ConvertXml();
header('Content-Type: text/xml');
echo $convert->arrayToXml(include CONFIG_FILE);

这是在浏览器中的输出:

它是如何工作的...

创建一个简单的 REST 客户端

REST 客户端使用超文本传输协议HTTP)向外部 Web 服务生成请求。通过更改 HTTP 方法,我们可以导致外部服务执行不同的操作。虽然有很多可用的方法(或动词),但我们只关注GETPOST。在这个配方中,我们将使用适配器软件设计模式来呈现实现 REST 客户端的两种不同方式。

如何做...

  1. 在我们定义 REST 客户端适配器之前,我们需要定义用于表示请求和响应信息的通用类。首先,我们将从一个抽象类开始,该类具有请求或响应所需的方法和属性:
namespace Application\Web;

class AbstractHttp
{
  1. 接下来,我们定义代表 HTTP 信息的类常量:
const METHOD_GET = 'GET';
const METHOD_POST = 'POST';
const METHOD_PUT = 'PUT';
const METHOD_DELETE = 'DELETE';
const CONTENT_TYPE_HTML = 'text/html';
const CONTENT_TYPE_JSON = 'application/json';
const CONTENT_TYPE_FORM_URL_ENCODED = 
  'application/x-www-form-urlencoded';
const HEADER_CONTENT_TYPE = 'Content-Type';
const TRANSPORT_HTTP = 'http';
const TRANSPORT_HTTPS = 'https';
const STATUS_200 = '200';
const STATUS_401 = '401';
const STATUS_500 = '500';
  1. 然后,我们定义了请求或响应所需的属性:
protected $uri;      // i.e. http://xxx.com/yyy
protected $method;    // i.e. GET, PUT, POST, DELETE
protected $headers;  // HTTP headers
protected $cookies;  // cookies
protected $metaData;  // information about the transmission
protected $transport;  // i.e. http or https
protected $data = array();
  1. 逻辑上,我们需要为这些属性定义 getter 和 setter:
public function setMethod($method)
{
  $this->method = $method;
}
public function getMethod()
{
  return $this->method ?? self::METHOD_GET;
}
// etc.
  1. 有些属性需要通过键访问。为此,我们定义了getXxxByKey()setXxxByKey()方法:
public function setHeaderByKey($key, $value)
{
  $this->headers[$key] = $value;
}
public function getHeaderByKey($key)
{
  return $this->headers[$key] ?? NULL;
}
public function getDataByKey($key)
{
  return $this->data[$key] ?? NULL;
}
public function getMetaDataByKey($key)
{
  return $this->metaData[$key] ?? NULL;
}
  1. 在某些情况下,请求将需要参数。我们假设参数将以 PHP 数组的形式存储在$data属性中。然后我们可以使用http_build_query()函数构建请求 URL:
public function setUri($uri, array $params = NULL)
{
  $this->uri = $uri;
  $first = TRUE;
  if ($params) {
    $this->uri .= '?' . http_build_query($params);
  }
}
public function getDataEncoded()
{
  return http_build_query($this->getData());
}
  1. 最后,我们根据原始请求设置$transport
public function setTransport($transport = NULL)
{
  if ($transport) {
      $this->transport = $transport;
  } else {
      if (substr($this->uri, 0, 5) == self::TRANSPORT_HTTPS) {
          $this->transport = self::TRANSPORT_HTTPS;
      } else {
          $this->transport = self::TRANSPORT_HTTP;
      }
    }
  }
  1. 在这个示例中,我们将定义一个Application\Web\Request类,当我们希望生成一个请求时,可以接受参数,或者在实现接受请求的服务器时,可以填充属性与传入的请求信息:
namespace Application\Web;
class Request extends AbstractHttp
{
  public function __construct(
    $uri = NULL, $method = NULL, array $headers = NULL, 
    array $data = NULL, array $cookies = NULL)
    {
      if (!$headers) $this->headers = $_SERVER ?? array();
      else $this->headers = $headers;
      if (!$uri) $this->uri = $this->headers['PHP_SELF'] ?? '';
      else $this->uri = $uri;
      if (!$method) $this->method = 
        $this->headers['REQUEST_METHOD'] ?? self::METHOD_GET;
      else $this->method = $method;
      if (!$data) $this->data = $_REQUEST ?? array();
      else $this->data = $data;
      if (!$cookies) $this->cookies = $_COOKIE ?? array();
      else $this->cookies = $cookies;
      $this->setTransport();
    }  
}
  1. 现在我们可以转向响应类。在这种情况下,我们将定义一个Application\Web\Received类。这个名称反映了我们正在重新打包从外部网络服务接收到的数据的事实:
namespace Application\Web;
class Received extends AbstractHttp
{
  public function __construct(
    $uri = NULL, $method = NULL, array $headers = NULL, 
    array $data = NULL, array $cookies = NULL)
  {
    $this->uri = $uri;
    $this->method = $method;
    $this->headers = $headers;
    $this->data = $data;
    $this->cookies = $cookies;
    $this->setTransport();
  }  
}

创建基于流的 REST 客户端

我们现在准备考虑两种不同的实现 REST 客户端的方式。第一种方法是使用一个称为Streams的底层 PHP I/O 层。这一层提供了一系列包装器,用于访问外部流资源。默认情况下,任何 PHP 文件命令都将使用文件包装器,这使得可以访问本地文件系统。我们将使用http://https://包装器来实现Application\Web\Client\Streams适配器:

  1. 首先,我们定义一个Application\Web\Client\Streams类:
namespace Application\Web\Client;
use Application\Web\ { Request, Received };
class Streams
{
  const BYTES_TO_READ = 4096;
  1. 接下来,我们定义一个方法来将请求发送到外部网络服务。在GET的情况下,我们将参数添加到 URI 中。在POST的情况下,我们创建一个包含元数据的流上下文,指示远程服务我们正在提供数据。使用 PHP Streams,发出请求只是简单地组合 URI,在POST的情况下设置流上下文。然后我们使用一个简单的fopen()
public static function send(Request $request)
{
  $data = $request->getDataEncoded();
  $received = new Received();
  switch ($request->getMethod()) {
    case Request::METHOD_GET :
      if ($data) {
        $request->setUri($request->getUri() . '?' . $data);
      }
      $resource = fopen($request->getUri(), 'r');
      break;
    case Request::METHOD_POST :
      $opts = [
        $request->getTransport() => 
        [
          'method'  => Request::METHOD_POST,
          'header'  => Request::HEADER_CONTENT_TYPE 
          . ': ' . Request::CONTENT_TYPE_FORM_URL_ENCODED,
          'content' => $data
        ]
      ];
      $resource = fopen($request->getUri(), 'w', 
      stream_context_create($opts));
      break;
    }
    return self::getResults($received, $resource);
}
  1. 最后,我们将看一下如何将结果检索并打包成一个Received对象。您会注意到我们添加了一个解码以 JSON 格式接收数据的规定:
protected static function getResults(Received $received, $resource)
{
  $received->setMetaData(stream_get_meta_data($resource));
  $data = $received->getMetaDataByKey('wrapper_data');
  if (!empty($data) && is_array($data)) {
    foreach($data as $item) {
      if (preg_match('!^HTTP/\d\.\d (\d+?) .*?$!', 
          $item, $matches)) {
          $received->setHeaderByKey('status', $matches[1]);
      } else {
          list($key, $value) = explode(':', $item);
          $received->setHeaderByKey($key, trim($value));
      }
    }
  }
  $payload = '';
  while (!feof($resource)) {
    $payload .= fread($resource, self::BYTES_TO_READ);
  }
  if ($received->getHeaderByKey(Received::HEADER_CONTENT_TYPE)) {
    switch (TRUE) {
      case stripos($received->getHeaderByKey(
                   Received::HEADER_CONTENT_TYPE), 
                   Received::CONTENT_TYPE_JSON) !== FALSE:
        $received->setData(json_decode($payload));
        break;
      default :
        $received->setData($payload);
        break;
          }
    }
    return $received;
}

定义基于 cURL 的 REST 客户端

我们现在将看一下我们的第二种 REST 客户端的方法,其中之一是基于 cURL 扩展的:

  1. 对于这种方法,我们将假设相同的请求和响应类。初始类定义与之前讨论的 Streams 客户端基本相同:
namespace Application\Web\Client;
use Application\Web\ { Request, Received };
class Curl
{
  1. send()方法比使用 Streams 时要简单得多。我们所需要做的就是定义一个选项数组,然后让 cURL 来处理剩下的事情:
public static function send(Request $request)
{
  $data = $request->getDataEncoded();
  $received = new Received();
  switch ($request->getMethod()) {
    case Request::METHOD_GET :
      $uri = ($data) 
        ? $request->getUri() . '?' . $data 
        : $request->getUri();
          $options = [
            CURLOPT_URL => $uri,
            CURLOPT_HEADER => 0,
            CURLOPT_RETURNTRANSFER => TRUE,
            CURLOPT_TIMEOUT => 4
          ];
          break;
  1. POST需要稍有不同的 cURL 参数:
case Request::METHOD_POST :
  $options = [
    CURLOPT_POST => 1,
    CURLOPT_HEADER => 0,
    CURLOPT_URL => $request->getUri(),
    CURLOPT_FRESH_CONNECT => 1,
    CURLOPT_RETURNTRANSFER => 1,
    CURLOPT_FORBID_REUSE => 1,
    CURLOPT_TIMEOUT => 4,
    CURLOPT_POSTFIELDS => $data
  ];
  break;
}
  1. 然后,我们执行一系列 cURL 函数,并通过getResults()运行结果:
$ch = curl_init();
curl_setopt_array($ch, ($options));
if( ! $result = curl_exec($ch))
{
  trigger_error(curl_error($ch));
}
$received->setMetaData(curl_getinfo($ch));
curl_close($ch);
return self::getResults($received, $result);
}
  1. getResults()方法将结果打包成一个Received对象:
protected static function getResults(Received $received, $payload)
{
  $type = $received->getMetaDataByKey('content_type');
  if ($type) {
    switch (TRUE) {
      case stripos($type, 
          Received::CONTENT_TYPE_JSON) !== FALSE):
          $received->setData(json_decode($payload));
          break;
      default :
          $received->setData($payload);
          break;
    }
  }
  return $received;
}

工作原理...

确保将所有前面的代码复制到这些类中:

  • Application\Web\AbstractHttp

  • Application\Web\Request

  • Application\Web\Received

  • Application\Web\Client\Streams

  • Application\Web\Client\Curl

在这个示例中,您可以向 Google Maps API 发出 REST 请求,以获取两个点之间的驾驶路线。您还需要按照developers.google.com/maps/documentation/directions/get-api-key上的说明创建一个 API 密钥。

然后,您可以定义一个chap_07_simple_rest_client_google_maps_curl.php调用脚本,使用Curl客户端发出请求。您还可以考虑定义一个chap_07_simple_rest_client_google_maps_streams.php调用脚本,使用Streams客户端发出请求:

<?php
define('DEFAULT_ORIGIN', 'New York City');
define('DEFAULT_DESTINATION', 'Redondo Beach');
define('DEFAULT_FORMAT', 'json');
$apiKey = include __DIR__ . '/google_api_key.php';
require __DIR__ . '/../Application/Autoload/Loader.php';
Application\Autoload\Loader::init(__DIR__ . '/..');
use Application\Web\Request;
use Application\Web\Client\Curl;

然后您可以获取起点和目的地:

$start = $_GET['start'] ?? DEFAULT_ORIGIN;
$end   = $_GET['end'] ?? DEFAULT_DESTINATION;
$start = strip_tags($start);
$end   = strip_tags($end);

现在,您可以填充Request对象,并使用它来生成请求:

$request = new Request(
  'https://maps.googleapis.com/maps/api/directions/json',
  Request::METHOD_GET,
  NULL,
  ['origin' => $start, 'destination' => $end, 'key' => $apiKey],
  NULL
);

$received = Curl::send($request);
$routes   = $received->getData()->routes[0];
include __DIR__ . '/chap_07_simple_rest_client_google_maps_template.php';

为了说明的目的,您还可以定义一个表示视图逻辑的模板,以显示请求结果:

<?php foreach ($routes->legs as $item) : ?>
  <!-- Trip Info -->
  <br>Distance: <?= $item->distance->text; ?>
  <br>Duration: <?= $item->duration->text; ?>
  <!-- Driving Directions -->
  <table>
    <tr>
    <th>Distance</th><th>Duration</th><th>Directions</th>
    </tr>
    <?php foreach ($item->steps as $step) : ?>
    <?php $class = ($count++ & 01) ? 'color1' : 'color2'; ?>
    <tr>
    <td class="<?= $class ?>"><?= $step->distance->text ?></td>
    <td class="<?= $class ?>"><?= $step->duration->text ?></td>
    <td class="<?= $class ?>">
    <?= $step->html_instructions ?></td>
    </tr>
    <?php endforeach; ?>
  </table>
<?php endforeach; ?>

以下是在浏览器中看到的请求结果:

它是如何工作的...

还有更多...

PHP 标准建议PSR-7)精确地定义了在 PHP 应用程序之间进行请求时要使用的请求和响应对象。这在附录中有详细介绍,定义 PSR-7 类

另请参阅

有关Streams的更多信息,请参阅此 PHP 文档页面php.net/manual/en/book.stream.php。一个经常被问到的问题是“HTTP PUT 和 POST 之间有什么区别?”关于这个话题的优秀讨论,请参考stackoverflow.com/questions/107390/whats-the-difference-between-a-post-and-a-put-http-request。有关从 Google 获取 API 密钥的更多信息,请参考以下网页:

developers.google.com/maps/documentation/directions/get-api-key

developers.google.com/maps/documentation/directions/intro#Introduction

创建一个简单的 REST 服务器

在实现 REST 服务器时有几个考虑因素。回答这三个问题将让您定义 REST 服务:

  • 如何捕获原始请求?

  • 您想要发布什么应用程序编程接口API)?

  • 您打算如何将 HTTP 动词(例如GETPUTPOSTDELETE)映射到 API 方法?

如何做...

  1. 我们将通过构建在前一篇文章创建一个简单的 REST 客户端中定义的请求和响应类来实现我们的 REST 服务器。回顾前一篇文章中讨论的类,包括以下内容:
  • Application\Web\AbstractHttp

  • Application\Web\Request

  • Application\Web\Received

  1. 我们还需要定义一个正式的Application\Web\Response响应类,基于AbstractHttp。这个类与其他类的主要区别在于它接受Application\Web\Request的实例作为参数。主要工作是在__construct()方法中完成的。设置Content-Type标头和状态也很重要:
namespace Application\Web;
class Response extends AbstractHttp
{

  public function __construct(Request $request = NULL, 
                              $status = NULL, $contentType = NULL)
  {
    if ($request) {
      $this->uri = $request->getUri();
      $this->data = $request->getData();
      $this->method = $request->getMethod();
      $this->cookies = $request->getCookies();
      $this->setTransport();
    }
    $this->processHeaders($contentType);
    if ($status) {
      $this->setStatus($status);
    }
  }
  protected function processHeaders($contentType)
  {
    if (!$contentType) {
      $this->setHeaderByKey(self::HEADER_CONTENT_TYPE, 
        self::CONTENT_TYPE_JSON);
    } else {
      $this->setHeaderByKey(self::HEADER_CONTENT_TYPE, 
        $contentType);
    }
  }
  public function setStatus($status)
  {
    $this->status = $status;
  }
  public function getStatus()
  {
    return $this->status;
  }
}
  1. 我们现在可以定义Application\Web\Rest\Server类。您可能会对它有多简单感到惊讶。真正的工作是在相关的 API 类中完成的:

注意

请注意 PHP 7 组使用语法的使用:

use Application\Web\ { Request,Response,Received }
namespace Application\Web\Rest;
use Application\Web\ { Request, Response, Received };
class Server
{
  protected $api;
  public function __construct(ApiInterface $api)
  {
    $this->api = $api;
  }
  1. 接下来,我们定义一个listen()方法,作为请求的目标。服务器实现的核心是这行代码:
$jsonData = json_decode(file_get_contents('php://input'),true);
  1. 这捕获了假定为 JSON 格式的原始输入:
public function listen()
{
  $request  = new Request();
  $response = new Response($request);
  $getPost  = $_REQUEST ?? array();
  $jsonData = json_decode(
    file_get_contents('php://input'),true);
  $jsonData = $jsonData ?? array();
  $request->setData(array_merge($getPost,$jsonData));

注意

我们还添加了身份验证的规定。否则,任何人都可以发出请求并获取潜在的敏感数据。您会注意到我们没有服务器类执行身份验证;相反,我们把它留给 API 类:

if (!$this->api->authenticate($request)) {
    $response->setStatus(Request::STATUS_401);
    echo $this->api::ERROR;
    exit;
}
  1. 然后将 API 方法映射到主要的 HTTP 方法GETPUTPOSTDELETE
$id = $request->getData()[$this->api::ID_FIELD] ?? NULL;
switch (strtoupper($request->getMethod())) {
  case Request::METHOD_POST :
    $this->api->post($request, $response);
    break;
  case Request::METHOD_PUT :
    $this->api->put($request, $response);
    break;
  case Request::METHOD_DELETE :
    $this->api->delete($request, $response);
    break;
  case Request::METHOD_GET :
  default :
    // return all if no params
  $this->api->get($request, $response);
}
  1. 最后,我们打包响应并发送它,以 JSON 编码:
  $this->processResponse($response);
  echo json_encode($response->getData());
}
  1. processResponse()方法设置标头,并确保结果打包为Application\Web\Response对象:
protected function processResponse($response)
{
  if ($response->getHeaders()) {
    foreach ($response->getHeaders() as $key => $value) {
      header($key . ': ' . $value, TRUE, 
             $response->getStatus());
    }
  }        
  header(Request::HEADER_CONTENT_TYPE 
  . ': ' . Request::CONTENT_TYPE_JSON, TRUE);
  if ($response->getCookies()) {
    foreach ($response->getCookies() as $key => $value) {
      setcookie($key, $value);
    }
  }
}
  1. 如前所述,API 类完成了真正的工作。我们首先定义一个抽象类,确保主要方法get()put()等都有对应的实现,并且所有这些方法都接受请求和响应对象作为参数。您可能会注意到我们添加了一个generateToken()方法,它使用 PHP 7 的random_bytes()函数生成一个真正随机的 16 字节序列:
namespace Application\Web\Rest;
use Application\Web\ { Request, Response };
abstract class AbstractApi implements ApiInterface
{
  const TOKEN_BYTE_SIZE  = 16;
  protected $registeredKeys;
  abstract public function get(Request $request, 
                               Response $response);
  abstract public function put(Request $request, 
                               Response $response);
  abstract public function post(Request $request, 
                                Response $response);
  abstract public function delete(Request $request, 
                                  Response $response);
  abstract public function authenticate(Request $request);
  public function __construct($registeredKeys, $tokenField)
  {
    $this->registeredKeys = $registeredKeys;
  }
  public static function generateToken()
  {
    return bin2hex(random_bytes(self::TOKEN_BYTE_SIZE));    
  }
}
  1. 我们还定义了一个相应的接口,可用于架构和设计目的,以及代码开发控制:
namespace Application\Web\Rest;
use Application\Web\ { Request, Response };
interface ApiInterface
{
  public function get(Request $request, Response $response);
  public function put(Request $request, Response $response);
  public function post(Request $request, Response $response);
  public function delete(Request $request, Response $response);
  public function authenticate(Request $request);
}
  1. 在这里,我们提供了一个基于AbstractApi的示例 API。这个类利用了在第五章中定义的数据库类,与数据库交互
namespace Application\Web\Rest;
use Application\Web\ { Request, Response, Received };
use Application\Entity\Customer;
use Application\Database\ { Connection, CustomerService };

class CustomerApi extends AbstractApi
{
  const ERROR = 'ERROR';
  const ERROR_NOT_FOUND = 'ERROR: Not Found';
  const SUCCESS_UPDATE = 'SUCCESS: update succeeded';
  const SUCCESS_DELETE = 'SUCCESS: delete succeeded';
  const ID_FIELD = 'id';      // field name of primary key
  const TOKEN_FIELD = 'token';  // field used for authentication
  const LIMIT_FIELD = 'limit';
  const OFFSET_FIELD = 'offset';
  const DEFAULT_LIMIT = 20;
  const DEFAULT_OFFSET = 0;

  protected $service;

  public function __construct($registeredKeys, 
                              $dbparams, $tokenField = NULL)
  {
    parent::__construct($registeredKeys, $tokenField);
    $this->service = new CustomerService(
      new Connection($dbparams));
  }
  1. 所有方法都接收请求和响应作为参数。您会注意到使用getDataByKey()来检索数据项。实际的数据库交互是由服务类执行的。您可能还会注意到,在所有情况下,我们都设置了 HTTP 状态码来通知客户端成功或失败。在get()的情况下,我们会查找 ID 参数。如果收到,我们只提供有关单个客户的信息。否则,我们使用限制和偏移量提供所有客户的列表:
public function get(Request $request, Response $response)
{
  $result = array();
  $id = $request->getDataByKey(self::ID_FIELD) ?? 0;
  if ($id > 0) {
      $result = $this->service->
        fetchById($id)->entityToArray();  
  } else {
    $limit  = $request->getDataByKey(self::LIMIT_FIELD) 
      ?? self::DEFAULT_LIMIT;
    $offset = $request->getDataByKey(self::OFFSET_FIELD) 
      ?? self::DEFAULT_OFFSET;
    $result = [];
    $fetch = $this->service->fetchAll($limit, $offset);
    foreach ($fetch as $row) {
      $result[] = $row;
    }
  }
  if ($result) {
      $response->setData($result);
      $response->setStatus(Request::STATUS_200);
  } else {
      $response->setData([self::ERROR_NOT_FOUND]);
      $response->setStatus(Request::STATUS_500);
  }
}
  1. put()方法用于插入客户数据:
public function put(Request $request, Response $response)
{
  $cust = Customer::arrayToEntity($request->getData(), 
                                  new Customer());
  if ($newCust = $this->service->save($cust)) {
      $response->setData(['success' => self::SUCCESS_UPDATE, 
                          'id' => $newCust->getId()]);
      $response->setStatus(Request::STATUS_200);
  } else {
      $response->setData([self::ERROR]);
      $response->setStatus(Request::STATUS_500);
  }      
}
  1. post()方法用于更新现有的客户条目:
public function post(Request $request, Response $response)
{
  $id = $request->getDataByKey(self::ID_FIELD) ?? 0;
  $reqData = $request->getData();
  $custData = $this->service->
    fetchById($id)->entityToArray();
  $updateData = array_merge($custData, $reqData);
  $updateCust = Customer::arrayToEntity($updateData, 
  new Customer());
  if ($this->service->save($updateCust)) {
      $response->setData(['success' => self::SUCCESS_UPDATE, 
                          'id' => $updateCust->getId()]);
      $response->setStatus(Request::STATUS_200);
  } else {
      $response->setData([self::ERROR]);
      $response->setStatus(Request::STATUS_500);
  }      
}
  1. 如其名称所示,delete()会删除客户条目:
public function delete(Request $request, Response $response)
{
  $id = $request->getDataByKey(self::ID_FIELD) ?? 0;
  $cust = $this->service->fetchById($id);
  if ($cust && $this->service->remove($cust)) {
      $response->setData(['success' => self::SUCCESS_DELETE, 
                          'id' => $id]);
      $response->setStatus(Request::STATUS_200);
  } else {
      $response->setData([self::ERROR_NOT_FOUND]);
      $response->setStatus(Request::STATUS_500);
  }
}
  1. 最后,我们定义authenticate()来提供一个低级机制来保护 API 使用,例如在这个例子中:
public function authenticate(Request $request)
{
  $authToken = $request->getDataByKey(self::TOKEN_FIELD) 
    ?? FALSE;
  if (in_array($authToken, $this->registeredKeys, TRUE)) {
      return TRUE;
  } else {
      return FALSE;
  }
}
}

它是如何工作的...

定义以下在前面的教程中讨论的类:

  • Application\Web\AbstractHttp

  • Application\Web\Request

  • Application\Web\Received

然后,您可以定义以下在本教程中描述的类,总结在下表中:

类 Application\Web* 在这些步骤中讨论
Response 2
Rest\Server 3 - 8
Rest\AbstractApi 9
Rest\ApiInterface 10
Rest\CustomerApi 11 - 16

现在您可以自由开发自己的 API 类。但是,如果您选择遵循示例Application\Web\Rest\CustomerApi,您还需要确保实现这些类,这些类在第五章中有介绍,与数据库交互

  • Application\Entity\Customer

  • Application\Database\Connection

  • Application\Database\CustomerService

现在您可以定义一个chap_07_simple_rest_server.php脚本来调用 REST 服务器:

<?php
$dbParams = include __DIR__ .  '/../../config/db.config.php';
require __DIR__ . '/../Application/Autoload/Loader.php';
Application\Autoload\Loader::init(__DIR__ . '/..');
use Application\Web\Rest\Server;
use Application\Web\Rest\CustomerApi;
$apiKey = include __DIR__ . '/api_key.php';
$server = new Server(new CustomerApi([$apiKey], $dbParams, 'id'));
$server->listen();

然后,您可以使用内置的 PHP 7 开发服务器来监听端口8080以接收 REST 请求:

**php -S localhost:8080 chap_07_simple_rest_server.php** 

要测试您的 API,可以使用Application\Web\Rest\AbstractApi::generateToken()方法生成一个认证令牌,然后将其放入一个api_key.php文件中,类似于这样:

<?php return '79e9b5211bbf2458a4085707ea378129';

然后,您可以使用通用的 API 客户端(例如前面介绍的那种)或浏览器插件,比如 Chao Zhou 的 RESTClient(有关更多信息,请参见restclient.net/)来生成示例请求。确保在请求中包含令牌,否则定义的 API 将拒绝请求。

这是一个基于AbstractApiPOST请求的示例,用于ID1,将balance字段设置为888888的值:

它是如何工作的...

还有更多...

有许多库可以帮助您实现 REST 服务器。我最喜欢的之一是一个在单个文件中实现 REST 服务器的示例:www.leaseweb.com/labs/2015/10/creating-a-simple-rest-api-in-php/

各种框架,如 CodeIgniter 和 Zend Framework,也有 REST 服务器实现。

创建一个简单的 SOAP 客户端

与实现 REST 客户端或服务器的过程相比,使用 SOAP 非常容易,因为有一个 PHP SOAP 扩展提供了这两种功能。

注意

一个经常被问到的问题是“SOAP 和 REST 之间有什么区别?” SOAP 在内部使用 XML 作为数据格式。SOAP 使用 HTTP 但仅用于传输,否则不了解其他 HTTP 方法。REST 直接操作 HTTP,并且可以使用任何数据格式,但首选 JSON。另一个关键区别是 SOAP 可以与 WSDL 一起操作,这使得服务自描述,因此更容易公开。因此,SOAP 服务通常由国家卫生组织等公共机构提供。

操作步骤...

在本例中,我们将为美国国家气象局提供的现有 SOAP 服务进行 SOAP 请求:

  1. 首先要考虑的是识别WSDL文档。WSDL 是描述服务的 XML 文档:
$wsdl = 'http://graphical.weather.gov/xml/SOAP_server/'
  . 'ndfdXMLserver.php?wsdl';
  1. 接下来,我们使用 WSDL 创建一个soap client实例:
$soap = new SoapClient($wsdl, array('trace' => TRUE));
  1. 然后,我们可以自由地初始化一些变量,以期待天气预报请求:
$units = 'm';
$params = '';
$numDays = 7;
$weather = '';
$format = '24 hourly';
$startTime = new DateTime();
  1. 然后,我们可以进行LatLonListCityNames() SOAP 请求,该请求在 WSDL 中标识为一个操作,以获取服务支持的城市列表。请求以 XML 格式返回,这表明需要创建一个SimpleXLMElement实例:
$xml = new SimpleXMLElement($soap->LatLonListCityNames(1));
  1. 不幸的是,城市及其对应的纬度和经度列表在单独的 XML 节点中。因此,我们使用array_combine() PHP 函数创建一个关联数组,其中纬度/经度是键,城市名是值。然后,我们可以稍后使用这个数组来呈现 HTML SELECT下拉列表,使用asort()对列表进行按字母排序:
$cityNames = explode('|', $xml->cityNameList);
$latLonCity = explode(' ', $xml->latLonList);
$cityLatLon = array_combine($latLonCity, $cityNames);
asort($cityLatLon);
  1. 然后,我们可以按以下方式从 Web 请求中获取城市数据:
$currentLatLon = (isset($_GET['city'])) ? strip_tags(urldecode($_GET['city'])) : '';
  1. 我们希望进行的 SOAP 调用是NDFDgenByDay()。我们可以通过检查 WSDL 来确定提供给 SOAP 服务器的参数的性质:
<message name="NDFDgenByDayRequest">
<part name="latitude" type="xsd:decimal"/>
<part name="longitude" type="xsd:decimal"/>
<part name="startDate" type="xsd:date"/>
<part name="numDays" type="xsd:integer"/>
<part name="Unit" type="xsd:string"/>
<part name="format" type="xsd:string"/>
</message>
  1. 如果设置了$currentLatLon的值,我们可以处理请求。我们将请求包装在try {} catch {}块中,以防抛出任何异常:
if ($currentLatLon) {
  list($lat, $lon) = explode(',', $currentLatLon);
  try {
      $weather = $soap->NDFDgenByDay($lat,$lon,
        $startTime->format('Y-m-d'),$numDays,$unit,$format);
  } catch (Exception $e) {
      $weather .= PHP_EOL;
      $weather .= 'Latitude: ' . $lat . ' | Longitude: ' . $lon;
      $weather .= 'ERROR' . PHP_EOL;
      $weather .= $e->getMessage() . PHP_EOL;
      $weather .= $soap->__getLastResponse() . PHP_EOL;
  }
}
?>

工作原理...

将所有前面的代码复制到chap_07_simple_soap_client_weather_service.php文件中。然后,您可以添加视图逻辑,显示带有城市列表的表单,以及结果:

<form method="get" name="forecast">
<br> City List: 
<select name="city">
<?php foreach ($cityLatLon as $latLon => $city) : ?>
<?php $select = ($currentLatLon == $latLon) ? ' selected' : ''; ?>
<option value="<?= urlencode($latLon) ?>" <?= $select ?>>
<?= $city ?></option>
<?php endforeach; ?>
</select>
<br><input type="submit" value="OK"></td>
</form>
<pre>
<?php var_dump($weather); ?>
</pre>

以下是在浏览器中请求俄亥俄州克利夫兰天气预报的结果:

工作原理...

另请参阅

有关 SOAP 和 REST 之间的区别的讨论,请参阅stackoverflow.com/questions/209905/representational-state-transfer-rest-and-simple-object-access-protocol-soap?lq=1上的文章。

创建一个简单的 SOAP 服务器

与 SOAP 客户端一样,我们可以使用 PHP SOAP 扩展来实现 SOAP 服务器。实现中最困难的部分将是从 API 类生成 WSDL。我们在这里不涵盖该过程,因为有许多好的 WSDL 生成器可用。

操作步骤...

  1. 首先,您需要一个 API,该 API 将由 SOAP 服务器处理。在本例中,我们定义了一个Application\Web\Soap\ProspectsApi类,允许我们创建、读取、更新和删除prospects表:
namespace Application\Web\Soap;
use PDO;
class ProspectsApi
{
  protected $registerKeys;
  protected $pdo;

  public function __construct($pdo, $registeredKeys)
  {
    $this->pdo = $pdo;
    $this->registeredKeys = $registeredKeys;
  }
}
  1. 然后,我们定义与创建、读取、更新和删除相对应的方法。在本例中,方法名为put()get()post()delete()。这些方法依次调用生成 SQL 请求的方法,这些方法从 PDO 实例执行。get()的示例如下:
public function get(array $request, array $response)
{
  if (!$this->authenticate($request)) return FALSE;
  $result = array();
  $id = $request[self::ID_FIELD] ?? 0;
  $email = $request[self::EMAIL_FIELD] ?? 0;
  if ($id > 0) {
      $result = $this->fetchById($id);  
      $response[self::ID_FIELD] = $id;
  } elseif ($email) {
      $result = $this->fetchByEmail($email);
      $response[self::ID_FIELD] = $result[self::ID_FIELD] ?? 0;
  } else {
      $limit = $request[self::LIMIT_FIELD] 
        ?? self::DEFAULT_LIMIT;
      $offset = $request[self::OFFSET_FIELD] 
        ?? self::DEFAULT_OFFSET;
      $result = [];
      foreach ($this->fetchAll($limit, $offset) as $row) {
        $result[] = $row;
      }
  }
  $response = $this->processResponse(
    $result, $response, self::SUCCESS, self::ERROR);
    return $response;
  }

  protected function processResponse($result, $response, 
                                     $success_code, $error_code)
  {
    if ($result) {
        $response['data'] = $result;
        $response['code'] = $success_code;
        $response['status'] = self::STATUS_200;
    } else {
        $response['data'] = FALSE;
        $response['code'] = self::ERROR_NOT_FOUND;
        $response['status'] = self::STATUS_500;
    }
    return $response;
  }
  1. 然后,您可以从您的 API 生成 WSDL。有许多基于 PHP 的 WSDL 生成器可用(请参阅还有更多...部分)。大多数要求您在将要发布的方法之前添加phpDocumentor标签。在我们的示例中,两个参数都是数组。以下是先前讨论的 API 的完整 WSDL:
<?xml version="1.0" encoding="UTF-8"?>
  <wsdl:definitions  targetNamespace="php7cookbook"    >
  <wsdl:message name="getSoapIn">
    <wsdl:part name="request" type="tns:array" />
    <wsdl:part name="response" type="tns:array" />
  </wsdl:message>
  <wsdl:message name="getSoapOut">
    <wsdl:part name="return" type="tns:array" />
  </wsdl:message>
  <!—some nodes removed to conserve space -->
  <wsdl:portType name="CustomerApiSoap">
  <!—some nodes removed to conserve space -->
  <wsdl:binding name="CustomerApiSoap" type="tns:CustomerApiSoap">
  <soap:binding transport="http://schemas.xmlsoap.org/soap/http" style="rpc" />
    <wsdl:operation name="get">
      <soap:operation soapAction="php7cookbook#get" />
        <wsdl:input>
          <soap:body use="encoded" encodingStyle= "http://schemas.xmlsoap.org/soap/encoding/" namespace="php7cookbook" parts="request response" />
        </wsdl:input>
        <wsdl:output>
          <soap:body use="encoded" encodingStyle= "http://schemas.xmlsoap.org/soap/encoding/" namespace="php7cookbook" parts="return" />
        </wsdl:output>
    </wsdl:operation>
  <!—some nodes removed to conserve space -->
  </wsdl:binding>
  <wsdl:service name="CustomerApi">
    <wsdl:port name="CustomerApiSoap" binding="tns:CustomerApiSoap">
    <soap:address location="http://localhost:8080/" />
    </wsdl:port>
  </wsdl:service>
  </wsdl:definitions>
  1. 接下来,创建一个chap_07_simple_soap_server.php文件,用于执行 SOAP 服务器。首先定义 WSDL 的位置和任何其他必要的文件(在本例中,用于数据库配置的文件)。如果设置了wsdl参数,则提供 WSDL 而不是尝试处理请求。在这个例子中,我们使用一个简单的 API 密钥来验证请求。然后创建一个 SOAP 服务器实例,分配一个 API 类的实例,并运行handle()
<?php
define('DB_CONFIG_FILE', '/../config/db.config.php');
define('WSDL_FILENAME', __DIR__ . '/chap_07_wsdl.xml');

if (isset($_GET['wsdl'])) {
    readfile(WSDL_FILENAME);
    exit;
}
$apiKey = include __DIR__ . '/api_key.php';
require __DIR__ . '/../Application/Web/Soap/ProspectsApi.php';
require __DIR__ . '/../Application/Database/Connection.php';
use Application\Database\Connection;
use Application\Web\Soap\ProspectsApi;
$connection = new Application\Database\Connection(
  include __DIR__ . DB_CONFIG_FILE);
$api = new Application\Web\Soap\ProspectsApi(
  $connection->pdo, [$apiKey]);
$server = new SoapServer(WSDL_FILENAME);
$server->setObject($api);
echo $server->handle();

注意

根据您的php.ini文件的设置,您可能需要禁用 WSDL 缓存,方法如下:

ini_set('soap.wsdl_cache_enabled', 0);

如果您在处理传入的POST数据时遇到问题,可以按照以下方式调整此参数:

ini_set('always_populate_raw_post_data', -1);

它是如何工作的...

您可以通过首先创建目标 API 类,然后生成 WSDL 来轻松测试此示例。然后,您可以使用内置的 PHP Web 服务器来提供 SOAP 服务,命令如下:

**php -S localhost:8080 chap_07_simple_soap_server.php** 

然后,您可以使用前面讨论的 SOAP 客户端来调用测试 SOAP 服务:

<?php
define('WSDL_URL', 'http://localhost:8080?wsdl=1');
$clientKey = include __DIR__ . '/api_key.php';
try {
  $client = new SoapClient(WSDL_URL);
  $response = [];
  $email = some_email_generated_by_test;
  $email = 'test5393@unlikelysource.com';
  echo "\nGet Prospect Info for Email: " . $email . "\n";
  $request = ['token' => $clientKey, 'email' => $email];
  $result = $client->get($request,$response);
  var_dump($result);

} catch (SoapFault $e) {
  echo 'ERROR' . PHP_EOL;
  echo $e->getMessage() . PHP_EOL;
} catch (Throwable $e) {
  echo 'ERROR' . PHP_EOL;
  echo $e->getMessage() . PHP_EOL;
} finally {
  echo $client->__getLastResponse() . PHP_EOL;
}

以下是电子邮件地址test5393@unlikelysource.com的输出:

它是如何工作的...

参见

简单地在谷歌上搜索 PHP 的 WSDL 生成器就会得到大约十几个结果。用于生成ProspectsApi类的 WSDL 的生成器基于code.google.com/archive/p/php-wsdl-creator/。有关phpDocumentor的更多信息,请参阅www.phpdoc.org/页面。

第八章:处理日期/时间和国际化方面

在本章中,我们将涵盖以下主题:

  • 在视图脚本中使用表情符号或 emoji

  • 转换复杂字符

  • 从浏览器数据中获取 locale

  • 按区域设置数字格式

  • 按区域设置货币

  • 按区域设置日期/时间格式

  • 创建一个 HTML 国际日历生成器

  • 构建重复事件生成器

  • 处理翻译而无需 gettext

介绍

我们将从利用PHP 7引入的新Unicode转义语法开始本章的两个配方。之后,我们将介绍如何从浏览器数据中确定 Web 访问者的locale。接下来的几个配方将涵盖创建一个 locale 类,它将允许您以特定于 locale 的格式表示数字、货币、日期和时间。最后,我们将介绍一些演示如何生成国际化日历、处理重复事件和执行翻译的配方,而无需使用gettext

在视图脚本中使用表情符号或 emoji

单词emoticonsemotionicon的组合。Emoji源自日本,是另一个更大、更广泛使用的图标集。这些图标是小笑脸、小忍者和在地板上打滚大笑的图标,在任何具有社交网络方面的网站上都很受欢迎。然而,在 PHP 7 之前,制作这些小家伙是一种沮丧的练习。

如何做...

  1. 首先,您需要知道您希望呈现的图标的 Unicode。在互联网上快速搜索将指引您到几个优秀的图表之一。以下是三个hear-no-evilsee-no-evilspeak-no-evil猴子图标的代码:

U+1F648U+1F649U+1F64A

如何做...

  1. 向浏览器输出任何 Unicode 必须得到正确的标识。这通常是通过meta标签完成的。您应该将字符集设置为 UTF-8。以下是一个示例:
<head>
  <title>PHP 7 Cookbook</title>
  <meta http-equiv="content-type" content="text/html;charset=utf-8" />
</head>
  1. 传统的方法是简单地使用 HTML 来显示图标。因此,您可以做如下操作:
<table>
  <tr>
    <td>&#x1F648;</td>
    <td>&#x1F649;</td>
    <td>&#x1F64A;</td>
  </tr>
</table>
  1. 从 PHP 7 开始,您现在可以使用此语法构造完整的 Unicode 字符:"\u{xxx}"。以下是与前述项目中相同的三个图标的示例:
<table>
  <tr>
    <td><?php echo "\u{1F648}"; ?></td>
    <td><?php echo "\u{1F649}"; ?></td>
    <td><?php echo "\u{1F64A}"; ?></td>
  </tr>
</table>

注意

您的操作系统和浏览器都必须支持 Unicode,并且还必须具有正确的字体集。例如,在 Ubuntu Linux 中,您需要安装ttf-ancient-fonts软件包才能在浏览器中看到表情符号。

工作原理...

在 PHP 7 中,引入了一种新的语法,允许您呈现任何 Unicode 字符。与其他语言不同,新的 PHP 语法允许变量数量的十六进制数字。基本格式如下:

\u{xxxx}

整个结构必须使用双引号引起来(或使用heredoc)。xxxx可以是任意组合的十六进制数字,2、4、6 及以上。

创建一个名为chap_08_emoji_using_html.php的文件。一定要包含meta标签,表示正在使用 UTF-8 字符编码的浏览器:

<!DOCTYPE html>
<html>
  <head>
    <title>PHP 7 Cookbook</title>
    <meta http-equiv="content-type" content="text/html;charset=utf-8" />
  </head>

接下来,设置一个基本的 HTML 表格,并显示一行表情符号/emoji:

  <body>
    <table>
      <tr>
        <td>&#x1F648;</td>
        <td>&#x1F649;</td>
        <td>&#x1F64A;</td>
      </tr>
    </table>
  </body>
</html>

现在使用 PHP 添加一行以发出表情符号/emoji:

  <tr>
    <td><?php echo "\u{1F648}"; ?></td>
    <td><?php echo "\u{1F649}"; ?></td>
    <td><?php echo "\u{1F64A}"; ?></td>
  </tr>

以下是从 Firefox 中看到的输出:

工作原理...

另请参阅

转换复杂字符

访问整个 Unicode 字符集的能力为呈现复杂字符,特别是拉丁-1 字母表之外的字符,打开了许多新的可能性。

如何做...

  1. 有些语言是从右到左而不是从左到右阅读的。例如希伯来语和阿拉伯语。在这个例子中,我们向您展示如何使用U+202E Unicode 字符来呈现反向文本。以下代码行打印txet desreveR
echo "\u{202E}Reversed text";
echo "\u{202D}";    // returns output to left-to-right

注意

完成后不要忘记调用从左到右覆盖字符U+202D

  1. 另一个考虑因素是使用组合字符。一个例子是ñ(字母n上面漂浮着一个波浪符~)。这在词语中使用,比如mañana(西班牙语中的早晨或明天,取决于上下文)。有一个组合字符,用 Unicode 代码U+00F1表示。这是它的使用示例,回显mañana
echo "ma\u{00F1}ana"; // shows mañana
  1. 然而,这可能会影响搜索的可能性。想象一下,您的客户没有带有这个组合字符的键盘。如果他们开始输入man试图搜索mañana,他们将不成功。

  2. 访问完整的 Unicode 集合提供了其他可能性。您可以使用组合字符,而不是使用组合字符,它可以在字母上方放置一个浮动的波浪符。在这个echo命令中,输出与以前相同。只是形成单词的方式不同:

echo "man\u{0303}ana"; // also shows mañana
  1. 类似的应用可以用于重音符号。考虑法语单词élève(学生)。您可以使用组合字符来呈现它,也可以使用组合代码将重音符号浮动在字母上方。考虑以下两个例子。这两个例子产生相同的输出,但呈现方式不同:
echo "\u{00E9}l\u{00E8}ve";
echo "e\u{0301}le\u{0300}ve";

它是如何工作的...

创建一个名为chap_08_control_and_combining_unicode.php的文件。确保包含meta标签,表示正在使用 UTF-8 字符编码的浏览器:

<!DOCTYPE html>
<html>
  <head>
    <title>PHP 7 Cookbook</title>
    <meta http-equiv="content-type" content="text/html;charset=utf-8" />
  </head>

接下来,设置基本的 PHP 和 HTML 来显示之前讨论的示例:

  <body>
    <pre>
      <?php
        echo "\u{202E}Reversed text"; // reversed
        //echo "\u{202D}"; // stops reverse
        echo "mañana";  // using pre-composed characters
        echo "ma\u{00F1}ana"; // pre-composed character
        echo "man\u{0303}ana"; // "n" with combining ~ character (U+0303)
        echo "élève";
        echo "\u{00E9}l\u{00E8}ve"; // pre-composed characters
        echo "e\u{0301}le\u{0300}ve"; // e + combining characters
      ?>
    </pre>
</body>
</html>

以下是浏览器的输出:

它是如何工作的...

从浏览器数据获取 locale

为了改善网站上的用户体验,重要的是以用户的区域设置可接受的格式显示信息。Locale 是一个通用术语,用来指示世界的某个地区。IT 社区已经努力使用由语言和国家代码组成的两部分指定来编码 locale。但是当一个人访问您的网站时,如何知道他们的区域设置呢?可能最有用的技术涉及检查 HTTP 语言标头。

如何做到...

  1. 为了封装 locale 功能,我们将假设一个类Application\I18n\Locale。我们将使这个类扩展一个现有的类Locale,这是 PHP 的 Intl 扩展的一部分。

注意

I18n 是 Internationalization 的常见缩写。(计算字母的数量!)

namespace Application\I18n;
use Locale as PhpLocale;
class Locale extends PhpLocale
{
  const FALLBACK_LOCALE = 'en';
  // some code
}
  1. 为了了解传入请求的样子,使用phpinfo(INFO_VARIABLES)。在测试后立即禁用此功能,因为它会向潜在攻击者透露太多信息:
<?php phpinfo(INFO_VARIABLES); ?>
  1. Locale 信息存储在$_SERVER['HTTP_ACCEPT_LANGUAGE']中。该值将采用这种一般形式:ll-CC,rl;q=0.n, ll-CC,rl;q=0.n,如表中所定义:
缩写 意义
ll 代表语言的两个小写字母代码。
- 在语言和国家之间分隔区域代码ll-CC
CC 代表国家的两个大写字母代码。
, 将 locale 代码与回退根 locale代码(通常与语言代码相同)分隔开。
rl 代表建议的根 locale 的两个小写字母代码。
; 将 locale 信息与质量分隔开。如果质量丢失,默认为q=1(100%)概率;这是首选的。
q 质量。
0.n 0.00 到 1.0 之间的某个值。将此值乘以 100,以获得此访问者实际首选语言的概率百分比。
  1. 可能会列出多个 locale。例如,网站访问者可能在他们的计算机上安装了多种语言。PHP 的 Locale 类恰好有一个方法acceptFromHttp(),它读取Accept-language标头字符串并给我们所需的设置:
protected $localeCode;
public function setLocaleCode($acceptLangHeader)
{
  $this->localeCode = $this->acceptFromHttp($acceptLangHeader);
}
  1. 然后我们可以定义适当的 getter。get AcceptLanguage()方法返回$_SERVER['HTTP_ACCEPT_LANGUAGE']中的值。
public function getAcceptLanguage()
{
  return $_SERVER['HTTP_ACCEPT_LANGUAGE'] ?? self::FALLBACK_LOCALE;
}
public function getLocaleCode()
{
  return $this->localeCode;
}
  1. 接下来,我们定义一个构造函数,允许我们“手动”设置区域设置。否则,区域设置信息将从浏览器中获取:
public function __construct($localeString = NULL)
{
  if ($localeString) {
    $this->setLocaleCode($localeString);
  } else {
    $this->setLocaleCode($this->getAcceptLanguage());
  }
}
  1. 现在要做出重要的决定:如何处理这些信息!这将在接下来的几篇文章中介绍。

注意

即使访问者似乎接受一个或多种语言,该访问者并不一定希望以其浏览器指示的语言/区域设置显示内容。因此,尽管您可以根据这些信息设置区域设置,但您还应该为他们提供一个静态的备选语言列表。

它是如何工作的...

在这个例子中,让我们举三个例子:

  • 从浏览器获取的信息

  • 预设区域设置fr-FR

  • 从 RFC 2616 中获取的字符串:da, en-gb;q=0.8, en;q=0.7

将步骤 1 到 6 的代码放入一个名为Locale.php的文件中,该文件位于Application\I18n文件夹中。

接下来,创建一个名为chap_08_getting_locale_from_browser.php的文件,该文件设置自动加载并使用新的类:

<?php
  require __DIR__ . '/../Application/Autoload/Loader.php';
  Application\Autoload\Loader::init(__DIR__ . '/..');
  use Application\I18n\Locale;

现在,您可以定义一个包含三个测试区域设置字符串的数组:

$locale = [NULL, 'fr-FR', 'da, en-gb;q=0.8, en;q=0.7'];

最后,循环遍历三个区域设置字符串,创建新类的实例。回显从getLocaleCode()返回的值,以查看做出了什么选择:

echo '<table>';
foreach ($locale as $code) {
  $locale = new Locale($code); 
  echo '<tr>
    <td>' . htmlspecialchars($code) . '</td>
    <td>' . $locale->getLocaleCode() . '</td>
  </tr>';
}
echo '</table>';

这是结果(稍微加了一点样式):

它是如何工作的...

另请参阅

按区域设置格式化数字

数字表示可以根据区域设置而变化。举一个简单的例子,在英国,三百万八千五百一十二点九十二可以看作是:

3,080,512.92.

然而,在法国,同样的数字可能会显示如下:

3 080 512,92

如何做...

在表示特定区域的数字之前,您需要确定区域设置。这可以使用前面一篇文章中讨论的Application\I18n\Locale类来实现。区域设置可以手动设置或从标头信息中获取。

  1. 接下来,我们将使用NumberFormatter类的format()方法,以区域特定的格式输出和解析数字。首先,我们添加一个属性,该属性将包含NumberFormatter类的一个实例:
use NumberFormatter;
protected $numberFormatter;

注意

我们最初的想法是考虑使用 PHP 函数setlocale()根据区域设置生成格式化的数字。然而,这种传统方法的问题在于一切都将基于这个区域设置。这可能会引入处理根据数据库规范存储的数据的问题。setlocale()的另一个问题是它基于过时的标准,包括 RFC 1766 和 ISO 639。最后,setlocale()高度依赖于操作系统的区域支持,这将使我们的代码不可移植。

  1. 通常,下一步将是在构造函数中设置$numberFormatter。然而,对于我们的Application\I18n\Locale类,这种方法的问题在于,我们最终会得到一个过于庞大的类,因为我们还需要执行货币和日期格式化。因此,我们添加一个getter,首先检查是否已经创建了NumberFormatter的实例。如果没有,则创建并返回一个实例。新的NumberFormatter中的第一个参数是区域代码。第二个参数NumberFormatter::DECIMAL表示我们需要的格式化类型:
public function getNumberFormatter()
{
  if (!$this->numberFormatter) {
    $this->numberFormatter = new NumberFormatter($this->getLocaleCode(), NumberFormatter::DECIMAL);
  }
  return $this->numberFormatter;
}
  1. 然后我们添加一个方法,给定任何数字,将生成一个字符串,该字符串根据区域设置格式化该数字:
public function formatNumber($number)
{
  return $this->getNumberFormatter()->format($number);
}
  1. 接下来,我们添加一个方法,该方法可用于根据区域设置解析数字,生成本机 PHP 数值。请注意,根据服务器的 ICU 版本,结果可能在解析失败时不会返回FALSE
public function parseNumber($string)
{
  $result = $this->getNumberFormatter()->parse($string);
  return ($result) ? $result : self::ERROR_UNABLE_TO_PARSE;
}

它是如何工作的...

按照前面的要点对Application\I18n\Locale类进行添加。然后,您可以创建一个chap_08_formatting_numbers.php文件,其中设置自动加载并使用此类:

<?php
  require __DIR__ . '/../Application/Autoload/Loader.php';
  Application\Autoload\Loader::init(__DIR__ . '/..');
  use Application\I18n\Locale;

为此说明,创建两个Locale实例,一个用于英国,另一个用于法国。您还可以指定一个大数字用于测试:

  $localeFr = new Locale('fr_FR');
  $localeUk = new Locale('en_GB');
  $number   = 1234567.89;
?>

最后,您可以将formatNumber()parseNumber()方法包装在适当的 HTML 显示逻辑中,并查看结果:

<!DOCTYPE html>
<html>
  <head>
    <title>PHP 7 Cookbook</title>
    <meta http-equiv="content-type" content="text/html;charset=utf-8" />
    <link rel="stylesheet" type="text/css" href="php7cookbook_html_table.css">
  </head>
  <body>
    <table>
      <tr>
        <th>Number</th>
        <td>1234567.89</td>
      </tr>
      <tr>
        <th>French Format</th>
        <td><?= $localeFr->formatNumber($number); ?></td>
      </tr>
      <tr>
        <th>UK Format</th>
        <td><?= $localeUk->formatNumber($number); ?></td>
      </tr>
      <tr>
        <th>UK Parse French Number: <?= $localeFr->formatNumber($number) ?></th>
        <td><?= $localeUk->parseNumber($localeFr->formatNumber($number)); ?></td>
      </tr>
      <tr>
        <th>UK Parse UK Number: <?= $localeUk->formatNumber($number) ?></th>
        <td><?= $localeUk->parseNumber($localeUk->formatNumber($number)); ?></td>
      </tr>
      <tr>
        <th>FR Parse FR Number: <?= $localeFr->formatNumber($number) ?></th>
        <td><?= $localeFr->parseNumber($localeFr->formatNumber($number)); ?></td>
      </tr>
      <tr>
        <th>FR Parse UK Number: <?= $localeUk->formatNumber($number) ?></th>
        <td><?= $localeFr->parseNumber($localeUk->formatNumber($number)); ?></td>
      </tr>
    </table>
  </body>
</html>

以下是从浏览器中看到的结果:

它是如何工作的...

注意

请注意,如果区域设置为fr_FR,则解析时,以英国格式化的数字不会返回正确的值。同样,当区域设置为en_GB时,以法国格式化的数字在解析时也不会返回正确的值。因此,在尝试解析数字之前,您可能需要考虑添加验证检查。

另请参阅

按区域设置处理货币

处理货币的技术与处理数字的技术类似。我们甚至会使用相同的NumberFormatter类!然而,有一个主要区别,这是一个停滞不前的问题:为了正确格式化货币,您需要掌握货币代码。

如何做...

  1. 首要任务是以某种格式使货币代码可用。一种可能性是将货币代码简单地添加为Application\I18n\Locale类的构造函数参数:
const FALLBACK_CURRENCY = 'GBP';
protected $currencyCode;
public function __construct($localeString = NULL, $currencyCode = NULL)
{
  // add this to the existing code:
  $this->currencyCode = $currencyCode ?? self::FALLBACK_CURRENCY;
}

注意

尽管这种方法显然是可靠且可行的,但往往会属于半途而废走捷径的范畴!这种方法也往往会消除完全自动化,因为货币代码无法从 HTTP 标头中获取。正如您可能从本书的其他示例中了解到的,我们不会回避更复杂的解决方案,所以,俗话说得好,系好安全带

  1. 我们首先需要建立某种查找机制,即给定一个国家代码,我们可以获取其主要货币代码。为此说明,我们将使用适配器软件设计模式。根据此模式,我们应该能够创建不同的类,这些类可能以完全不同的方式运行,但产生相同的结果。因此,我们需要定义所需的结果。为此目的,我们引入一个类,Application\I18n\IsoCodes。正如您所看到的,这个类具有所有相关的属性,以及一种类似通用的构造函数:
namespace Application\I18n;
class IsoCodes
{
  public $name;
  public $iso2;
  public $iso3;
  public $iso_numeric;
  public $iso_3166;
  public $currency_name;
  public $currency_code;
  public $currency_number;
  public function __construct(array $data)
  {
    $vars = get_object_vars($this);
    foreach ($vars as $key => $value) {
      $this->$key = $data[$key] ?? NULL;
    }
  }
}
  1. 接下来,我们定义一个接口,其中包含我们需要执行国家代码到货币代码查找的方法。在这种情况下,我们引入Application\I18n\IsoCodesInterface
namespace Application\I18n;

interface IsoCodesInterface
{
  public function getCurrencyCodeFromIso2CountryCode($iso2) : IsoCodes;
}
  1. 现在我们准备构建一个查找适配器类,我们将其称为Application\I18n\IsoCodesDb。它实现了上述接口,并接受一个Application\Database\Connection实例(参见第一章,“建立基础”),用于执行查找。构造函数设置所需的信息,包括连接、查找表名称和表示 ISO2 代码的列。接口所需的查找方法然后发出一个 SQL 语句并返回一个数组,然后用于构建一个IsoCodes实例:
namespace Application\I18n;

use PDO;
use Application\Database\Connection;

class IsoCodesDb implements IsoCodesInterface
{
  protected $isoTableName;
  protected $iso2FieldName;
  protected $connection;
  public function __construct(Connection $connection, $isoTableName, $iso2FieldName)
  {
    $this->connection = $connection;
    $this->isoTableName = $isoTableName;
    $this->iso2FieldName = $iso2FieldName;
  }
  public function getCurrencyCodeFromIso2CountryCode($iso2) : IsoCodes
  {
    $sql = sprintf('SELECT * FROM %s WHERE %s = ?', $this->isoTableName, $this->iso2FieldName);
    $stmt = $this->connection->pdo->prepare($sql);
    $stmt->execute([$iso2]);
    return new IsoCodes($stmt->fetch(PDO::FETCH_ASSOC);
  }
}
  1. 现在我们将注意力转回到Application\I18n\Locale类。我们首先添加了一些新的属性和类常量:
const ERROR_UNABLE_TO_PARSE = 'ERROR: Unable to parse';
const FALLBACK_CURRENCY = 'GBP';

protected $currencyFormatter;
protected $currencyLookup;
protected $currencyCode;
  1. 我们添加了一个新的方法,从区域设置字符串中检索国家代码。我们可以利用来自 PHPLocale类(我们扩展的类)的“getRegion()”方法。以防需要,我们还添加了一个“getCurrencyCode()”方法:
public function getCountryCode()
{
  return $this->getRegion($this->getLocaleCode());
}
public function getCurrencyCode()
{
  return $this->currencyCode;
}
  1. 与格式化数字一样,我们定义了一个“getCurrencyFormatter(I)”,就像我们之前所做的“getNumberFormatter()”一样。请注意,使用NumberFormatter定义了$currencyFormatter,但第二个参数不同:
public function getCurrencyFormatter()
{
  if (!$this->currencyFormatter) {
    $this->currencyFormatter = new NumberFormatter($this->getLocaleCode(), NumberFormatter::CURRENCY);
  }
  return $this->currencyFormatter;
}
  1. 然后,如果已定义查找类,我们将在类构造函数中添加货币代码查找:
public function __construct($localeString = NULL, IsoCodesInterface $currencyLookup = NULL)
{
  // add this to the existing code:
  $this->currencyLookup = $currencyLookup;
  if ($this->currencyLookup) {
    $this->currencyCode = $this->currencyLookup->getCurrencyCodeFromIso2CountryCode($this->getCountryCode())->currency_code;
  } else {
    $this->currencyCode = self::FALLBACK_CURRENCY;
  }
}
  1. 然后添加适当的货币格式和解析方法。请注意,与解析数字不同,如果解析操作不成功,解析货币将返回FALSE
public function formatCurrency($currency)
{
  return $this->getCurrencyFormatter()->formatCurrency($currency, $this->currencyCode);
}
public function parseCurrency($string)
{
  $result = $this->getCurrencyFormatter()->parseCurrency($string, $this->currencyCode);
  return ($result) ? $result : self::ERROR_UNABLE_TO_PARSE;
}

工作原理...

创建以下类,如前面几个要点中所述:

讨论的要点
Application\I18n\IsoCodes 3
Application\I18n\IsoCodesInterface 4
Application\I18n\IsoCodesDb 5

为了说明的目的,我们假设有一个填充了数据的 MySQL 数据库表iso_country_codes,其结构如下:

CREATE TABLE `iso_country_codes` (
  `name` varchar(128) NOT NULL,
  `iso2` varchar(2) NOT NULL,
  `iso3` varchar(3) NOT NULL,
  `iso_numeric` int(11) NOT NULL AUTO_INCREMENT,
  `iso_3166` varchar(32) NOT NULL,
  `currency_name` varchar(32) DEFAULT NULL,
  `currency_code` char(3) DEFAULT NULL,
  `currency_number` int(4) DEFAULT NULL,
  PRIMARY KEY (`iso_numeric`)
) ENGINE=InnoDB AUTO_INCREMENT=895 DEFAULT CHARSET=utf8;

按照之前讨论的要点 6 到 9,对Application\I18n\Locale类进行添加。然后可以创建一个chap_08_formatting_currency.php文件,其中设置自动加载并使用适当的类:

<?php
define('DB_CONFIG_FILE', __DIR__ . '/../config/db.config.php');
require __DIR__ . '/../Application/Autoload/Loader.php';
Application\Autoload\Loader::init(__DIR__ . '/..');
use Application\I18n\Locale;
use Application\I18n\IsoCodesDb;
use Application\Database\Connection;
use Application\I18n\Locale;

接下来,我们创建ConnectionIsoCodesDb类的实例:

$connection = new Connection(include DB_CONFIG_FILE);
$isoLookup = new IsoCodesDb($connection, 'iso_country_codes', 'iso2');

为此示例,创建两个Locale实例,一个用于英国,另一个用于法国。您还可以指定一个大数字用于测试:

$localeFr = new Locale('fr-FR', $isoLookup);
$localeUk = new Locale('en_GB', $isoLookup);
$number   = 1234567.89;
?>

最后,您可以将“formatCurrency()”和“parseCurrency()”方法包装在适当的 HTML 显示逻辑中,并查看结果。根据前一个配方中呈现的工作原理...部分(此处未重复以节省树木!)制定您的视图逻辑。这是最终输出:

工作原理...

参见

按区域设置格式化日期/时间

日期和时间的格式因地区而异。作为一个经典的例子,考虑 2016 年,4 月,15 日和晚上的时间。美国人民偏好的格式可能是下午 7:23,2016 年 4 月 15 日,而在中国,您很可能会看到 2016-04-15 19:23。与数字和货币格式化一样,以一种对您的网站访问者可接受的格式显示(和解析)日期也很重要。

操作步骤...

  1. 首先,我们需要修改Application\I18n\Locale,添加语句以使用日期格式化类:
use IntlCalendar;
use IntlDateFormatter;
  1. 接下来,我们添加一个属性来表示IntlDateFormatter实例,以及一系列预定义的常量:
const DATE_TYPE_FULL   = IntlDateFormatter::FULL;
const DATE_TYPE_LONG   = IntlDateFormatter::LONG;
const DATE_TYPE_MEDIUM = IntlDateFormatter::MEDIUM;
const DATE_TYPE_SHORT  = IntlDateFormatter::SHORT;

const ERROR_UNABLE_TO_PARSE = 'ERROR: Unable to parse';
const ERROR_UNABLE_TO_FORMAT = 'ERROR: Unable to format date';
const ERROR_ARGS_STRING_ARRAY = 'ERROR: Date must be string YYYY-mm-dd HH:ii:ss or array(y,m,d,h,i,s)';
const ERROR_CREATE_INTL_DATE_FMT = 'ERROR: Unable to create international date formatter';

protected $dateFormatter;
  1. 之后,我们可以定义一个方法getDateFormatter(),它返回一个IntlDateFormatter实例。$type的值与之前定义的DATE_TYPE_*常量之一相匹配:
public function getDateFormatter($type)
{
  switch ($type) {
    case self::DATE_TYPE_SHORT :
      $formatter = new IntlDateFormatter($this->getLocaleCode(),
        IntlDateFormatter::SHORT, IntlDateFormatter::SHORT);
      break;
    case self::DATE_TYPE_MEDIUM :
      $formatter = new IntlDateFormatter($this->getLocaleCode(), IntlDateFormatter::MEDIUM, IntlDateFormatter::MEDIUM);
      break;
    case self::DATE_TYPE_LONG :
      $formatter = new IntlDateFormatter($this->getLocaleCode(), IntlDateFormatter::LONG, IntlDateFormatter::LONG);
      break;
    case self::DATE_TYPE_FULL :
      $formatter = new IntlDateFormatter($this->getLocaleCode(), IntlDateFormatter::FULL, IntlDateFormatter::FULL);
      break;
    default :
      throw new InvalidArgumentException(self::ERROR_CREATE_INTL_DATE_FMT);
  }
  $this->dateFormatter = $formatter;
  return $this->dateFormatter;
}
  1. 接下来,我们定义一个方法,生成一个区域设置格式的日期。定义传入的$date的格式有点棘手。它不能是特定于区域设置的,否则我们将需要根据区域设置规则解析它,结果难以预测。更好的策略是接受一个代表年、月、日等值的整数数组。作为备用方案,我们将接受一个字符串,但只能是这种格式:YYYY-mm-dd HH:ii:ss。时区是可选的,可以单独设置。首先我们初始化变量:
public function formatDate($date, $type, $timeZone = NULL)
{
  $result   = NULL;
  $year     = date('Y');
  $month    = date('m');
  $day      = date('d');
  $hour     = 0;
  $minutes  = 0;
  $seconds  = 0;
  1. 之后,我们生成代表年、月、日等值的值的分解:
if (is_string($date)) {
  list($dateParts, $timeParts) = explode(' ', $date);
  list($year,$month,$day) = explode('-',$dateParts);
  list($hour,$minutes,$seconds) = explode(':',$timeParts);
} elseif (is_array($date)) {
  list($year,$month,$day,$hour,$minutes,$seconds) = $date;
} else {
  throw new InvalidArgumentException(self::ERROR_ARGS_STRING_ARRAY);
}
  1. 接下来,我们创建一个IntlCalendar实例,它将作为运行format()时的参数。我们使用离散的整数值设置日期:
$intlDate = IntlCalendar::createInstance($timeZone, $this->getLocaleCode());
$intlDate->set($year,$month,$day,$hour,$minutes,$seconds);
  1. 最后,我们获得日期格式化程序实例,并生成结果:
  $formatter = $this->getDateFormatter($type);
  if ($timeZone) {
    $formatter->setTimeZone($timeZone);
  }
  $result = $formatter->format($intlDate);
  return $result ?? self::ERROR_UNABLE_TO_FORMAT;
}
  1. parseDate()方法实际上比格式化更简单。唯一的复杂之处在于如果未指定类型要做什么(这可能是最常见的情况)。我们需要做的就是循环遍历所有可能的类型(只有四种),直到产生结果为止:
public function parseDate($string, $type = NULL)
{
 if ($type) {
  $result = $this->getDateFormatter($type)->parse($string);
 } else {
  $tryThese = [self::DATE_TYPE_FULL,
    self::DATE_TYPE_LONG,
    self::DATE_TYPE_MEDIUM,
    self::DATE_TYPE_SHORT];
  foreach ($tryThese as $type) {
  $result = $this->getDateFormatter($type)->parse($string);
    if ($result) {
      break;
    }
  }
 }
 return ($result) ? $result : self::ERROR_UNABLE_TO_PARSE;
}

它是如何工作的...

对之前讨论过的Application\I18n\Locale进行更改。然后,您可以创建一个测试文件chap_08_formatting_date.php,设置自动加载,并创建Locale类的两个实例,一个用于美国,另一个用于法国:

<?php
require __DIR__ . '/../Application/Autoload/Loader.php';
Application\Autoload\Loader::init(__DIR__ . '/..');
use Application\I18n\Locale;

$localeFr = new Locale('fr-FR');
$localeUs = new Locale('en_US');
$date     = '2016-02-29 17:23:58';
?>

接下来,通过合适的样式,运行formatDate()parseDate()的测试:

echo $localeFr->formatDate($date, Locale::DATE_TYPE_FULL);
echo $localeUs->formatDate($date, Locale::DATE_TYPE_MEDIUM);
$localeUs->parseDate($localeFr->formatDate($date, Locale::DATE_TYPE_MEDIUM));
// etc.

这里显示了输出的一个示例:

它是如何工作的...

另请参阅

创建 HTML 国际日历生成器

创建一个显示日历的程序是你在中学时最有可能做的事情。一个嵌套的for()循环,内部循环生成一个七天的列表,通常就足够了。甚至每个月有多少天这个问题也很容易通过一个简单的数组解决。当你需要弄清楚,在任何给定的年份,1 月 1 日是星期几时,情况就会变得棘手起来。还有,如果你想用特定语言和格式表示月份和星期几,符合特定区域设置的话,会怎么样?正如你可能已经猜到的那样,我们将使用之前讨论过的Application\I18n\Locale类构建一个解决方案。

操作步骤...

  1. 首先,我们需要创建一个通用类,用于保存单日的信息。最初,它只会保存一个整数值$dayOfMonth。稍后,在下一个示例中,我们将扩展它以包括事件。由于这个类的主要目的是产生$dayOfMonth,我们将把这个值纳入它的构造函数,并定义__invoke()来返回这个值:
namespace Application\I18n;

class Day
{
  public $dayOfMonth;
  public function __construct($dayOfMonth)
  {
    $this->dayOfMonth = $dayOfMonth;
  }
  public function __invoke()
  {
    return $this->dayOfMonth ?? '';
  }
}
  1. 创建一个新的类,它将保存适当的日历生成方法。它将接受一个Application\I18n\Locale的实例,并定义一些类常量和属性。格式代码,如EEEEEMMMM,是从 ICU 日期格式中提取的:
namespace Application\I18n;

use IntlCalendar;

class Calendar
{

  const DAY_1 = 'EEEEE';  // T
  const DAY_2 = 'EEEEEE'; // Tu
  const DAY_3 = 'EEE';   // Tue
  const DAY_FULL = 'EEEE'; // Tuesday
  const MONTH_1 = 'MMMMM'; // M
  const MONTH_3 = 'MMM';  // Mar
  const MONTH_FULL = 'MMMM';  // March
  const DEFAULT_ACROSS = 3;
  const HEIGHT_FULL = '150px';
  const HEIGHT_SMALL = '60px';

  protected $locale;
  protected $dateFormatter;
  protected $yearArray;
  protected $height;

  public function __construct(Locale $locale)
  {
    $this->locale = $locale;
  }

     // other methods are discussed in the following bullets

}
  1. 然后我们定义一个方法,从我们的locale类中返回一个IntlDateFormatter实例。这将存储在一个类属性中,因为它将经常被使用:
protected function getDateFormatter()
{
 if (!$this->dateFormatter) {
  $this->dateFormatter = $this->locale->getDateFormatter(Locale::DATE_TYPE_FULL);
 }
 return $this->dateFormatter;
}
  1. 接下来,我们定义一个核心方法buildMonthArray(),它创建一个多维数组,其中外部键是一年中的周数,内部数组是表示一周的七个元素的天。我们接受年份、月份和可选的时区作为参数。请注意,在变量初始化的一部分中,我们从月份中减去 1。这是因为IntlCalendar::set()方法期望月份的基于 0 的值,其中 0 代表一月,1 代表二月,依此类推:
public function buildMonthArray($year, $month, $timeZone = NULL)
{
$month -= 1; 
//IntlCalendar months are 0 based; Jan==0, Feb==1 and so on
  $day = 1;
  $first = TRUE;
  $value = 0;
  $monthArray = array();
  1. 然后,我们创建一个IntlCalendar实例,并使用它来确定这个月有多少天:
$cal = IntlCalendar::createInstance($timeZone, $this->locale->getLocaleCode());
$cal->set($year, $month, $day);
$maxDaysInMonth = $cal->getActualMaximum(IntlCalendar::FIELD_DAY_OF_MONTH);
  1. 之后,我们使用我们的IntlDateFormatter实例来确定这个月的第一天是星期几。之后,我们将模式设置为w,随后将给出周数:
$formatter = $this->getDateFormatter();
$formatter->setPattern('e');
$firstDayIsWhatDow = $formatter->format($cal);
  1. 现在我们准备通过嵌套循环遍历该月的所有天。外部的while()循环确保我们不会超过月份的末尾。内部循环表示一周中的天。您会注意到我们利用IntlCalendar::get(),它允许我们从各种预定义字段中检索值。如果一年中的周数超过 52,我们还会将周数值调整为 0:
while ($day <= $maxDaysInMonth) {
  for ($dow = 1; $dow <= 7; $dow++) {
    $cal->set($year, $month, $day);
    $weekOfYear = $cal->get(IntlCalendar::FIELD_WEEK_OF_YEAR);
    if ($weekOfYear > 52) $weekOfYear = 0;
  1. 然后,我们检查$first是否仍然设置为TRUE。如果是,我们开始向数组添加日期。否则,数组值设置为NULL。然后,我们关闭所有打开的语句并返回数组。请注意,我们还需要确保内部循环不会超过月份的天数,因此在外部else子句中有额外的if()语句。

注意

请注意,我们不仅存储月份的值,还使用新定义的Application\I18n\Day类。

      if ($first) {
        if ($dow == $firstDayIsWhatDow) {
          $first = FALSE;
          $value = $day++;
        } else {
          $value = NULL;
        }
      } else {
        if ($day <= $maxDaysInMonth) {
          $value = $day++;
        } else {
          $value = NULL;
        }
      }
      $monthArray[$weekOfYear][$dow] = new Day($value);
    }
  }
  return $monthArray;
}

完善国际化输出

  1. 首先,一系列小方法,从提取基于类型的国际格式化日期开始。类型决定我们是否提供星期几的全名、缩写,或者只是一个字母,都适合该区域设置:
protected function getDay($type, $cal)
{
  $formatter = $this->getDateFormatter();
  $formatter->setPattern($type);
  return $formatter->format($cal);
}
  1. 接下来,我们需要一个方法来返回一个星期几的 HTML 行,调用新定义的getDay()方法。如前所述,类型决定了日期的外观:
protected function getWeekHeaderRow($type, $cal, $year, $month, $week)
{
  $output = '<tr>';
  $width  = (int) (100/7);
  foreach ($week as $day) {
    $cal->set($year, $month, $day());
    $output .= '<th style="vertical-align:top;" width="' . $width . '%">' . $this->getDay($type, $cal) . '</th>';
  }
  $output .= '</tr>' . PHP_EOL;
  return $output;
}
  1. 之后,我们定义一个非常简单的方法来返回一行星期日期。请注意,我们利用Day::__invoke()使用:$day()
protected function getWeekDaysRow($week)
{
  $output = '<tr style="height:' . $this->height . ';">';
  $width  = (int) (100/7);
  foreach ($week as $day) {
    $output .= '<td style="vertical-align:top;" width="' . $width . '%">' . $day() .  '</td>';
  }
  $output .= '</tr>' . PHP_EOL;
  return $output;
}
  1. 最后,一个将较小方法组合在一起生成单个月份日历的方法。首先我们构建月份数组,但只有在$yearArray尚不可用时才这样做:
public function calendarForMonth($year, 
    $month, 
    $timeZone = NULL, 
    $dayType = self::DAY_3, 
    $monthType = self::MONTH_FULL, 
    $monthArray = NULL)
{
  $first = 0;
  if (!$monthArray) 
    $monthArray = $this->yearArray[$year][$month]
    ?? $this->buildMonthArray($year, $month, $timeZone);
  1. 月份需要减去1,因为IntlCalendar的月份是基于 0 的:1 月= 0,2 月= 1,依此类推。然后,我们使用时区(如果有的话)和区域设置构建一个IntlCalendar实例。接下来,我们创建一个IntlDateFormatter实例,根据区域设置检索月份名称和其他信息:
  $month--;
  $cal = IntlCalendar::createInstance($timeZone, $this->locale->getLocaleCode());
  $cal->set($year, $month, 1);
  $formatter = $this->getDateFormatter();
  $formatter->setPattern($monthType);
  1. 然后,我们循环遍历月份数组,并调用刚才提到的较小方法来构建最终的输出:
  $this->height = ($dayType == self::DAY_FULL) 
     ? self::HEIGHT_FULL : self::HEIGHT_SMALL;
  $html = '<h1>' . $formatter->format($cal) . '</h1>';
  $header = '';
  $body   = '';
  foreach ($monthArray as $weekNum => $week) {
    if ($first++ == 1) {
      $header .= $this->getWeekHeaderRow($dayType, $cal, $year, $month, $week);
    }
    $body .= $this->getWeekDaysRow($dayType, $week);
  }
  $html .= '<table>' . $header . $body . '</table>' . PHP_EOL;
  return $html;
}
  1. 为了生成整年的日历,只需循环遍历 1 到 12 月。为了方便外部访问,我们首先定义一个构建年份数组的方法:
public function buildYearArray($year, $timeZone = NULL)
{
  $this->yearArray = array();
  for ($month = 1; $month <= 12; $month++) {
    $this->yearArray[$year][$month] = $this->buildMonthArray($year, $month, $timeZone);
  }
  return $this->yearArray;
}

public function getYearArray()
{
  return $this->yearArray;
}
  1. 要为一年生成日历,我们定义一个方法calendarForYear()。如果年份数组尚未构建,我们调用buildYearArray()。我们考虑要显示多少个月份的日历,然后调用calendarForMonth()
public function calendarForYear($year, 
  $timeZone = NULL, 
  $dayType = self::DAY_1, 
  $monthType = self::MONTH_3, 
  $across = self::DEFAULT_ACROSS)
{
  if (!$this->yearArray) $this->buildYearArray($year, $timeZone);
  $yMax = (int) (12 / $across);
  $width = (int) (100 / $across);
  $output = '<table>' . PHP_EOL;
  $month = 1;
  for ($y = 1; $y <= $yMax; $y++) {
    $output .= '<tr>';
    for ($x = 1; $x <= $across; $x++) {
      $output .= '<td style="vertical-align:top;" width="' . $width . '%">' . $this->calendarForMonth($year, $month, $timeZone, $dayType, $monthType, $this->yearArray[$year][$month++]) . '</td>';
    }
    $output .= '</tr>' . PHP_EOL;
  }
  $output .= '</table>';
  return $output;
}

它是如何工作的...

首先,确保按照前面的示例构建Application\I18n\Locale类。之后,在Application\I18n文件夹中创建一个名为Calendar.php的新文件,其中包含本示例中描述的所有方法。

接下来,定义一个调用程序chap_08_html_calendar.php,设置自动加载并创建LocaleCalendar实例。还要确保定义年份和月份:

<?php
require __DIR__ . '/../Application/Autoload/Loader.php';
Application\Autoload\Loader::init(__DIR__ . '/..');
use Application\I18n\Locale;
use Application\I18n\Calendar;

$localeFr = new Locale('fr-FR');
$localeUs = new Locale('en_US');
$localeTh = new Locale('th_TH');
$calendarFr = new Calendar($localeFr);
$calendarUs = new Calendar($localeUs);
$calendarTh = new Calendar($localeTh);
$year = 2016;
$month = 1;
?>

然后,您可以开发适当的视图逻辑来显示不同的日历。例如,您可以包括参数来显示完整的月份和日期名称:

<!DOCTYPE html>
<html>
  <head>
  <title>PHP 7 Cookbook</title>
  <meta http-equiv="content-type" content="text/html;charset=utf-8" />
  <link rel="stylesheet" type="text/css" href="php7cookbook_html_table.css">
  </head>
  <body>
    <h3>Year: <?= $year ?></h3>
    <?= $calendarFr->calendarForMonth($year, $month, NULL, Calendar::DAY_FULL); ?>
    <?= $calendarUs->calendarForMonth($year, $month, NULL, Calendar::DAY_FULL); ?>
    <?= $calendarTh->calendarForMonth($year, $month, NULL, Calendar::DAY_FULL); ?>
  </body>
</html>

它是如何工作的...

通过进行一些修改,您还可以显示整年的日历:

$localeTh = new Locale('th_TH');
$localeEs = new Locale('es_ES');
$calendarTh = new Calendar($localeTh);
$calendarEs = new Calendar($localeEs);
$year = 2016;
echo $calendarTh->calendarForYear($year);
echo $calendarEs->calendarForYear($year);

这是浏览器输出,显示了一个完整的西班牙语年历:

它是如何工作的...

另请参阅

构建一个重复事件生成器

与生成日历相关的一个非常普遍的需求是安排事件。事件可以是一次性事件,发生在一天,或者在周末。然而,更需要跟踪重复事件。我们需要考虑开始日期、重复间隔(每天、每周、每月)以及发生次数或特定的结束日期。

如何做...

  1. 在任何其他事情之前,创建一个表示事件的类将是一个绝妙的主意。最终,您可能会将数据存储在数据库中的这样一个类中。然而,在本示例中,我们将简单地定义类,并将数据库方面留给您的想象力。您会注意到我们将使用DateTime扩展中包含的许多类,这些类非常适合事件生成:
namespace Application\I18n;

use DateTime;
use DatePeriod;
use DateInterval;
use InvalidArgumentException;

class Event
{
  // code
}
  1. 接下来,我们定义一系列有用的类常量和属性。您会注意到,我们将大多数属性定义为public,以节省所需的 getter 和 setter 的数量。间隔被定义为sprintf()格式字符串;%d将被替换为一个值:
const INTERVAL_DAY = 'P%dD';
const INTERVAL_WEEK = 'P%dW';
const INTERVAL_MONTH = 'P%dM';
const FLAG_FIRST = 'FIRST';    // 1st of the month
const ERROR_INVALID_END  = 'Need to supply either # occurrences or an end date';
const ERROR_INVALID_DATE = 'String i.e. YYYY-mm-dd or DateTime instance only';
const ERROR_INVALID_INTERVAL = 'Interval must take the form "P\d+(D | W | M)"';

public $id;
public $flag;
public $value;
public $title;
public $locale;
public $interval;
public $description;
public $occurrences;
public $nextDate;
protected $endDate;
protected $startDate;
  1. 接下来,我们将注意力转向构造函数。我们需要收集和设置与事件相关的所有信息。变量名不言自明。

注意

$value并不是那么清晰。这个参数最终将被替换为间隔格式字符串中的值。因此,例如,如果用户选择$intervalINTERVAL_DAY,并且$value2,则生成的间隔字符串将是P2D,这意味着每隔一天(或每隔 2 天)。

public function __construct($title, 
    $description,
    $startDate,
    $interval,
    $value,
    $occurrences = NULL,
    $endDate = NULL,
    $flag = NULL)
{
  1. 然后我们初始化变量。请注意,ID 是伪随机生成的,但最终可能成为数据库events表中的主键。在这里,我们使用md5()不是出于安全目的,而是为了快速生成哈希,以便 ID 具有一致的外观:
$this->id = md5($title . $interval . $value) . sprintf('%04d', rand(0,9999));
$this->flag = $flag;
$this->value = $value;
$this->title = $title;
$this->description = $description;
$this->occurrences = $occurrences;
  1. 如前所述,间隔参数是一个sprintf()模式,用于构造适当的DateInterval实例:
try {
  $this->interval = new DateInterval(sprintf($interval, $value));
  } catch (Exception $e) {
  error_log($e->getMessage());
  throw new InvalidArgumentException(self::ERROR_INVALID_INTERVAL);
}
  1. 要初始化$startDate,我们调用stringOrDate()。然后,我们尝试通过调用stringOrDate()calcEndDateFromOccurrences()来生成$endDate的值。如果我们既没有结束日期也没有发生次数,就会抛出异常:
  $this->startDate = $this->stringOrDate($startDate);
  if ($endDate) {
    $this->endDate = $this->stringOrDate($endDate);
  } elseif ($occurrences) {
    $this->endDate = $this->calcEndDateFromOccurrences();
  } else {
  throw new InvalidArgumentException(self::ERROR_INVALID_END);
  }
  $this->nextDate = $this->startDate;
}
  1. stringOrDate()方法由几行代码组成,用于检查日期变量的数据类型,并返回DateTime实例或NULL
protected function stringOrDate($date)
{
  if ($date === NULL) { 
    $newDate = NULL;
  } elseif ($date instanceof DateTime) {
    $newDate = $date;
  } elseif (is_string($date)) {
    $newDate = new DateTime($date);
  } else {
    throw new InvalidArgumentException(self::ERROR_INVALID_END);
  }
  return $newDate;
}
  1. 如果设置了$occurrences,我们将从构造函数中调用calcEndDateFromOccurrences()方法,以便我们知道此事件的结束日期。我们利用DatePeriod类,它提供了基于开始日期、DateInterval和发生次数的迭代:
protected function calcEndDateFromOccurrences()
{
  $endDate = new DateTime('now');
  $period = new DatePeriod(
$this->startDate, $this->interval, $this->occurrences);
  foreach ($period as $date) {
    $endDate = $date;
  }
  return $endDate;
}
  1. 接下来,我们加入一个__toString()魔术方法,它简单地回显事件的标题:
public function __toString()
{
  return $this->title;
}
  1. 我们需要为我们的Event类定义的最后一个方法是getNextDate(),在生成日历时使用:
public function  getNextDate(DateTime $today)
{
  if ($today > $this->endDate) {
    return FALSE;
  }
  $next = clone $today;
  $next->add($this->interval);
  return $next;
}
  1. 接下来,我们将注意力转向上一篇食谱中描述的Application\I18n\Calendar类。通过进行一些小的修改,我们准备好将我们新定义的Event类与日历联系起来。首先,我们添加一个新属性$events,以及一个用于以数组形式添加事件的方法。我们使用Event::$id属性来确保事件被合并而不是被覆盖:
protected $events = array();
public function addEvent(Event $event)
{
  $this->events[$event->id] = $event;
}
  1. 接下来,我们添加一个名为processEvents()的方法,该方法在构建年历时将Event实例添加到Day对象中。首先,我们检查是否有任何事件,以及Day对象是否为NULL。您可能还记得,月初可能不是星期的第一天,因此需要将Day对象的值设置为NULL。我们当然不希望将事件添加到一个无效的日期!然后,我们调用Event::getNextDate()并查看日期是否匹配。如果匹配,我们将Event存储到Day::$events[]中,并在Event对象上设置下一个日期:
protected function processEvents($dayObj, $cal)
{
  if ($this->events && $dayObj()) {
    $calDateTime = $cal->toDateTime();
    foreach ($this->events as $id => $eventObj) {
      $next = $eventObj->getNextDate($eventObj->nextDate);
      if ($next) {
        if ($calDateTime->format('Y-m-d') == 
            $eventObj->nextDate->format('Y-m-d')) {
          $dayObj->events[$eventObj->id] = $eventObj;
          $eventObj->nextDate = $next;
        }
      }
    }
  }
  return $dayObj;
}

注意

请注意,我们不直接比较两个对象。这样做的两个原因:首先,一个是DateTime实例,另一个是IntlCalendar实例。另一个更有说服力的原因是,当获取DateTime实例时可能包括小时:分钟:秒,导致两个对象之间的实际值差异。

  1. 现在我们需要在buildMonthArray()方法中添加对processEvents()的调用,使其如下所示:
  while ($day <= $maxDaysInMonth) {
    for ($dow = 1; $dow <= 7; $dow++) {
      // add this to the existing code:
      $dayObj = $this->processEvents(new Day($value), $cal);
      $monthArray[$weekOfYear][$dow] = $dayObj;
    }
  }
  1. 最后,我们需要修改getWeekDaysRow(),添加必要的代码以在框内输出事件信息以及日期:
protected function getWeekDaysRow($type, $week)
{
  $output = '<tr style="height:' . $this->height . ';">';
  $width  = (int) (100/7);
  foreach ($week as $day) {
    $events = '';
    if ($day->events) {
      foreach ($day->events as $single) {
        $events .= '<br>' . $single->title;
        if ($type == self::DAY_FULL) {
          $events .= '<br><i>' . $single->description . '</i>';
        }
      }
    }
    $output .= '<td style="vertical-align:top;" width="' . $width . '%">' 
  . $day() . $events . '</td>';
  }
  $output .= '</tr>' . PHP_EOL;
  return $output;
}

它是如何工作的...

要将事件与日历关联,首先编写步骤 1 到 10 中描述的Application\I18n\Event类。接下来,修改Application\I18n\Calendar,如步骤 11 到 14 中所述。然后,您可以创建一个测试脚本chap_08_recurring_events.php,设置自动加载并创建LocaleCalendar实例。为了说明,继续使用'es_ES'作为区域设置:

<?php
require __DIR__ . '/../Application/Autoload/Loader.php';
Application\Autoload\Loader::init(__DIR__ . '/..');
use Application\I18n\ { Locale, Calendar, Event };

try {
  $year = 2016;
  $localeEs = new Locale('es_ES');
  $calendarEs = new Calendar($localeEs);

现在我们可以开始定义并向日历添加事件。第一个示例添加了一个持续 3 天并从 2016 年 1 月 8 日开始的事件:

  // add event: 3 days
  $title = 'Conf';
  $description = 'Special 3 day symposium on eco-waste';
  $startDate = '2016-01-08';
  $event = new Event($title, $description, $startDate, 
                     Event::INTERVAL_DAY, 1, 2);
  $calendarEs->addEvent($event);

以下是另一个示例,即每月 1 日直到 2017 年 9 月发生的事件:

  $title = 'Pay Rent';
  $description = 'Sent rent check to landlord';
  $startDate = new DateTime('2016-02-01');
  $event = new Event($title, $description, $startDate, 
    Event::INTERVAL_MONTH, 1, '2017-09-01', NULL, Event::FLAG_FIRST);
  $calendarEs->addEvent($event);

然后,您可以根据需要添加每周、每两周、每月等样本事件。然后关闭try...catch块,并生成适当的显示逻辑:

} catch (Throwable $e) {
  $message = $e->getMessage();
}
?>
<!DOCTYPE html>
<head>
  <title>PHP 7 Cookbook</title>
  <meta http-equiv="content-type" content="text/html;charset=utf-8" />
  <link rel="stylesheet" type="text/css" href="php7cookbook_html_table.css">
</head>
<body>
<h3>Year: <?= $year ?></h3>
<?= $calendarEs->calendarForYear($year, 'Europe/Berlin', 
    Calendar::DAY_3, Calendar::MONTH_FULL, 2); ?>
<?= $calendarEs->calendarForMonth($year, 1  , 'Europe/Berlin', 
    Calendar::DAY_FULL); ?>
</body>
</html>

以下是显示年初几个月的输出:

它是如何工作的...

另请参阅

处理翻译而不使用 gettext

翻译是使您的网站对国际客户群体可访问的重要部分。实现这一目标的一种方法是使用基于本地服务器上安装的GNU gettext操作系统工具的 PHP gettext函数。gettext有很好的文档和支持,但使用了传统的方法并具有明显的缺点。因此,在本教程中,我们提出了一种替代翻译方法,您可以构建自己的适配器

需要认识到的一点重要的是,PHP 可用的编程翻译工具主要设计为提供单词或短语的有限翻译,称为msgid消息 ID)。翻译的等效物称为msgstr消息字符串)。因此,通常只涉及相对不变的项目,如菜单、表单、错误或成功消息等。在本教程中,我们将假设您已将实际网页翻译存储为文本块。

注意

如果您需要翻译整个页面的内容,您可以考虑使用Google 翻译 API。但这是一个付费服务。或者,您可以使用Amazon Mechanical Turk以廉价的方式将翻译外包给具有多语言技能的个人。有关 URL,请参阅本教程末尾的另请参阅部分。

如何做...

  1. 我们将再次使用适配器软件设计模式,这次是为了提供翻译源的替代方案。在这个示例中,我们将演示.ini文件、.csv文件和数据库的适配器。

  2. 首先,我们将定义一个接口,稍后将用于标识翻译适配器。翻译适配器的要求非常简单,我们只需要为给定的消息 ID 返回一个消息字符串:

namespace Application\I18n\Translate\Adapter;
interface TranslateAdapterInterface
{
  public function translate($msgid);
}
  1. 接下来,我们定义一个与接口匹配的特质。特质将包含实际所需的代码。请注意,如果我们未能找到消息字符串,我们只需返回消息 ID:
namespace Application\I18n\Translate\Adapter;

trait TranslateAdapterTrait
{
  protected $translation;
  public function translate($msgid)
  {
    return $this->translation[$msgid] ?? $msgid;
  }
}
  1. 现在我们准备定义我们的第一个适配器。在这个示例中,我们将从使用.ini文件作为翻译源的适配器开始。您会注意到的第一件事是,我们使用了之前定义的特质。构造方法将在适配器之间有所不同。在这种情况下,我们使用parse_ini_file()来生成一个键/值对数组,其中键是消息 ID。请注意,我们使用$filePattern参数来替换区域设置,然后可以加载适当的翻译文件:
namespace Application\I18n\Translate\Adapter;

use Exception;
use Application\I18n\Locale;

class Ini implements TranslateAdapterInterface
{
  use TranslateAdapterTrait;
  const ERROR_NOT_FOUND = 'Translation file not found';
  public function __construct(Locale $locale, $filePattern)
  {
    $translateFileName = sprintf($filePattern, $locale->getLocaleCode());
    if (!file_exists($translateFileName)) {
      error_log(self::ERROR_NOT_FOUND . ':' . $translateFileName);
      throw new Exception(self::ERROR_NOT_FOUND);
    } else {
      $this->translation = parse_ini_file($translateFileName);
    }
  }
}
  1. 下一个适配器,Application\I18n\Translate\Adapter\Csv,除了打开翻译文件并使用fgetcsv()循环检索消息 ID / 消息字符串键值对外,其他都相同。这里我们只展示构造函数中的区别:
public function __construct(Locale $locale, $filePattern)
{
  $translateFileName = sprintf($filePattern, $locale->getLocaleCode());
  if (!file_exists($translateFileName)) {
    error_log(self::ERROR_NOT_FOUND . ':' . $translateFileName);
    throw new Exception(self::ERROR_NOT_FOUND);
  } else {
    $fileObj = new SplFileObject($translateFileName, 'r');
    while ($row = $fileObj->fgetcsv()) {
      $this->translation[$row[0]] = $row[1];
    }
  }
}

注意

这两个适配器的一个很大的缺点是,我们需要预加载整个翻译集,如果有大量的翻译,这会对内存造成压力。此外,需要打开和解析翻译文件,这会拖慢性能。

  1. 现在我们介绍第三个适配器,它执行数据库查找,避免了其他两个适配器的问题。我们使用一个PDO准备语句,它在开始时发送到数据库,只发送一次。然后我们根据需要执行多次,提供消息 ID 作为参数。您还会注意到,我们需要覆盖特质中定义的translate()方法。最后,您可能已经注意到我们使用了PDOStatement::fetchColumn(),因为我们只需要一个值:
namespace Application\I18n\Translate\Adapter;

use Exception;
use Application\Database\Connection;
use Application\I18n\Locale;

class Database implements TranslateAdapterInterface
{
  use TranslateAdapterTrait;
  protected $connection;
  protected $statement;
  protected $defaultLocaleCode;
  public function __construct(Locale $locale, 
                              Connection $connection, 
                              $tableName)
  {
    $this->defaultLocaleCode = $locale->getLocaleCode();
    $this->connection = $connection;
    $sql = 'SELECT msgstr FROM ' . $tableName 
       . ' WHERE localeCode = ? AND msgid = ?';
    $this->statement = $this->connection->pdo->prepare($sql);
  }
  public function translate($msgid, $localeCode = NULL)
  {
    if (!$localeCode) $localeCode = $this->defaultLocaleCode;
    $this->statement->execute([$localeCode, $msgid]);
    return $this->statement->fetchColumn();
  }
}
  1. 现在我们准备定义核心的Translation类,它与一个(或多个)适配器相关联。我们分配一个类常量来表示默认的区域设置,并为区域设置、适配器和文本文件模式(稍后解释)设置属性:
namespace Application\I18n\Translate;

use Application\I18n\Locale;
use Application\I18n\Translate\Adapter\TranslateAdapterInterface;

class Translation
{
  const DEFAULT_LOCALE_CODE = 'en_GB';
  protected $defaultLocaleCode;
  protected $adapter = array();
  protected $textFilePattern = array();
  1. 在构造函数中,我们确定区域设置,并将初始适配器设置为此区域设置。通过这种方式,我们能够托管多个适配器:
public function __construct(TranslateAdapterInterface $adapter, 
              $defaultLocaleCode = NULL, 
              $textFilePattern = NULL)
{
  if (!$defaultLocaleCode) {
    $this->defaultLocaleCode = self::DEFAULT_LOCALE_CODE;
  } else {
    $this->defaultLocaleCode = $defaultLocaleCode;
  }
  $this->adapter[$this->defaultLocaleCode] = $adapter;
  $this->textFilePattern[$this->defaultLocaleCode] = $textFilePattern;
}
  1. 接下来,我们定义一系列的 setter,这给了我们更多的灵活性:
public function setAdapter($localeCode, TranslateAdapterInterface $adapter)
{
  $this->adapter[$localeCode] = $adapter;
}
public function setDefaultLocaleCode($localeCode)
{
  $this->defaultLocaleCode = $localeCode;
}
public function setTextFilePattern($localeCode, $pattern)
{
  $this->textFilePattern[$localeCode] = $pattern;
}
  1. 然后,我们定义了 PHP 魔术方法__invoke(),它让我们可以直接调用翻译实例,返回给定消息 ID 的消息字符串:
public function __invoke($msgid, $locale = NULL)
{
  if ($locale === NULL) $locale = $this->defaultLocaleCode;
  return $this->adapter[$locale]->translate($msgid);
}
  1. 最后,我们还添加了一个方法,可以从文本文件中返回翻译的文本块。请记住,这可以修改为使用数据库。我们没有在适配器中包含这个功能,因为它的目的完全不同;我们只想根据一个键返回大块代码,这个键可能是翻译文本文件的文件名:
public function text($key, $localeCode = NULL)
{
  if ($localeCode === NULL) $localeCode = $this->defaultLocaleCode;
  $contents = $key;
  if (isset($this->textFilePattern[$localeCode])) {
    $fn = sprintf($this->textFilePattern[$localeCode], $localeCode, $key);
    if (file_exists($fn)) {
      $contents = file_get_contents($fn);
    }
  }
  return $contents;
}

它是如何工作的...

首先,您需要定义一个目录结构来存放翻译文件。为了说明的目的,您可以创建一个目录,/path/to/project/files/data/languages。在这个目录结构下,创建代表不同区域设置的子目录。对于这个示例,您可以使用这些:de_DEfr_FRen_GBes_ES,分别代表德语、法语、英语和西班牙语。

接下来,您需要创建不同的翻译文件。例如,这是一个代表西班牙语的data/languages/es_ES/translation.ini文件:

Welcome=Bienvenido
About Us=Sobre Nosotros
Contact Us=Contáctenos
Find Us=Encontrarnos
click=clic para más información

同样,为了演示 CSV 适配器,创建一个相同的 CSV 文件,data/languages/es_ES/translation.csv

"Welcome","Bienvenido"
"About Us","Sobre Nosotros"
"Contact Us","Contáctenos"
"Find Us","Encontrarnos"
"click","clic para más información"

最后,创建一个名为translation的数据库表,并用相同的数据填充它。主要区别在于数据库表将具有三个字段:msgidmsgstrlocale_code

CREATE TABLE `translation` (
  `msgid` varchar(255) NOT NULL,
  `msgstr` varchar(255) NOT NULL,
  `locale_code` char(6) NOT NULL DEFAULT '',
  PRIMARY KEY (`msgid`,`locale_code`)
) ENGINE=InnoDB DEFAULT CHARSET=latin1;

接下来,使用本教程中显示的代码定义先前提到的类:

  • Application\I18n\Translate\Adapter\TranslateAdapterInterface

  • Application\I18n\Translate\Adapter\TranslateAdapterTrait

  • Application\I18n\Translate\Adapter\Ini

  • Application\I18n\Translate\Adapter\Csv

  • Application\I18n\Translate\Adapter\Database

  • Application\I18n\Translate\Translation

现在,您可以创建一个名为chap_08_translation_database.php的测试文件,以测试数据库翻译适配器。它应该实现自动加载,使用适当的类,并创建LocaleConnection实例。请注意,TEXT_FILE_PATTERN常量是一个sprintf()模式,其中区域代码和文件名被替换:

<?php
define('DB_CONFIG_FILE', '/../config/db.config.php');
define('TEXT_FILE_PATTERN', __DIR__ . '/../data/languages/%s/%s.txt');
require __DIR__ . '/../Application/Autoload/Loader.php';
Application\Autoload\Loader::init(__DIR__ . '/..');
use Application\I18n\Locale;
use Application\I18n\Translate\ { Translation, Adapter\Database };
use Application\Database\Connection;

$conn = new Connection(include __DIR__ . DB_CONFIG_FILE);
$locale = new Locale('fr_FR');

接下来,创建一个翻译适配器实例,并使用它来创建一个Translation实例:

$adapter = new Database($locale, $conn, 'translation');
$translate = new Translation($adapter, $locale->getLocaleCode(), TEXT_FILE_PATTERN);
?>

最后,创建使用$translate实例的显示逻辑:

<!DOCTYPE html>
<head>
  <title>PHP 7 Cookbook</title>
  <meta http-equiv="content-type" content="text/html;charset=utf-8" />
  <link rel="stylesheet" type="text/css" href="php7cookbook_html_table.css">
</head>
<body>
<table>
<tr>
  <th><h1 style="color:white;"><?= $translate('Welcome') ?></h1></th>
  <td>
    <div style="float:left;width:50%;vertical-align:middle;">
    <h3 style="font-size:24pt;"><i>Some Company, Inc.</i></h3>
    </div>
    <div style="float:right;width:50%;">
    <img src="jcartier-city.png" width="300px"/>
    </div>
  </td>
</tr>
<tr>
  <th>
    <ul>
      <li><?= $translate('About Us') ?></li>
      <li><?= $translate('Contact Us') ?></li>
      <li><?= $translate('Find Us') ?></li>
    </ul>
  </th>
  <td>
    <p>
    <?= $translate->text('main_page'); ?>
    </p>
    <p>
    <a href="#"><?= $translate('click') ?></a>
    </p>
  </td>
</tr>
</table>
</body>
</html>

然后,您可以执行其他类似的测试,替换新的区域设置以获得不同的语言,或者使用另一个适配器来测试不同的数据源。以下是使用fr_FR区域设置和数据库翻译适配器的输出示例:

工作原理...

另请参阅

第九章:开发中间件

在本章中,我们将涵盖以下主题:

  • 使用中间件进行身份验证

  • 使用中间件实现访问控制

  • 使用缓存来提高性能

  • 实现路由

  • 进行跨框架系统调用

  • 使用中间件跨语言

介绍

在 IT 行业中经常发生的情况是,术语被创造出来,然后被使用和滥用。术语中间件也不例外。可以说,这个术语最早是在 2000 年由互联网工程任务组IETF)提出的。最初,这个术语是用于指代在传输层(即 TCP/IP)和应用层之间运行的任何软件。最近,特别是随着PHP 标准推荐编号 7PSR-7)的接受,中间件,特别是在 PHP 世界中,已经被应用到了 Web 客户端-服务器环境中。

注意

本节中的配方将使用附录中定义的具体类,定义 PSR-7 类

使用中间件进行身份验证

中间件的一个非常重要的用途是提供身份验证。大多数基于 Web 的应用程序都需要通过用户名和密码验证访问者的能力。通过将 PSR-7 标准纳入身份验证类,您将使其在各个方面都具有通用性,可以说是足够安全,可以在提供 PSR-7 兼容请求和响应对象的任何框架中使用。

操作步骤...

  1. 我们首先定义一个 Application\Acl\AuthenticateInterface 类。我们使用这个接口来支持适配器软件设计模式,通过允许各种适配器,使我们的 Authenticate 类更具通用性,每个适配器都可以从不同的来源(例如,从文件中,使用 OAuth2 等)获取身份验证。请注意使用 PHP 7 定义返回值数据类型的能力:
namespace Application\Acl;
use Psr\Http\Message\ { RequestInterface, ResponseInterface };
interface AuthenticateInterface
{
  public function login(RequestInterface $request) : 
    ResponseInterface;
}

注意

请注意,通过定义一个需要符合 PSR-7 的请求并生成符合 PSR-7 的响应的方法,我们使得此接口具有普遍适用性。

  1. 接下来,我们定义实现接口所需的 login() 方法的适配器。我们确保使用适当的类,并定义适合的常量和属性。构造函数使用在第五章中定义的 Application\Database\Connection
namespace Application\Acl;
use PDO;
use Application\Database\Connection;
use Psr\Http\Message\ { RequestInterface, ResponseInterface };
use Application\MiddleWare\ { Response, TextStream };
class DbTable  implements AuthenticateInterface
{
  const ERROR_AUTH = 'ERROR: authentication error';
  protected $conn;
  protected $table;
  public function __construct(Connection $conn, $tableName)
  {
    $this->conn = $conn;
    $this->table = $tableName;
  }
  1. 核心 login() 方法从请求对象中提取用户名和密码。然后我们进行直接的数据库查找。如果匹配成功,我们将用户信息存储在响应主体中,以 JSON 编码:
public function login(RequestInterface $request) : 
  ResponseInterface
{
  $code = 401;
  $info = FALSE;
  $body = new TextStream(self::ERROR_AUTH);
  $params = json_decode($request->getBody()->getContents());
  $response = new Response();
  $username = $params->username ?? FALSE;
  if ($username) {
      $sql = 'SELECT * FROM ' . $this->table 
        . ' WHERE email = ?';
      $stmt = $this->conn->pdo->prepare($sql);
      $stmt->execute([$username]);
      $row = $stmt->fetch(PDO::FETCH_ASSOC);
      if ($row) {
          if (password_verify($params->password, 
              $row['password'])) {
                unset($row['password']);
                $body = 
                new TextStream(json_encode($row));
                $response->withBody($body);
                $code = 202;
                $info = $row;
              }
            }
          }
          return $response->withBody($body)->withStatus($code);
        }
      }

提示

最佳实践

永远不要以明文形式存储密码。当您需要进行密码匹配时,请使用 password_verify(),这样就不需要再生成密码哈希。

  1. Authenticate 类是一个实现 AuthenticationInterface 的适配器类的包装器。因此,构造函数接受一个适配器类作为参数,以及一个字符串作为密钥,在其中身份验证信息存储在 $_SESSION 中:
namespace Application\Acl;
use Application\MiddleWare\ { Response, TextStream };
use Psr\Http\Message\ { RequestInterface, ResponseInterface };
class Authenticate
{
  const ERROR_AUTH = 'ERROR: invalid token';
  const DEFAULT_KEY = 'auth';
  protected $adapter;
  protected $token;
  public function __construct(
  AuthenticateInterface $adapter, $key)
  {
    $this->key = $key;
    $this->adapter = $adapter;
  }
  1. 此外,我们提供了一个带有安全令牌的登录表单,可以帮助防止跨站点请求伪造CSRF)攻击:
public function getToken()
{
  $this->token = bin2hex(random_bytes(16));
  $_SESSION['token'] = $this->token;
  return $this->token;
}
public function matchToken($token)
{
  $sessToken = $_SESSION['token'] ?? date('Ymd');
  return ($token == $sessToken);
}
public function getLoginForm($action = NULL)
{
  $action = ($action) ? 'action="' . $action . '" ' : '';
  $output = '<form method="post" ' . $action . '>';
  $output .= '<table><tr><th>Username</th><td>';
  $output .= '<input type="text" name="username" /></td>';
  $output .= '</tr><tr><th>Password</th><td>';
  $output .= '<input type="password" name="password" />';
  $output .= '</td></tr><tr><th>&nbsp;</th>';
  $output .= '<td><input type="submit" /></td>';
  $output .= '</tr></table>';
  $output .= '<input type="hidden" name="token" value="';
  $output .= $this->getToken() . '" />';
  $output .= '</form>';
  return $output;
}
  1. 最后,此类中的 login() 方法将检查令牌是否有效。如果无效,则返回 400 响应。否则,调用适配器的 login() 方法:
public function login(
RequestInterface $request) : ResponseInterface
{
  $params = json_decode($request->getBody()->getContents());
  $token = $params->token ?? FALSE;
  if (!($token && $this->matchToken($token))) {
      $code = 400;
      $body = new TextStream(self::ERROR_AUTH);
      $response = new Response($code, $body);
  } else {
      $response = $this->adapter->login($request);
  }
  if ($response->getStatusCode() >= 200
      && $response->getStatusCode() < 300) {
      $_SESSION[$this->key] = 
        json_decode($response->getBody()->getContents());
  } else {
      $_SESSION[$this->key] = NULL;
  }
  return $response;
}

}

工作原理...

首先,请确保遵循附录中定义的配方。接下来,继续定义本配方中介绍的类,总结如下表所示:

在这些步骤中讨论
Application\Acl\AuthenticateInterface 1
Application\Acl\DbTable 2 - 3
Application\Acl\Authenticate 4 - 6

然后,您可以定义一个 chap_09_middleware_authenticate.php 调用程序,设置自动加载并使用适当的类:

<?php
session_start();
define('DB_CONFIG_FILE', __DIR__ . '/../config/db.config.php');
define('DB_TABLE', 'customer_09');
define('SESSION_KEY', 'auth');
require __DIR__ . '/../Application/Autoload/Loader.php';
Application\Autoload\Loader::init(__DIR__ . '/..');

use Application\Database\Connection;
use Application\Acl\ { DbTable, Authenticate };
use Application\MiddleWare\ { ServerRequest, Request, Constants, TextStream };

现在您可以设置身份验证适配器和核心类了:

$conn   = new Connection(include DB_CONFIG_FILE);
$dbAuth = new DbTable($conn, DB_TABLE);
$auth   = new Authenticate($dbAuth, SESSION_KEY);

确保初始化传入请求,并设置要发送到身份验证类的请求:

$incoming = new ServerRequest();
$incoming->initialize();
$outbound = new Request();

检查传入的类方法是否为POST。如果是,将请求传递给身份验证类:

if ($incoming->getMethod() == Constants::METHOD_POST) {
  $body = new TextStream(json_encode(
  $incoming->getParsedBody()));
  $response = $auth->login($outbound->withBody($body));
}
$action = $incoming->getServerParams()['PHP_SELF'];
?>

显示逻辑如下:

<?= $auth->getLoginForm($action) ?>

这是一个无效身份验证尝试的输出。请注意右侧的401状态代码。在这个示例中,您可以添加对响应对象的var_dump()

工作原理...

这是一个成功的身份验证:

工作原理...

另请参阅

有关如何避免 CSRF 和其他攻击的指导,请参阅第十二章 提高 Web 安全性

使用中间件实现访问控制

顾名思义,中间件位于一系列函数或方法调用的中间。因此,中间件非常适合“门卫”的任务。您可以使用一个中间件类轻松实现访问控制列表ACL)机制,该类读取 ACL 并允许或拒绝对序列中下一个函数或方法调用的访问。

如何做...

  1. 这个过程中可能最困难的部分是确定 ACL 中要包括哪些因素。为了说明,假设我们的用户都被分配了一个level和一个status。在这个示例中,level 的定义如下:
  'levels' => [0, 'BEG', 'INT', 'ADV']
  1. 状态可能表示他们在会员注册过程中的进展。例如,状态为0可能表示他们已启动会员注册过程,但尚未确认。状态为1可能表示他们的电子邮件地址已确认,但他们尚未支付月费,依此类推。

  2. 接下来,我们需要定义我们计划控制的资源。在这种情况下,我们将假设有必要控制对站点上一系列网页的访问。因此,我们需要定义一个这样的资源数组。在 ACL 中,我们可以引用键:

'pages'  => [0 => 'sorry', 'logout' => 'logout', 'login'  => 'auth',
             1 => 'page1', 2 => 'page2', 3 => 'page3',
             4 => 'page4', 5 => 'page5', 6 => 'page6',
             7 => 'page7', 8 => 'page8', 9 => 'page9']
  1. 最后,最重要的配置部分是根据levelstatus对页面进行分配。配置数组中使用的通用模板可能如下所示:
status => ['inherits' => <key>, 'pages' => [level => [pages allowed], etc.]]
  1. 现在我们可以定义Acl类了。与以前一样,我们使用了一些类,并定义了适用于访问控制的常量和属性:
namespace Application\Acl;

use InvalidArgumentException;
use Psr\Http\Message\RequestInterface;
use Application\MiddleWare\ { Constants, Response, TextStream };

class Acl
{
  const DEFAULT_STATUS = '';
  const DEFAULT_LEVEL  = 0;
  const DEFAULT_PAGE   = 0;
  const ERROR_ACL = 'ERROR: authorization error';
  const ERROR_APP = 'ERROR: requested page not listed';
  const ERROR_DEF = 
    'ERROR: must assign keys "levels", "pages" and "allowed"';
  protected $default;
  protected $levels;
  protected $pages;
  protected $allowed; 
  1. __construct()方法中,我们将分配数组分解为$pages(要控制的资源)、$levels$allowed(实际分配)。如果数组不包括这三个子组件中的一个,就会抛出异常:
public function __construct(array $assignments)
{
  $this->default = $assignments['default'] 
    ?? self::DEFAULT_PAGE;
  $this->pages   = $assignments['pages'] ?? FALSE;
  $this->levels  = $assignments['levels'] ?? FALSE;
  $this->allowed = $assignments['allowed'] ?? FALSE;
  if (!($this->pages && $this->levels && $this->allowed)) {
      throw new InvalidArgumentException(self::ERROR_DEF);
  }
}
  1. 您可能已经注意到我们允许继承。在$allowed中,inherits键可以设置为数组中的另一个键。如果是这样,我们需要将其值与当前正在检查的值合并。我们通过反向迭代$allowed,每次循环都合并任何继承的值。顺便说一句,这种方法也只隔离适用于特定statuslevel的规则:
protected function mergeInherited($status, $level)
{
  $allowed = $this->allowed[$status]['pages'][$level] 
    ?? array();
  for ($x = $status; $x > 0; $x--) {
    $inherits = $this->allowed[$x]['inherits'];
    if ($inherits) {
        $subArray = 
          $this->allowed[$inherits]['pages'][$level] 
          ?? array();
        $allowed = array_merge($allowed, $subArray);
    }
  }
  return $allowed;
}
  1. 在处理授权时,我们初始化了一些变量,然后从原始请求 URI 中提取了请求的页面。如果页面参数不存在,我们设置了400代码:
public function isAuthorized(RequestInterface $request)
{
  $code = 401;    // unauthorized
  $text['page'] = $this->pages[$this->default];
  $text['authorized'] = FALSE;
  $page = $request->getUri()->getQueryParams()['page'] 
    ?? FALSE;
  if ($page === FALSE) {
      $code = 400;    // bad request
  1. 否则,我们解码请求体内容,并获取statuslevel。然后我们可以调用mergeInherited(),它返回一个对此statuslevel可访问的页面数组:
} else {
    $params = json_decode(
      $request->getBody()->getContents());
    $status = $params->status ?? self::DEFAULT_LEVEL;
    $level  = $params->level  ?? '*';
    $allowed = $this->mergeInherited($status, $level);
  1. 如果请求的页面在$allowed数组中,我们将状态代码设置为200,并返回一个授权设置,以及与请求的页面代码对应的网页:
if (in_array($page, $allowed)) {
    $code = 200;    // OK
    $text['authorized'] = TRUE;
    $text['page'] = $this->pages[$page];
} else {
    $code = 401;            }
}
  1. 然后我们返回响应,以 JSON 编码,完成:
$body = new TextStream(json_encode($text));
return (new Response())->withStatus($code)
->withBody($body);
}

}

工作原理...

之后,您需要定义Application\Acl\Acl,这在本示例中进行了讨论。现在转到/path/to/source/for/this/chapter文件夹并创建两个目录:publicpages。在pages中,创建一系列 PHP 文件,例如page1.phppage2.php等。以下是其中一个页面的示例:

<?php // page 1 ?>
<h1>Page 1</h1>
<hr>
<p>Lorem ipsum dolor sit amet, consectetur adipiscing elit. etc.</p>

您还可以定义一个menu.php页面,该页面可以包含在输出中:

<?php // menu ?>
<a href="?page=1">Page 1</a>
<a href="?page=2">Page 2</a>
<a href="?page=3">Page 3</a>
// etc.

logout.php页面应销毁会话:

<?php
  $_SESSION['info'] = FALSE;
  session_destroy();
?>
<a href="/">BACK</a>

auth.php页面将显示登录屏幕(如前一示例中所述):

<?= $auth->getLoginForm($action) ?>

然后,您可以创建一个配置文件,根据级别和状态允许访问网页。为了举例说明,将其命名为chap_09_middleware_acl_config.php并返回一个类似于以下内容的数组:

<?php
$min = [0, 'logout'];
return [
  'default' => 0,     // default page
  'levels' => [0, 'BEG', 'INT', 'ADV'],
  'pages'  => [0 => 'sorry', 
  'logout' => 'logout', 
  'login' => 'auth',
               1 => 'page1', 2 => 'page2', 3 => 'page3',
               4 => 'page4', 5 => 'page5', 6 => 'page6',
               7 => 'page7', 8 => 'page8', 9 => 'page9'],
  'allowed' => [
               0 => ['inherits' => FALSE,
                     'pages' => [ '*' => $min, 'BEG' => $min,
                     'INT' => $min,'ADV' => $min]],
               1 => ['inherits' => FALSE,
                     'pages' => ['*' => ['logout'],
                    'BEG' => [1, 'logout'],
                    'INT' => [1,2, 'logout'],
                    'ADV' => [1,2,3, 'logout']]],
               2 => ['inherits' => 1,
                     'pages' => ['BEG' => [4],
                     'INT' => [4,5],
                     'ADV' => [4,5,6]]],
               3 => ['inherits' => 2,
                     'pages' => ['BEG' => [7],
                     'INT' => [7,8],
                     'ADV' => [7,8,9]]]
    ]
];

最后,在public文件夹中,定义index.php,该文件设置自动加载,并最终调用AuthenticateAcl类。与其他示例一样,定义配置文件,设置自动加载,并使用某些类。还要记得启动会话:

<?php
session_start();
session_regenerate_id();
define('DB_CONFIG_FILE', __DIR__ . '/../../config/db.config.php');
define('DB_TABLE', 'customer_09');
define('PAGE_DIR', __DIR__ . '/../pages');
define('SESSION_KEY', 'auth');
require __DIR__ . '/../../Application/Autoload/Loader.php';
Application\Autoload\Loader::init(__DIR__ . '/../..');

use Application\Database\Connection;
use Application\Acl\ { Authenticate, Acl };
use Application\MiddleWare\ { ServerRequest, Request, Constants, TextStream };

提示

最佳实践

保护会话是最佳实践。帮助保护会话的一种简单方法是使用session_regenerate_id(),它使现有的 PHP 会话标识无效并生成一个新的标识。因此,如果攻击者通过非法手段获得会话标识符,任何给定会话标识符有效的时间窗口将被最小化。

现在您可以拉取 ACL 配置,并为AuthenticateAcl创建实例:

$config = require __DIR__ . '/../chap_09_middleware_acl_config.php';
$acl    = new Acl($config);
$conn   = new Connection(include DB_CONFIG_FILE);
$dbAuth = new DbTable($conn, DB_TABLE);
$auth   = new Authenticate($dbAuth, SESSION_KEY);

接下来,定义传入和传出请求实例:

$incoming = new ServerRequest();
$incoming->initialize();
$outbound = new Request();

如果传入的请求方法是post,则调用login()方法处理身份验证:

if (strtolower($incoming->getMethod()) == Constants::METHOD_POST) {
    $body = new TextStream(json_encode(
    $incoming->getParsedBody()));
    $response = $auth->login($outbound->withBody($body));
}

如果为身份验证定义的会话密钥已填充,则表示用户已成功验证。如果没有,我们将编写一个名为later的匿名函数,其中包含身份验证登录页面:

$info = $_SESSION[SESSION_KEY] ?? FALSE;
if (!$info) {
    $execute = function () use ($auth) {
      include PAGE_DIR . '/auth.php';
    };

否则,您可以继续进行 ACL 检查。您首先需要从原始查询中找到用户想要访问的网页,但是:

} else {
    $query = $incoming->getServerParams()['QUERY_STRING'] ?? '';

然后,您可以重新编程$outbound请求以包含此信息:

$outbound->withBody(new TextStream(json_encode($info)));
$outbound->getUri()->withQuery($query);

接下来,您将能够检查授权,提供传出请求作为参数:

$response = $acl->isAuthorized($outbound);

然后,您可以检查authorized参数的返回响应,并编写匿名函数以包含返回的page参数(如果 OK),以及否则包含sorry页面:

$params   = json_decode($response->getBody()->getContents());
$isAllowed = $params->authorized ?? FALSE;
if ($isAllowed) {
    $execute = function () use ($response, $params) {
      include PAGE_DIR .'/' . $params->page . '.php';
      echo '<pre>', var_dump($response), '</pre>';
      echo '<pre>', var_dump($_SESSION[SESSION_KEY]);
      echo '</pre>';
    };
} else {
    $execute = function () use ($response) {
      include PAGE_DIR .'/sorry.php';
      echo '<pre>', var_dump($response), '</pre>';
      echo '<pre>', var_dump($_SESSION[SESSION_KEY]);
      echo '</pre>';
    };
}
}

现在,您只需要设置表单操作并在 HTML 中包装匿名函数:

$action = $incoming->getServerParams()['PHP_SELF'];
?>
<!DOCTYPE html>
<head>
  <title>PHP 7 Cookbook</title>
  <meta http-equiv="content-type" content="text/html;charset=utf-8" />
</head>
<body>
  <?php $execute(); ?>
</body>
</html>

要测试它,您可以使用内置的 PHP Web 服务器,但是您需要使用-t标志指示文档根目录为public

**cd /path/to/source/for/this/chapter**
**php -S localhost:8080 -t public**

从浏览器中,您可以访问http://localhost:8080/ URL。

如果您尝试访问任何页面,您将被重定向回登录页面。根据配置,具有状态=1和级别=BEG的用户只能访问页面1并注销。如果以此用户身份登录,尝试访问页面 2,则输出如下:

它是如何工作的...

另请参阅

一旦用户登录,此示例依赖于$_SESSION作为用户身份验证的唯一手段。有关如何保护 PHP 会话的良好示例,请参见第十二章提高 Web 安全性,特别是名为保护 PHP 会话的示例。

使用缓存提高性能

缓存软件设计模式是存储需要很长时间才能生成的结果的地方。这可以采用漫长的视图脚本或复杂的数据库查询的形式。当然,存储目的地需要具有高性能,如果您希望提高网站访问者的用户体验。由于不同的安装将具有不同的潜在存储目标,因此缓存机制也适用于适配器模式。潜在存储目标的示例包括内存、数据库和文件系统。

如何做...

  1. 与本章中的其他一些配方一样,由于有共享的常量,我们定义了一个独立的Application\Cache\Constants类:
<?php
namespace Application\Cache;

class Constants
{
  const DEFAULT_GROUP  = 'default';
  const DEFAULT_PREFIX = 'CACHE_';
  const DEFAULT_SUFFIX = '.cache';
  const ERROR_GET      = 'ERROR: unable to retrieve from cache';
  // not all constants are shown to conserve space
}
  1. 由于我们遵循适配器设计模式,接下来我们定义一个接口:
namespace Application\Cache;
interface  CacheAdapterInterface
{
  public function hasKey($key);
  public function getFromCache($key, $group);
  public function saveToCache($key, $data, $group);
  public function removeByKey($key);
  public function removeByGroup($group);
}
  1. 现在我们准备定义我们的第一个缓存适配器,在这个示例中,我们使用 MySQL 数据库。我们需要定义将保存列名和准备语句的属性:
namespace Application\Cache;
use PDO;
use Application\Database\Connection;
class Database implements CacheAdapterInterface
{
  protected $sql;
  protected $connection;
  protected $table;
  protected $dataColumnName;
  protected $keyColumnName;
  protected $groupColumnName;
  protected $statementHasKey       = NULL;
  protected $statementGetFromCache = NULL;
  protected $statementSaveToCache  = NULL;
  protected $statementRemoveByKey  = NULL;
  protected $statementRemoveByGroup= NULL;
  1. 构造函数允许我们提供键列名以及Application\Database\Connection实例和用于缓存的表的名称:
public function __construct(Connection $connection,
  $table,
  $idColumnName,
  $keyColumnName,
  $dataColumnName,
  $groupColumnName = Constants::DEFAULT_GROUP)
  {
    $this->connection  = $connection;
    $this->setTable($table);
    $this->setIdColumnName($idColumnName);
    $this->setDataColumnName($dataColumnName);
    $this->setKeyColumnName($keyColumnName);
    $this->setGroupColumnName($groupColumnName);
  }
  1. 接下来的几个方法准备语句,并在访问数据库时调用。我们没有展示所有的方法,但呈现足够的内容来给你一个想法:
public function prepareHasKey()
{
  $sql = 'SELECT `' . $this->idColumnName . '` '
  . 'FROM `'   . $this->table . '` '
  . 'WHERE `'  . $this->keyColumnName . '` = :key ';
  $this->sql[__METHOD__] = $sql;
  $this->statementHasKey = 
  $this->connection->pdo->prepare($sql);
}
public function prepareGetFromCache()
{
  $sql = 'SELECT `' . $this->dataColumnName . '` '
  . 'FROM `'   . $this->table . '` '
  . 'WHERE `'  . $this->keyColumnName . '` = :key '
  . 'AND `'    . $this->groupColumnName . '` = :group';
  $this->sql[__METHOD__] = $sql;
  $this->statementGetFromCache = 
  $this->connection->pdo->prepare($sql);
}
  1. 现在我们定义一个确定给定键的数据是否存在的方法:
public function hasKey($key)
{
  $result = 0;
  try {
      if (!$this->statementHasKey) $this->prepareHasKey();
          $this->statementHasKey->execute(['key' => $key]);
  } catch (Throwable $e) {
      error_log(__METHOD__ . ':' . $e->getMessage());
      throw new Exception(Constants::ERROR_REMOVE_KEY);
  }
  return (int) $this->statementHasKey
  ->fetch(PDO::FETCH_ASSOC)[$this->idColumnName];
}
  1. 核心方法是从缓存中读取和写入的方法。这是从缓存中检索的方法。我们只需要执行准备好的语句,执行SELECT,带有WHERE子句,其中包括键和组:
public function getFromCache(
$key, $group = Constants::DEFAULT_GROUP)
{
  try {
      if (!$this->statementGetFromCache) 
          $this->prepareGetFromCache();
          $this->statementGetFromCache->execute(
            ['key' => $key, 'group' => $group]);
          while ($row = $this->statementGetFromCache
            ->fetch(PDO::FETCH_ASSOC)) {
            if ($row && count($row)) {
                yield unserialize($row[$this->dataColumnName]);
            }
          }
  } catch (Throwable $e) {
      error_log(__METHOD__ . ':' . $e->getMessage());
      throw new Exception(Constants::ERROR_GET);
  }
}
  1. 写入缓存时,我们首先确定是否存在该缓存键的条目。如果是,我们执行UPDATE;否则,我们执行INSERT
public function saveToCache($key, $data, $group = Constants::DEFAULT_GROUP)
{
  $id = $this->hasKey($key);
  $result = 0;
  try {
      if ($id) {
          if (!$this->statementUpdateCache) 
              $this->prepareUpdateCache();
              $result = $this->statementUpdateCache
              ->execute(['key' => $key, 
              'data' => serialize($data), 
              'group' => $group, 
              'id' => $id]);
          } else {
              if (!$this->statementSaveToCache) 
              $this->prepareSaveToCache();
              $result = $this->statementSaveToCache
              ->execute(['key' => $key, 
              'data' => serialize($data), 
              'group' => $group]);
          }
      } catch (Throwable $e) {
          error_log(__METHOD__ . ':' . $e->getMessage());
          throw new Exception(Constants::ERROR_SAVE);
      }
      return $result;
   }
  1. 然后我们定义了两种方法,通过键或组来删除缓存。通过组删除提供了一个方便的机制,如果有大量需要删除的项目:
public function removeByKey($key)
{
  $result = 0;
  try {
      if (!$this->statementRemoveByKey) 
      $this->prepareRemoveByKey();
      $result = $this->statementRemoveByKey->execute(
        ['key' => $key]);
  } catch (Throwable $e) {
      error_log(__METHOD__ . ':' . $e->getMessage());
      throw new Exception(Constants::ERROR_REMOVE_KEY);
  }
  return $result;
}

public function removeByGroup($group)
{
  $result = 0;
  try {
      if (!$this->statementRemoveByGroup) 
          $this->prepareRemoveByGroup();
          $result = $this->statementRemoveByGroup->execute(
            ['group' => $group]);
      } catch (Throwable $e) {
          error_log(__METHOD__ . ':' . $e->getMessage());
          throw new Exception(Constants::ERROR_REMOVE_GROUP);
      }
      return $result;
  }
  1. 最后,我们为每个属性定义获取器和设置器。这里没有展示所有的内容以节省空间:
public function setTable($name)
{
  $this->table = $name;
}
public function getTable()
{
  return $this->table;
}
// etc.
}
  1. 文件系统缓存适配器定义了与之前定义的相同的方法。请注意使用md5(),不是为了安全,而是作为一种快速从键生成文本字符串的方法:
namespace Application\Cache;
use RecursiveIteratorIterator;
use RecursiveDirectoryIterator;
class File implements CacheAdapterInterface
{
  protected $dir;
  protected $prefix;
  protected $suffix;
  public function __construct(
    $dir, $prefix = NULL, $suffix = NULL)
  {
    if (!file_exists($dir)) {
        error_log(__METHOD__ . ':' . Constants::ERROR_DIR_NOT);
        throw new Exception(Constants::ERROR_DIR_NOT);
    }
    $this->dir = $dir;
    $this->prefix = $prefix ?? Constants::DEFAULT_PREFIX;
    $this->suffix = $suffix ?? Constants::DEFAULT_SUFFIX;
  }

  public function hasKey($key)
  {
    $action = function ($name, $md5Key, &$item) {
      if (strpos($name, $md5Key) !== FALSE) {
        $item ++;
      }
    };

    return $this->findKey($key, $action);
  }

  public function getFromCache($key, $group = Constants::DEFAULT_GROUP)
  {
    $fn = $this->dir . '/' . $group . '/' 
    . $this->prefix . md5($key) . $this->suffix;
    if (file_exists($fn)) {
        foreach (file($fn) as $line) { yield $line; }
    } else {
        return array();
    }
  }

  public function saveToCache(
    $key, $data, $group = Constants::DEFAULT_GROUP)
  {
    $baseDir = $this->dir . '/' . $group;
    if (!file_exists($baseDir)) mkdir($baseDir);
    $fn = $baseDir . '/' . $this->prefix . md5($key) 
    . $this->suffix;
    return file_put_contents($fn, json_encode($data));
  }

  protected function findKey($key, callable $action)
  {
    $md5Key = md5($key);
    $iterator = new RecursiveIteratorIterator(
      new RecursiveDirectoryIterator($this->dir),
      RecursiveIteratorIterator::SELF_FIRST);
      $item = 0;
    foreach ($iterator as $name => $obj) {
      $action($name, $md5Key, $item);
    }
    return $item;
  }

  public function removeByKey($key)
  {
    $action = function ($name, $md5Key, &$item) {
      if (strpos($name, $md5Key) !== FALSE) {
        unlink($name);
        $item++;
      }
    };
    return $this->findKey($key, $action);
  }

  public function removeByGroup($group)
  {
    $removed = 0;
    $baseDir = $this->dir . '/' . $group;
    $pattern = $baseDir . '/' . $this->prefix . '*' 
    . $this->suffix;
    foreach (glob($pattern) as $file) {
      unlink($file);
      $removed++;
    }
    return $removed;
  }
}
  1. 现在我们准备介绍核心缓存机制。在构造函数中,我们接受一个实现了CacheAdapterInterface的类作为参数:
namespace Application\Cache;
use Psr\Http\Message\RequestInterface;
use Application\MiddleWare\ { Request, Response, TextStream };
class Core
{
  public function __construct(CacheAdapterInterface $adapter)
  {
    $this->adapter = $adapter;
  }
  1. 接下来是一系列的包装方法,调用适配器中同名的方法,但接受Psr\Http\Message\RequestInterface类作为参数,并返回Psr\Http\Message\ResponseInterface作为响应。我们从一个简单的开始:hasKey()。注意我们如何从请求参数中提取key
public function hasKey(RequestInterface $request)
{
  $key = $request->getUri()->getQueryParams()['key'] ?? '';
  $result = $this->adapter->hasKey($key);
}
  1. 要从缓存中检索信息,我们需要从请求对象中提取键和组参数,然后调用适配器中的相同方法。如果没有获得结果,我们设置一个204代码,表示请求成功,但没有生成内容。否则,我们设置一个200(成功)代码,并遍历结果。然后将所有内容放入响应对象中,并返回:
public function getFromCache(RequestInterface $request)
{
  $text = array();
  $key = $request->getUri()->getQueryParams()['key'] ?? '';
  $group = $request->getUri()->getQueryParams()['group'] 
    ?? Constants::DEFAULT_GROUP;
  $results = $this->adapter->getFromCache($key, $group);
  if (!$results) { 
      $code = 204; 
  } else {
      $code = 200;
      foreach ($results as $line) $text[] = $line;
  }
  if (!$text || count($text) == 0) $code = 204;
  $body = new TextStream(json_encode($text));
  return (new Response())->withStatus($code)
                         ->withBody($body);
}
  1. 奇怪的是,写入缓存几乎与之前定义的方法相同,只是结果预期要么是一个数字(即受影响的行数),要么是一个布尔结果:
public function saveToCache(RequestInterface $request)
{
  $text = array();
  $key = $request->getUri()->getQueryParams()['key'] ?? '';
  $group = $request->getUri()->getQueryParams()['group'] 
    ?? Constants::DEFAULT_GROUP;
  $data = $request->getBody()->getContents();
  $results = $this->adapter->saveToCache($key, $data, $group);
  if (!$results) { 
      $code = 204;
  } else {
      $code = 200;
      $text[] = $results;
  }
      $body = new TextStream(json_encode($text));
      return (new Response())->withStatus($code)
                             ->withBody($body);
  }
  1. 删除方法与预期相似:
public function removeByKey(RequestInterface $request)
{
  $text = array();
  $key = $request->getUri()->getQueryParams()['key'] ?? '';
  $results = $this->adapter->removeByKey($key);
  if (!$results) {
      $code = 204;
  } else {
      $code = 200;
      $text[] = $results;
  }
  $body = new TextStream(json_encode($text));
  return (new Response())->withStatus($code)
                         ->withBody($body);
}

public function removeByGroup(RequestInterface $request)
{
  $text = array();
  $group = $request->getUri()->getQueryParams()['group'] 
    ?? Constants::DEFAULT_GROUP;
  $results = $this->adapter->removeByGroup($group);
  if (!$results) {
      $code = 204;
  } else {
      $code = 200;
      $text[] = $results;
  }
  $body = new TextStream(json_encode($text));
  return (new Response())->withStatus($code)
                         ->withBody($body);
  }
} // closing brace for class Core

它是如何工作的...

为了演示Acl类的使用,您需要定义本篇文章中描述的类,总结如下:

在这些步骤中讨论
Application\Cache\Constants 1
Application\Cache\CacheAdapterInterface 2
Application\Cache\Database 3 - 10
Application\Cache\File 11
Application\Cache\Core 12 - 16

接下来,定义一个测试程序,你可以称之为chap_09_middleware_cache_db.php。在这个程序中,像往常一样,定义必要文件的常量,设置自动加载,使用适当的类,哦...并编写一个生成质数的函数(你可能在这一点上重新阅读最后一点。不用担心,我们可以帮你解决这个问题!):

<?php
define('DB_CONFIG_FILE', __DIR__ . '/../config/db.config.php');
define('DB_TABLE', 'cache');
define('CACHE_DIR', __DIR__ . '/cache');
define('MAX_NUM', 100000);
require __DIR__ . '/../Application/Autoload/Loader.php';
Application\Autoload\Loader::init(__DIR__ . '/..');
use Application\Database\Connection;
use Application\Cache\{ Constants, Core, Database, File };
use Application\MiddleWare\ { Request, TextStream };

好吧,需要一个运行时间很长的函数,所以质数生成器,我们来吧!数字 1、2 和 3 被给定为质数。我们使用 PHP 7 的yield from语法来生成这前三个。然后,我们直接跳到 5,并继续到请求的最大值:

function generatePrimes($max)
{
  yield from [1,2,3];
  for ($x = 5; $x < $max; $x++)
  {
    if($x & 1) {
        $prime = TRUE;
        for($i = 3; $i < $x; $i++) {
            if(($x % $i) === 0) {
                $prime = FALSE;
                break;
            }
        }
        if ($prime) yield $x;
    }
  }
}

然后,您可以设置一个数据库缓存适配器实例,作为核心的参数:

$conn    = new Connection(include DB_CONFIG_FILE);
$dbCache = new Database(
  $conn, DB_TABLE, 'id', 'key', 'data', 'group');
$core    = new Core($dbCache);

或者,如果您希望使用文件缓存适配器,这是适当的代码:

$fileCache = new File(CACHE_DIR);
$core    = new Core($fileCache);

如果您想要清除缓存,可以这样做:

$uriString = '/?group=' . Constants::DEFAULT_GROUP;
$cacheRequest = new Request($uriString, 'get');
$response = $core->removeByGroup($cacheRequest);

您可以使用time()microtime()来查看此脚本在有缓存和无缓存的情况下运行的时间:

$start = time() + microtime(TRUE);
echo "\nTime: " . $start;

接下来,生成一个缓存请求。状态码200表示您能够从缓存中获取素数列表:

$uriString = '/?key=Test1';
$cacheRequest = new Request($uriString, 'get');
$response = $core->getFromCache($cacheRequest);
$status   = $response->getStatusCode();
if ($status == 200) {
    $primes = json_decode($response->getBody()->getContents());

否则,您可以假设未从缓存中获取任何内容,这意味着您需要生成素数,并将结果保存到缓存中:

} else {
    $primes = array();
    foreach (generatePrimes(MAX_NUM) as $num) {
        $primes[] = $num;
    }
    $body = new TextStream(json_encode($primes));
    $response = $core->saveToCache(
    $cacheRequest->withBody($body));
}

然后,您可以检查停止时间,计算差异,并查看您的新素数列表:

$time = time() + microtime(TRUE);
$diff = $time - $start;
echo "\nTime: $time";
echo "\nDifference: $diff";
var_dump($primes);

这是在值存储在缓存之前的预期输出:

它是如何工作的...

现在,您可以再次运行相同的程序,这次是从缓存中检索:

它是如何工作的...

考虑到我们的小素数生成器不是世界上效率最高的,而且演示是在笔记本电脑上运行的,时间从 30 多秒降到了毫秒。

还有更多...

另一个可能的缓存适配器可以围绕Alternate PHP Cache (APC)扩展的命令构建。该扩展包括诸如apc_exists()apc_store()apc_fetch()apc_clear_cache()之类的函数。这些函数非常适合我们的hasKey()saveToCache()getFromCache()removeBy*()函数。

另请参阅

您可能考虑对先前描述的缓存适配器类进行轻微更改,遵循 PSR-6,这是一个针对缓存的标准建议。然而,对于这个标准的接受程度并不像 PSR-7 那样高,因此我们决定在这里提出的配方中不完全遵循这个标准。有关 PSR-6 的更多信息,请参阅www.php-fig.org/psr/psr-6/

实施路由

路由是指接受用户友好的 URL、解析 URL 为其组成部分,然后确定应该调度哪个类和方法的过程。这种实现的优势在于,不仅可以使您的 URL搜索引擎优化SEO)友好,还可以创建规则,包括正则表达式模式,可以提取参数的值。

如何做...

  1. 可能最受欢迎的方法是利用支持URL 重写的 Web 服务器。这样的一个例子是配置为使用mod_rewrite的 Apache Web 服务器。然后,您定义重写规则,允许图形文件请求以及对 CSS 和 JavaScript 的请求保持不变。否则,请求将通过路由方法进行处理。

  2. 另一种潜在的方法是简单地让您的 Web 服务器虚拟主机定义指向特定的路由脚本,然后调用路由类,做出路由决策,并适当地重定向。

  3. 要考虑的第一段代码是如何定义路由配置。显而易见的答案是构造一个数组,其中每个键都指向一个正则表达式,该正则表达式与 URI 路径匹配,并且有某种形式的操作。以下代码片段显示了这种配置的示例。在这个例子中,我们定义了三个路由:homepage和默认路由。默认路由应该放在最后,因为它将匹配之前未匹配的任何内容。操作以匿名函数的形式呈现,如果路由匹配发生,则将执行该函数:

$config = [
  'home' => [
    'uri' => '!^/$!',
    'exec' => function ($matches) {
      include PAGE_DIR . '/page0.php'; }
  ],
  'page' => [
    'uri' => '!^/(page)/(\d+)$!',
      'exec' => function ($matches) {
        include PAGE_DIR . '/page' . $matches[2] . '.php'; }
  ],
  Router::DEFAULT_MATCH => [
    'uri' => '!.*!',
    'exec' => function ($matches) {
      include PAGE_DIR . '/sorry.php'; }
  ],
];
  1. 接下来,我们定义我们的Router类。我们首先定义在检查和匹配路由过程中将有用的常量和属性:
namespace Application\Routing;
use InvalidArgumentException;
use Psr\Http\Message\ServerRequestInterface;
class Router
{
  const DEFAULT_MATCH = 'default';
  const ERROR_NO_DEF  = 'ERROR: must supply a default match';
  protected $request;
  protected $requestUri;
  protected $uriParts;
  protected $docRoot;
  protected $config;
  protected $routeMatch;
  1. 构造函数接受一个符合ServerRequestInterface的类、文档根目录的路径以及前面提到的配置文件。请注意,如果未提供默认配置,则会抛出异常:
public function __construct(ServerRequestInterface $request, $docRoot, $config)
{
  $this->config = $config;
  $this->docRoot = $docRoot;
  $this->request = $request;
  $this->requestUri = 
    $request->getServerParams()['REQUEST_URI'];
  $this->uriParts = explode('/', $this->requestUri);
  if (!isset($config[self::DEFAULT_MATCH])) {
      throw new InvalidArgumentException(
        self::ERROR_NO_DEF);
  }
}
  1. 接下来,我们有一系列的 getter,允许我们检索原始请求、文档根目录和最终路由匹配:
public function getRequest()
{
  return $this->request;
}
public function getDocRoot()
{
  return $this->docRoot;
}
public function getRouteMatch()
{
  return $this->routeMatch;
}
  1. isFileOrDir()方法用于确定我们是否试图匹配 CSS、JavaScript 或图形请求(以及其他可能性):
public function isFileOrDir()
{
  $fn = $this->docRoot . '/' . $this->requestUri;
  $fn = str_replace('//', '/', $fn);
  if (file_exists($fn)) {
      return $fn;
  } else {
      return '';
  }
}
  1. 最后,我们定义了match(),它遍历配置数组,并通过preg_match()运行uri参数。如果匹配成功,则将配置键和preg_match()填充的$matches数组存储在$routeMatch中,并返回回调。如果没有匹配,则返回默认回调:
public function match()
{
  foreach ($this->config as $key => $route) {
    if (preg_match($route['uri'], 
        $this->requestUri, $matches)) {
        $this->routeMatch['key'] = $key;
        $this->routeMatch['match'] = $matches;
        return $route['exec'];
    }
  }
  return $this->config[self::DEFAULT_MATCH]['exec'];
}
}

工作原理...

首先,切换到/path/to/source/for/this/chapter并创建一个名为routing的目录。接下来,定义一个文件index.php,设置自动加载并使用正确的类。您可以定义一个常量PAGE_DIR,指向上一篇文章中创建的pages目录:

<?php
define('DOC_ROOT', __DIR__);
define('PAGE_DIR', DOC_ROOT . '/../pages');

require_once __DIR__ . '/../../Application/Autoload/Loader.php';
Application\Autoload\Loader::init(__DIR__ . '/../..');
use Application\MiddleWare\ServerRequest;
use Application\Routing\Router;

接下来,添加在本教程第 3 步中讨论的配置数组。请注意,您可以在模式的末尾添加(/)?以考虑可选的尾随斜杠。另外,对于home路由,您可以提供两个选项://home

$config = [
  'home' => [
    'uri' => '!^(/|/home)$!',
    'exec' => function ($matches) {
      include PAGE_DIR . '/page0.php'; }
  ],
  'page' => [
    'uri' => '!^/(page)/(\d+)(/)?$!',
    'exec' => function ($matches) {
      include PAGE_DIR . '/page' . $matches[2] . '.php'; }
  ],
  Router::DEFAULT_MATCH => [
    'uri' => '!.*!',
    'exec' => function ($matches) {
      include PAGE_DIR . '/sorry.php'; }
  ],
];

然后,您可以定义一个路由器实例,将初始化的ServerRequest实例作为第一个参数提供:

$router = new Router((new ServerRequest())
  ->initialize(), DOC_ROOT, $config);
$execute = $router->match();
$params  = $router->getRouteMatch()['match'];

然后,您需要检查请求是文件还是目录,以及路由匹配是否为/

if ($fn = $router->isFileOrDir()
    && $router->getRequest()->getUri()->getPath() != '/') {
    return FALSE;
} else {
    include DOC_ROOT . '/main.php';
}

接下来,定义main.php,类似于这样:

<?php // demo using middleware for routing ?>
<!DOCTYPE html>
<head>
  <title>PHP 7 Cookbook</title>
  <meta http-equiv="content-type" 
  content="text/html;charset=utf-8" />
</head>
<body>
    <?php include PAGE_DIR . '/route_menu.php'; ?>
    <?php $execute($params); ?>
</body>
</html>

最后,需要一个使用用户友好路由的修订菜单:

<?php // menu for routing ?>
<a href="/home">Home</a>
<a href="/page/1">Page 1</a>
<a href="/page/2">Page 2</a>
<a href="/page/3">Page 3</a>
<!-- etc. -->

要使用 Apache 测试配置,请定义一个虚拟主机定义,指向/path/to/source/for/this/chapter/routing。此外,定义一个.htaccess文件,将任何不是文件、目录或链接的请求重定向到index.php。或者,您可以直接使用内置的 PHP Web 服务器。在终端窗口或命令提示符中,键入此命令:

**cd /path/to/source/for/this/chapter/routing**
**php -S localhost:8080**

在浏览器中,请求http://localhost:8080/home时的输出如下:

工作原理...

另请参阅

有关使用NGINX Web 服务器进行重写的信息,请参阅本文:nginx.org/en/docs/http/ngx_http_rewrite_module.html。有许多复杂的 PHP 路由库可用,介绍的功能远远超过了这里介绍的简单路由器。这些包括 Altorouter (altorouter.com/),TreeRoute (github.com/baryshev/TreeRoute),FastRoute (github.com/nikic/FastRoute)和 Aura.Router. (github.com/auraphp/Aura.Router)。此外,大多数框架(例如 Zend Framework 2 或 CodeIgniter)都具有自己的路由功能。

进行跨框架系统调用

PSR-7(和中间件)开发的主要原因之一是日益增长的需要在框架之间进行调用。值得注意的是,PSR-7 的主要文档由PHP Framework Interop Group (PHP-FIG)托管。

操作步骤...

  1. 在中间件跨框架调用中使用的主要机制是创建一个驱动程序,依次执行框架调用,维护一个公共的请求和响应对象。预期请求和响应对象分别代表Psr\Http\Message\ServerRequestInterfacePsr\Http\Message\ResponseInterface

  2. 为了说明这一点,我们定义了一个中间件会话验证器。常量和属性反映了会话thumbprint,这是一个我们用来包含网站访问者 IP 地址、浏览器和语言设置等因素的术语:

namespace Application\MiddleWare\Session;
use InvalidArgumentException;
use Psr\Http\Message\ { 
  ServerRequestInterface, ResponseInterface };
use Application\MiddleWare\ { Constants, Response, TextStream };
class Validator
{
  const KEY_TEXT = 'text';
  const KEY_SESSION = 'thumbprint';
  const KEY_STATUS_CODE = 'code';
  const KEY_STATUS_REASON = 'reason';
  const KEY_STOP_TIME = 'stop_time';
  const ERROR_TIME = 'ERROR: session has exceeded stop time';
  const ERROR_SESSION = 'ERROR: thumbprint does not match';
  const SUCCESS_SESSION = 'SUCCESS: session validates OK';
  protected $sessionKey;
  protected $currentPrint;
  protected $storedPrint;
  protected $currentTime;
  protected $storedTime;
  1. 构造函数接受ServerRequestInterface实例和会话作为参数。如果会话是一个数组(比如$_SESSION),我们将其包装在一个类中。我们这样做的原因是,以防我们传递了一个会话对象,比如 Joomla 中使用的JSession。然后,我们使用先前提到的因素创建指纹。如果存储的指纹不可用,我们假设这是第一次,并存储当前的指纹以及停止时间(如果设置了此参数)。我们使用md5()是因为它是一个快速的哈希,不会外部暴露,因此对这个应用程序很有用:
public function __construct(
  ServerRequestInterface $request, $stopTime = NULL)
{
  $this->currentTime  = time();
  $this->storedTime   = $_SESSION[self::KEY_STOP_TIME] ?? 0;
  $this->currentPrint = 
    md5($request->getServerParams()['REMOTE_ADDR']
      . $request->getServerParams()['HTTP_USER_AGENT']
      . $request->getServerParams()['HTTP_ACCEPT_LANGUAGE']);
        $this->storedPrint  = $_SESSION[self::KEY_SESSION] 
      ?? NULL;
  if (empty($this->storedPrint)) {
      $this->storedPrint = $this->currentPrint;
      $_SESSION[self::KEY_SESSION] = $this->storedPrint;
      if ($stopTime) {
          $this->storedTime = $stopTime;
          $_SESSION[self::KEY_STOP_TIME] = $stopTime;
      }
  }
}
  1. 并不需要定义__invoke(),但这个魔术方法对于独立的中间件类非常方便。按照惯例,我们接受ServerRequestInterfaceResponseInterface实例作为参数。在这个方法中,我们只是检查当前的指纹是否与存储的指纹匹配。第一次,当然,它们会匹配。但在后续请求中,有可能会捕获到试图劫持会话的攻击者。此外,如果会话时间超过了停止时间(如果设置了),同样会发送401代码:
public function __invoke(
  ServerRequestInterface $request, Response $response)
{
  $code = 401;  // unauthorized
  if ($this->currentPrint != $this->storedPrint) {
      $text[self::KEY_TEXT] = self::ERROR_SESSION;
      $text[self::KEY_STATUS_REASON] = 
        Constants::STATUS_CODES[401];
  } elseif ($this->storedTime) {
      if ($this->currentTime > $this->storedTime) {
          $text[self::KEY_TEXT] = self::ERROR_TIME;
          $text[self::KEY_STATUS_REASON] = 
            Constants::STATUS_CODES[401];
      } else {
          $code = 200; // success
      }
  }
  if ($code == 200) {
      $text[self::KEY_TEXT] = self::SUCCESS_SESSION;
      $text[self::KEY_STATUS_REASON] = 
        Constants::STATUS_CODES[200];
  }
  $text[self::KEY_STATUS_CODE] = $code;
  $body = new TextStream(json_encode($text));
  return $response->withStatus($code)->withBody($body);
}
  1. 现在我们可以使用我们的新中间件类。至少在这一点上,不同框架之间的调用存在的主要问题在这里总结。因此,我们如何实现中间件在很大程度上取决于最后一点:
  • 并非所有的 PHP 框架都符合 PSR-7

  • 现有的 PSR-7 实现并不完整

  • 所有框架都想成为“老大”

  1. 作为一个例子,让我们来看看Zend Expressive的配置文件,它是一个自称为PSR7 中间件微框架。这里有一个名为middleware-pipeline.global.php的文件,它位于标准 Expressive 应用程序中的config/autoload文件夹中。依赖项键用于标识将在管道中激活的中间件包装类:
<?php
use Zend\Expressive\Container\ApplicationFactory;
use Zend\Expressive\Helper;
return [  
  'dependencies' => [
     'factories' => [
        Helper\ServerUrlMiddleware::class => 
        Helper\ServerUrlMiddlewareFactory::class,
        Helper\UrlHelperMiddleware::class => 
        Helper\UrlHelperMiddlewareFactory::class,
        **// insert your own class here**
     ],
  ],
  1. middleware_pipline键下,您可以标识在路由过程发生之前或之后将被执行的类。可选参数包括patherrorpriority
'middleware_pipeline' => [
   'always' => [
      'middleware' => [
         Helper\ServerUrlMiddleware::class,
      ],
      'priority' => 10000,
   ],
   'routing' => [
      'middleware' => [
         ApplicationFactory::ROUTING_MIDDLEWARE,
         Helper\UrlHelperMiddleware::class,
         **// insert reference to middleware here**
         ApplicationFactory::DISPATCH_MIDDLEWARE,
      ],
      'priority' => 1,
   ],
   'error' => [
      'middleware' => [
         // Add error middleware here.
      ],
      'error'    => true,
      'priority' => -10000,
    ],
  ],
];
  1. 另一种技术是修改现有框架模块的源代码,并向符合 PSR-7 的中间件应用程序发出请求。以下是修改Joomla!安装以包含中间件会话验证器的示例。

  2. 接下来,将此代码添加到/path/to/joomla文件夹中的index.php文件的末尾。由于 Joomla!使用 Composer,我们可以利用 Composer 自动加载程序:

session_start();    // to support use of $_SESSION
$loader = include __DIR__ . '/libraries/vendor/autoload.php';
$loader->add('Application', __DIR__ . '/libraries/vendor');
$loader->add('Psr', __DIR__ . '/libraries/vendor');
  1. 然后,创建我们的中间件会话验证器的实例,并在$app = JFactory::getApplication('site');之前进行验证请求:
$session = JFactory::getSession();
$request = 
  (new Application\MiddleWare\ServerRequest())->initialize();
$response = new Application\MiddleWare\Response();
$validator = new Application\Security\Session\Validator(
  $request, $session);
$response = $validator($request, $response);
if ($response->getStatusCode() != 200) {
  // take some action
}

它是如何工作的...

首先,创建描述步骤 2-5 的Application\MiddleWare\Session\Validator测试中间件类。然后,您需要转到getcomposer.org/并按照说明获取 Composer。将其下载到/path/to/source/for/this/chapter文件夹中。接下来,构建一个基本的 Zend Expressive 应用程序,如下所示。在提示是否选择最小骨架时,请务必选择No

**cd /path/to/source/for/this/chapter**
**php composer.phar create-project zendframework/zend-expressive-skeleton expressive**

这将创建一个/path/to/source/for/this/chapter/expressive文件夹。切换到这个目录。修改public/index.php如下:

<?php
if (php_sapi_name() === 'cli-server'
    && is_file(__DIR__ . parse_url(
$_SERVER['REQUEST_URI'], PHP_URL_PATH))
) {
    return false;
}
chdir(dirname(__DIR__));
**session_start();**
**$_SESSION['time'] = time();**
**$appDir = realpath(__DIR__ . '/../../..');**
**$loader = require 'vendor/autoload.php';**
**$loader->add('Application', $appDir);**
$container = require 'config/container.php';
$app = $container->get(\Zend\Expressive\Application::class);
$app->run();

然后,您需要创建一个调用我们会话验证中间件的包装类。创建一个SessionValidateAction.php文件,需要放在/path/to/source/for/this/chapter/expressive/src/App/Action文件夹中。为了说明这一点,将停止时间参数设置为一个较短的持续时间。在这种情况下,time() + 10给您 10 秒:

namespace App\Action;
use Application\MiddleWare\Session\Validator;
use Zend\Diactoros\ { Request, Response };
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
class SessionValidateAction
{
  public function __invoke(ServerRequestInterface $request, 
  ResponseInterface $response, callable $next = null)
  {
    $inbound   = new Response();
    $validator = new Validator($request, **time()+10**);
    $inbound   = $validator($request, $response);
    if ($inbound->getStatusCode() != 200) {
        session_destroy();
        setcookie('PHPSESSID', 0, time()-300);
        $params = json_decode(
          $inbound->getBody()->getContents(), TRUE);
        echo '<h1>',$params[Validator::KEY_TEXT],'</h1>';
        echo '<pre>',var_dump($inbound),'</pre>';
        exit;
    }
    return $next($request,$response);
  }
}

现在,您需要将新类添加到中间件管道中。修改config/autoload/middleware-pipeline.global.php如下。修改部分用粗体显示:

<?php
use Zend\Expressive\Container\ApplicationFactory;
use Zend\Expressive\Helper;
return [
  'dependencies' => [
 **'invokables' => [**
 **App\Action\SessionValidateAction::class =>** 
 **App\Action\SessionValidateAction::class,**
 **],**
   'factories' => [
      Helper\ServerUrlMiddleware::class => 
      Helper\ServerUrlMiddlewareFactory::class,
      Helper\UrlHelperMiddleware::class => 
      Helper\UrlHelperMiddlewareFactory::class,
    ],
  ],
  'middleware_pipeline' => [
      'always' => [
         'middleware' => [
            Helper\ServerUrlMiddleware::class,
         ],
         'priority' => 10000,
      ],
      'routing' => [
         'middleware' => [
            ApplicationFactory::ROUTING_MIDDLEWARE,
            Helper\UrlHelperMiddleware::class,
            **App\Action\SessionValidateAction::class,**
            ApplicationFactory::DISPATCH_MIDDLEWARE,
         ],
         'priority' => 1,
      ],
    'error' => [
       'middleware' => [
          // Add error middleware here.
       ],
       'error'    => true,
       'priority' => -10000,
    ],
  ],
];

您可能还考虑修改主页模板以显示$_SESSION的状态。相关文件是/path/to/source/for/this/chapter/expressive/templates/app/home-page.phtml。只需添加var_dump($_SESSION)即可。

最初,您应该看到类似以下的东西:

它是如何工作的...

10 秒后,刷新浏览器。现在您应该看到这个:

它是如何工作的...

使用中间件跨语言

除非您尝试在不同版本的 PHP 之间进行通信,否则 PSR-7 中间件将几乎没有用处。回想一下这个首字母缩略词的含义:PHP 标准建议。因此,如果您需要向另一种语言编写的应用程序发出请求,请将其视为任何其他 Web 服务 HTTP 请求。

如何做...

  1. 在 PHP 4 的情况下,实际上有机会进行面向对象编程的有限支持。因此,最好的方法是降级前三个食谱中描述的基本 PSR-7 类。没有足够的空间来涵盖所有的变化,但我们提供了Application\MiddleWare\ServerRequest的潜在 PHP 4 版本。首先要注意的是没有命名空间!因此,我们使用下划线 _ 来代替命名空间分隔符的类名:
class Application_MiddleWare_ServerRequest
extends Application_MiddleWare_Request
implements Psr_Http_Message_ServerRequestInterface
{
  1. 在 PHP 4 中,所有属性都使用关键字var进行标识:
var $serverParams;
var $cookies;
var $queryParams;
// not all properties are shown
  1. initialize()方法几乎相同,只是在 PHP 4 中不允许使用$this->getServerParams()['REQUEST_URI']这样的语法。因此,我们需要将其拆分为一个单独的变量:
function initialize()
{
  $params = $this->getServerParams();
  $this->getCookieParams();
  $this->getQueryParams();
  $this->getUploadedFiles;
  $this->getRequestMethod();
  $this->getContentType();
  $this->getParsedBody();
  return $this->withRequestTarget($params['REQUEST_URI']);
}
  1. 所有$_XXX超全局变量都出现在 PHP 4 的后续版本中:
function getServerParams()
{
  if (!$this->serverParams) {
      $this->serverParams = $_SERVER;
  }
  return $this->serverParams;
}
// not all getXXX() methods are shown to conserve space
  1. 空合并运算符是在 PHP 7 中引入的。我们需要使用isset(XXX) ? XXX : '';代替:
function getRequestMethod()
{
  $params = $this->getServerParams();
  $method = isset($params['REQUEST_METHOD']) 
    ? $params['REQUEST_METHOD'] : '';
  $this->method = strtolower($method);
  return $this->method;
}
  1. JSON 扩展是在 PHP 5 中引入的。因此,我们需要满足于原始输入。我们还可以在json_encode()json_decode()的位置使用serialize()unserialize()
function getParsedBody()
{
  if (!$this->parsedBody) {
      if (($this->getContentType() == 
           Constants::CONTENT_TYPE_FORM_ENCODED
           || $this->getContentType() == 
           Constants::CONTENT_TYPE_MULTI_FORM)
           && $this->getRequestMethod() == 
           Constants::METHOD_POST)
      {
          $this->parsedBody = $_POST;
      } elseif ($this->getContentType() == 
                Constants::CONTENT_TYPE_JSON
                || $this->getContentType() == 
                Constants::CONTENT_TYPE_HAL_JSON)
      {
          ini_set("allow_url_fopen", true);
          $this->parsedBody = 
            file_get_contents('php://stdin');
      } elseif (!empty($_REQUEST)) {
          $this->parsedBody = $_REQUEST;
      } else {
          ini_set("allow_url_fopen", true);
          $this->parsedBody = 
            file_get_contents('php://stdin');
      }
  }
  return $this->parsedBody;
}
  1. withXXX()方法在 PHP 4 中基本相同:
function withParsedBody($data)
{
  $this->parsedBody = $data;
  return $this;
}
  1. 同样,withoutXXX()方法也是一样的:
function withoutAttribute($name)
{
  if (isset($this->attributes[$name])) {
      unset($this->attributes[$name]);
  }
  return $this;
}

}
  1. 对于使用其他语言的网站,我们可以使用 PSR-7 类来制定请求和响应,但随后需要使用 HTTP 客户端与其他网站进行通信。例如,回想一下本章中讨论的“开发 PSR-7 请求类”食谱中的Request演示。以下是它是如何工作的...部分的示例:
$request = new Request(
  TARGET_WEBSITE_URL,
  Constants::METHOD_POST,
  new TextStream($contents),
  [Constants::HEADER_CONTENT_TYPE => 
  Constants::CONTENT_TYPE_FORM_ENCODED,
  Constants::HEADER_CONTENT_LENGTH => $body->getSize()]
);

$data = http_build_query(['data' => 
$request->getBody()->getContents()]);

$defaults = array(
  CURLOPT_URL => $request->getUri()->getUriString(),
  CURLOPT_POST => true,
  CURLOPT_POSTFIELDS => $data,
);
$ch = curl_init();
curl_setopt_array($ch, $defaults);
$response = curl_exec($ch);
curl_close($ch);

第十章:查看高级算法

在本章中,我们将涵盖:

  • 使用 getter 和 setter

  • 实现链表

  • 构建冒泡排序

  • 实现堆栈

  • 构建二分搜索类

  • 实现搜索引擎

  • 显示多维数组并累积总数

介绍

在本章中,我们涵盖了实现各种高级算法的配方,例如链表、冒泡排序、堆栈和二分搜索。此外,我们还涵盖了 getter 和 setter,以及实现搜索引擎和显示来自多维数组的值并累积总数。

使用 getter 和 setter

乍一看,似乎定义具有public属性的类,然后可以直接读取或写入这些属性是有意义的。然而,最佳做法是将属性定义为protected,然后为每个属性定义gettersetter。顾名思义,getter用于检索属性的值。setter用于设置值。

提示

最佳实践

将属性定义为protected以防止意外外部访问。使用public get和 set方法来访问这些属性。通过这种方式,不仅可以更精确地控制访问,还可以在获取和设置它们时进行格式和数据类型的更改。

如何做...

  1. Getter 和 setter 在获取或设置值时提供了额外的灵活性。如果需要,您可以添加额外的逻辑层,这是直接读取或写入公共属性时无法实现的。您只需要创建一个以getset为前缀的公共方法。属性的名称成为后缀。约定是将变量的第一个字母大写。因此,如果属性是$testValue,getter 将是getTestValue()

  2. 在此示例中,我们定义了一个具有受保护属性$date的类。请注意,getset方法允许将其视为DateTime对象或字符串。无论如何,该值实际上都存储为DateTime实例。

$a = new class() {
  protected $date;
  public function setDate($date)
  {
    if (is_string($date)) {
        $this->date = new DateTime($date);
    } else {
        $this->date = $date;
    }
  }
  public function getDate($asString = FALSE)
  {
    if ($asString) {
        return $this->date->format('Y-m-d H:i:s');
    } else {
        return $this->date;
    }
  }
};
  1. Getter 和 setter 允许您过滤或清理传入或传出的数据。在下面的示例中,有两个属性$intVal$arrVal,它们被设置为默认初始值NULL。请注意,getter 的返回值不仅是数据类型化的,而且还提供了默认值。setter 也要么强制执行传入的数据类型,要么将传入的值强制转换为特定的数据类型:
<?php
class GetSet
{
  protected $intVal = NULL;
  protected $arrVal = NULL;
  // note the use of the null coalesce operator to return a default value
  public function getIntVal() : int
  {
    return $this->intVal ?? 0;
  }
  public function getArrVal() : array
  {
    return $this->arrVal ?? array();
  }
  public function setIntVal($val)
  {
    $this->intVal = (int) $val ?? 0;
  }
  public function setArrVal(array $val)
  {
    $this->arrVal = $val ?? array();
  }
}
  1. 如果一个类有很多属性,为每个属性定义一个明确的 getter 和 setter 可能会变得乏味。在这种情况下,可以使用魔术方法__call()定义一种回退。以下类定义了九个不同的属性。我们不必定义九个 getter 和九个 setter,而是定义一个名为__call()的方法,该方法确定使用是get还是set。如果是get,它会从内部数组中检索键。如果是set,它会将值存储在内部数组中。

注意

__call()方法是一个魔术方法,如果应用程序调用不存在的方法,则会执行该方法。

<?php
class LotsProps
{
  protected $firstName  = NULL;
  protected $lastName   = NULL;
  protected $addr1      = NULL;
  protected $addr2      = NULL;
  protected $city       = NULL;
  protected $state      = NULL;
  protected $province   = NULL;
  protected $postalCode = NULL;
  protected $country    = NULL;
  protected $values     = array();

  public function __call($method, $params)
  {
    preg_match('/^(get|set)(.*?)$/i', $method, $matches);
    $prefix = $matches[1] ?? '';
    $key    = $matches[2] ?? '';
    $key    = strtolower($key);
    if ($prefix == 'get') {
        return $this->values[$key] ?? '---';
    } else {
        $this->values[$key] = $params[0];
    }
  }
}

工作原理...

将步骤 1 中提到的代码复制到一个新文件chap_10_oop_using_getters_and_setters.php中。要测试该类,请添加以下内容:

// set date using a string
$a->setDate('2015-01-01');
var_dump($a->getDate());

// retrieves the DateTime instance
var_dump($a->getDate(TRUE));

// set date using a DateTime instance
$a->setDate(new DateTime('now'));
var_dump($a->getDate());

// retrieves the DateTime instance
var_dump($a->getDate(TRUE));

在下面的输出中,您可以看到$date属性可以使用string或实际的DateTime实例进行设置。当执行getDate()时,可以根据$asString标志的值返回stringDateTime实例:

工作原理...

接下来,看一下步骤 2 中定义的代码。将此代码复制到一个名为chap_10_oop_using_getters_and_setters_defaults.php的文件中,并添加以下内容:

// create the instance
$a = new GetSet();

// set a "proper" value
$a->setIntVal(1234);
echo $a->getIntVal();
echo PHP_EOL;

// set a bogus value
$a->setIntVal('some bogus value');
echo $a->getIntVal();
echo PHP_EOL;

// NOTE: boolean TRUE == 1
$a->setIntVal(TRUE);
echo $a->getIntVal();
echo PHP_EOL;

// returns array() even though no value was set
var_dump($a->getArrVal());
echo PHP_EOL;

// sets a "proper" value
$a->setArrVal(['A','B','C']);
var_dump($a->getArrVal());
echo PHP_EOL;

try {
    $a->setArrVal('this is not an array');
    var_dump($a->getArrVal());
    echo PHP_EOL;
} catch (TypeError $e) {
    echo $e->getMessage();
}

echo PHP_EOL;

从以下输出中可以看出,设置正确的整数值会按预期工作。非数字值默认为0。有趣的是,如果您将布尔值TRUE作为setIntVal()的参数,它会被插入为1

如果您在不设置值的情况下调用getArrVal(),默认值是一个空数组。设置数组值会按预期工作。但是,如果您将非数组值作为参数提供,数组的类型提示会导致抛出TypeError,可以像这样捕获:

工作原理...

最后,将步骤 3 中定义的LotsProps类放在一个单独的文件chap_10_oop_using_getters_and_setters_magic_call.php中。现在添加代码来设置值。当然,会调用魔术方法__call()。运行preg_match()后,不存在的属性的剩余部分,在set字母之后,将成为内部数组$values中的一个键:

$a = new LotsProps();
$a->setFirstName('Li\'l Abner');
$a->setLastName('Yokum');
$a->setAddr1('1 Dirt Street');
$a->setCity('Dogpatch');
$a->setState('Kentucky');
$a->setPostalCode('12345');
$a->setCountry('USA');
?>

然后,您可以定义显示值的 HTML,使用相应的get方法。这些方法将返回内部数组的键:

<div class="container">
<div class="left blue1">Name</div>
<div class="right yellow1">
<?= $a->getFirstName() . ' ' . $a->getLastName() ?></div>   
</div>
<div class="left blue2">Address</div>
<div class="right yellow2">
    <?= $a->getAddr1() ?>
    <br><?= $a->getAddr2() ?>
    <br><?= $a->getCity() ?>
    <br><?= $a->getState() ?>
    <br><?= $a->getProvince() ?>
    <br><?= $a->getPostalCode() ?>
    <br><?= $a->getCountry() ?>
</div>   
</div>

这是最终输出:

工作原理...

实现链表

链表是一个列表包含指向另一个列表键的列表。类似地,在数据库术语中,您可以有一个包含数据的表,以及一个指向数据的单独索引。一个索引可以按 ID 生成项目列表。另一个索引可能根据标题产生列表等等。链表的显着特点是您不必触及原始项目列表。

例如,在接下来显示的图表中,主列表包含 ID 号码和水果的名称。如果您直接输出主列表,水果名称将按照以下顺序显示:苹果葡萄香蕉橙子樱桃。另一方面,如果您使用链表作为索引,结果输出的水果名称将是苹果香蕉樱桃葡萄橙子

实现链表

如何做...

  1. 链表的主要用途之一是以不同的顺序显示项目。一种方法是创建键值对的迭代,其中键表示新顺序,值包含主列表中键的值。这样的函数可能如下所示:
function buildLinkedList(array $primary,
                         callable $makeLink)
{
  $linked = new ArrayIterator();
  foreach ($primary as $key => $row) {
    $linked->offsetSet($makeLink($row), $key);
  }
  $linked->ksort();
  return $linked;
}
  1. 我们使用匿名函数生成新的键,以提供额外的灵活性。您还会注意到我们按键排序(ksort()),以便链表按键顺序迭代。

  2. 我们只需要通过链表进行迭代,但是从主列表$customer中产生结果:

foreach ($linked as $key => $link) {
  $output .= printRow($customer[$link]);
}
  1. 请注意,我们绝对不会触及主列表。这使我们能够生成多个链表,每个链表代表不同的顺序,同时保留我们的原始数据集。

  2. 链表的另一个重要用途是用于过滤。该技术与之前显示的类似。唯一的区别是我们扩展了buildLinkedList()函数,添加了一个过滤列和过滤值:

function buildLinkedList(array $primary,
                         callable $makeLink,
                         $filterCol = NULL,
                         $filterVal = NULL)
{
  $linked = new ArrayIterator();
  $filterVal = trim($filterVal);
  foreach ($primary as $key => $row) {
    if ($filterCol) {
      if (trim($row[$filterCol]) == $filterVal) {
        $linked->offsetSet($makeLink($row), $key);
      }
    } else {
      $linked->offsetSet($makeLink($row), $key);
    }
  }
  $linked->ksort();
  return $linked;
}
  1. 我们只在链表中包含与主列表中的$filterCol表示的值匹配的值。迭代逻辑与步骤 2 中显示的相同。

  2. 最后,另一种形式的链表是双向链表。在这种情况下,列表构造成可以向前或向后进行迭代。在 PHP 的情况下,我们很幸运地拥有一个 SPL 类SplDoublyLinkedList,它可以很好地完成这项任务。以下是构建双向链表的函数:

function buildDoublyLinkedList(ArrayIterator $linked)
{
  $double = new SplDoublyLinkedList();
  foreach ($linked as $key => $value) {
    $double->push($value);
  }
  return $double;
}

注意

SplDoublyLinkedList的术语可能会产生误导。SplDoublyLinkedList::top()实际上指向列表的末尾,而SplDoublyLinkedList::bottom()指向开始

工作原理...

将第一个项目中显示的代码复制到一个文件chap_10_linked_list_include.php中。为了演示链表的使用,您需要一个数据源。在这个示例中,您可以使用先前配方中提到的customer.csv文件。它是一个 CSV 文件,包含以下列:

"id","name","balance","email","password","status","security_question",
"confirm_code","profile_id","level"

您可以将以下函数添加到先前提到的包含文件中,以生成客户的主列表,并显示有关它们的信息。请注意,我们使用第一列 id 作为主键:

function readCsv($fn, &$headers)
{
  if (!file_exists($fn)) {
    throw new Error('File Not Found');
  }
  $fileObj = new SplFileObject($fn, 'r');
  $result = array();
  $headers = array();
  $firstRow = TRUE;
  while ($row = $fileObj->fgetcsv()) {
    // store 1st row as headers
    if ($firstRow) {
      $firstRow = FALSE;
      $headers = $row;
    } else {
      if ($row && $row[0] !== NULL && $row[0] !== 0) {
        $result[$row[0]] = $row;
      }
    }
  }
  return $result;
}

function printHeaders($headers)
{
  return sprintf('%4s : %18s : %8s : %32s : %4s' . PHP_EOL,
                 ucfirst($headers[0]),
                 ucfirst($headers[1]),
                 ucfirst($headers[2]),
                 ucfirst($headers[3]),
                 ucfirst($headers[9]));
}

function printRow($row)
{
  return sprintf('%4d : %18s : %8.2f : %32s : %4s' . PHP_EOL,
                 $row[0], $row[1], $row[2], $row[3], $row[9]);
}

function printCustomer($headers, $linked, $customer)
{
  $output = '';
  $output .= printHeaders($headers);
  foreach ($linked as $key => $link) {
    $output .= printRow($customer[$link]);
  }
  return $output;
}

然后您可以定义一个调用程序chap_10_linked_list_in_order.php,其中包括先前定义的文件,并读取customer.csv

<?php
define('CUSTOMER_FILE', __DIR__ . '/../data/files/customer.csv');
include __DIR__ . '/chap_10_linked_list_include.php';
$headers = array();
$customer = readCsv(CUSTOMER_FILE, $headers);

然后您可以定义一个将在链表中生成一个键的匿名函数。在这个示例中,定义一个将第一列(名称)分解为名和姓的函数:

$makeLink = function ($row) {
  list($first, $last) = explode(' ', $row[1]);
  return trim($last) . trim($first);
};

然后您可以调用函数来构建链表,并使用printCustomer()来显示结果:

$linked = buildLinkedList($customer, $makeLink);
echo printCustomer($headers, $linked, $customer);

以下是输出可能会出现的方式:

工作原理...

要生成过滤结果,请修改buildLinkedList(),如步骤 4 中所述。然后可以添加逻辑来检查过滤列的值是否与过滤器中的值匹配:

define('LEVEL_FILTER', 'INT');

$filterCol = 9;
$filterVal = LEVEL_FILTER;
$linked = buildLinkedList($customer, $makeLink, $filterCol, $filterVal);

还有更多...

PHP 7.1 引入了使用[ ]作为list()的替代方法。如果您查看先前提到的匿名函数,您可以在 PHP 7.1 中将其重写如下:

$makeLink = function ($row) {
  [$first, $last] = explode(' ', $row[1]);
  return trim($last) . trim($first);
};

更多信息,请参见wiki.php.net/rfc/short_list_syntax

构建冒泡排序

经典的冒泡排序经常分配给大学生练习。尽管如此,掌握这个算法很重要,因为有许多情况下内置的 PHP 排序函数不适用。一个例子是对多维数组进行排序,其中排序键不是第一列。

冒泡排序的工作方式是通过递归迭代列表并交换当前值和下一个值。如果要使项目按升序排列,则如果下一个项目小于当前项目,则进行交换。对于降序排序,如果相反情况为真,则进行交换。当不再发生交换时,排序结束。

在下图中,第一次通过后,GrapeBanana被交换,OrangeCherry也被交换。第二次通过后,GrapeCherry被交换。在最后一次通过中不再发生交换,冒泡排序结束:

构建冒泡排序

如何做...

  1. 我们不想实际移动数组中的值;这在资源使用方面将是非常昂贵的。相反,我们将使用在前面的配方中讨论的链表

  2. 首先,我们使用在前面的配方中讨论的buildLinkedList()函数构建一个链表。

  3. 然后我们定义一个新函数bubbleSort(),它接受引用的链表,主列表,排序字段和表示排序顺序(升序或降序)的参数:

function bubbleSort(&$linked, $primary, $sortField, $order = 'A')
{
  1. 所需的变量包括代表迭代次数的变量,交换次数的变量,以及基于链表的迭代器:
  static $iterations = 0;
  $swaps = 0;
  $iterator = new ArrayIterator($linked);
  1. while()循环中,只有在迭代仍然有效时才继续进行,也就是说仍在进行中。然后我们获取当前键和值,以及下一个键和值。请注意额外的if()语句以确保迭代仍然有效(也就是说,确保我们不会掉出列表的末尾!):
while ($iterator->valid()) {
  $currentLink = $iterator->current();
  $currentKey  = $iterator->key();
  if (!$iterator->valid()) break;
  $iterator->next();
  $nextLink = $iterator->current();
  $nextKey  = $iterator->key();
  1. 接下来,我们检查排序是升序还是降序。根据方向,我们检查下一个值是大于还是小于当前值。比较的结果存储在$expr中:
if ($order == 'A') {
    $expr = $primary[$linked->offsetGet
            ($currentKey)][$sortField] > 
            $primary[$linked->offsetGet($nextKey)][$sortField];
} else {
    $expr = $primary[$linked->offsetGet
            ($currentKey)][$sortField] < 
            $primary[$linked->offsetGet($nextKey)][$sortField];
}
  1. 如果$expr的值为TRUE,并且我们有有效的当前键和下一个键,则交换链表中的值。我们还增加$swaps
if ($expr && $currentKey && $nextKey 
    && $linked->offsetExists($currentKey) 
    && $linked->offsetExists($nextKey)) {
    $tmp = $linked->offsetGet($currentKey);
    $linked->offsetSet($currentKey, 
    $linked->offsetGet($nextKey));
    $linked->offsetSet($nextKey, $tmp);
    $swaps++;
  }
}
  1. 最后,如果发生了任何交换,我们需要再次运行迭代,直到没有更多的交换。因此,我们对同一个方法进行递归调用:
if ($swaps) bubbleSort($linked, $primary, $sortField, $order);
  1. 真正的返回值是重新组织的链表。我们还返回迭代的次数,仅供参考:
  return ++$iterations;
}

工作原理...

将之前讨论的bubbleSort()函数添加到上一篇教程中创建的包含文件中。您可以使用前一篇教程中讨论的相同逻辑来读取customer.csv文件,生成一个主列表:

<?php
define('CUSTOMER_FILE', __DIR__ . '/../data/files/customer.csv');
include __DIR__ . '/chap_10_linked_list_include.php';
$headers = array();
$customer = readCsv(CUSTOMER_FILE, $headers);

然后,您可以使用第一列作为排序键来生成一个链表:

$makeLink = function ($row) {
  return $row[0];
};
$linked = buildLinkedList($customer, $makeLink);

最后,调用bubbleSort()函数,提供链表和客户列表作为参数。您还可以提供一个排序列,在本示例中是列 2,表示账户余额,使用字母'A'表示升序。printCustomer()函数可用于显示输出:

echo 'Iterations: ' . bubbleSort($linked, $customer, 2, 'A') . PHP_EOL;
echo printCustomer($headers, $linked, $customer);

以下是输出的示例:

工作原理...

实现堆栈

堆栈是一种简单的算法,通常实现为后进先出LIFO)。想象一下一堆书放在图书馆的桌子上。当图书管理员去把书放回原位时,首先处理的是最顶部的书,依此类推,直到堆栈底部的书被替换。最顶部的书是最后放在堆栈上的,因此后进先出。

在编程术语中,堆栈用于临时存储信息。检索顺序有助于首先检索最近的项目。

如何做...

  1. 首先,我们定义一个Application\Generic\Stack类。核心逻辑封装在 SPL 类SplStack中:
namespace Application\Generic;
use SplStack;
class Stack
{
  // code
}
  1. 接下来,我们定义一个表示堆栈的属性,并设置一个SplStack实例:
protected $stack;
public function __construct()
{
  $this->stack = new SplStack();
}
  1. 然后我们定义了从堆栈中添加和删除的方法,经典的push()pop()方法:
public function push($message)
{
  $this->stack->push($message);
}
public function pop()
{
  return $this->stack->pop();
}
  1. 我们还添加了一个__invoke()的实现,返回stack属性的实例。这允许我们在直接函数调用中使用对象:
public function __invoke()
{
  return $this->stack;
}

工作原理...

堆栈的一个可能用途是存储消息。在消息的情况下,通常希望首先检索最新的消息,因此这是堆栈的一个完美用例。按照本教程中讨论的内容定义Application\Generic\Stack类。接下来,定义一个调用程序,设置自动加载并创建stack的实例:

<?php
// setup class autoloading
require __DIR__ . '/../Application/Autoload/Loader.php';
Application\Autoload\Loader::init(__DIR__ . '/..');
use Application\Generic\Stack;
$stack = new Stack();

要对堆栈执行操作,存储一系列消息。由于您很可能会在应用程序的不同点存储消息,因此可以使用sleep()来模拟其他代码的运行:

echo 'Do Something ... ' . PHP_EOL;
$stack->push('1st Message: ' . date('H:i:s'));
sleep(3);

echo 'Do Something Else ... ' . PHP_EOL;
$stack->push('2nd Message: ' . date('H:i:s'));
sleep(3);

echo 'Do Something Else Again ... ' . PHP_EOL;
$stack->push('3rd Message: ' . date('H:i:s'));
sleep(3);

最后,只需遍历堆栈以检索消息。请注意,您可以调用堆栈对象,就像它是一个函数一样,它返回SplStack实例:

echo 'What Time Is It?' . PHP_EOL;
foreach ($stack() as $item) {
  echo $item . PHP_EOL;
}

以下是预期输出:

工作原理...

构建二进制搜索类

传统搜索通常按顺序遍历项目列表。这意味着要搜索的最大可能数量的项目可能与列表的长度相同!这并不是很有效。如果需要加快搜索速度,请考虑实现二进制搜索。

这种技术非常简单:找到列表中点,并确定搜索项是小于、等于还是大于中点项。如果小于,则将上限设置为中点,并仅搜索列表的前半部分。如果大于,则将下限设置为中点,并仅搜索列表的后半部分。然后继续将列表分成 1/4、1/8、1/16 等,直到找到搜索项(或没有找到)。

注意

需要注意的是,尽管比较的最大次数远远小于顺序搜索(log n + 1,其中n是列表中的元素数,log是二进制对数),但参与搜索的列表必须首先排序,这当然会降低性能。

如何做...

  1. 我们首先构建一个搜索类Application\Generic\Search,它接受主列表作为参数。作为控制,我们还定义一个属性$iterations
namespace Application\Generic;
class Search
{
  protected $primary;
  protected $iterations;
  public function __construct($primary)
  {
    $this->primary = $primary;
  }
  1. 接下来,我们定义一个方法binarySearch(),它设置了搜索基础设施。首要任务是构建一个单独的数组$search,其中键是搜索中包含的列的组合。然后我们按键排序:
public function binarySearch(array $keys, $item)
{
  $search = array();
  foreach ($this->primary as $primaryKey => $data) {
    $searchKey = function ($keys, $data) {
      $key = '';
      foreach ($keys as $k) $key .= $data[$k];
      return $key;
    };
    $search[$searchKey($keys, $data)] = $primaryKey;
  }
  ksort($search);
  1. 然后我们将键提取到另一个数组$binary中,以便我们可以根据数字键执行二进制排序。然后我们调用doBinarySearch(),它会从我们的中间数组$search中得到一个键,或一个布尔值FALSE
  $binary = array_keys($search);
  $result = $this->doBinarySearch($binary, $item);
  return $this->primary[$search[$result]] ?? FALSE;
}
  1. 首先,doBinarySearch()初始化一系列参数。$iterations$found$loop$done$max都用于防止无限循环。$upper$lower表示要检查的列表切片:
public function doBinarySearch($binary, $item)
{
  $iterations = 0;
  $found = FALSE;
  $loop  = TRUE;
  $done  = -1;
  $max   = count($binary);
  $lower = 0;
  $upper = $max - 1;
  1. 然后我们实现一个while()循环并设置中点:
  while ($loop && !$found) {
    $mid = (int) (($upper - $lower) / 2) + $lower;
  1. 现在我们可以使用新的 PHP 7 太空船操作符,它在单个比较中给出小于、等于或大于。如果小于,则将上限设置为中点。如果大于,则将下限调整为中点。如果相等,则完成:
switch ($item <=> $binary[$mid]) {
  // $item < $binary[$mid]
  case -1 :
  $upper = $mid;
  break;
  // $item == $binary[$mid]
  case 0 :
  $found = $binary[$mid];
  break;
  // $item > $binary[$mid]
  case 1 :
  default :
  $lower = $mid;
}
  1. 现在是一点循环控制。我们增加迭代次数,并确保它不超过列表的大小。如果超过了,肯定有问题,我们需要退出。否则,我们检查上限和下限是否连续两次相同,如果是,则搜索项未找到。然后我们存储迭代次数并返回找到的内容(或未找到):
    $loop = (($iterations++ < $max) && ($done < 1));
    $done += ($upper == $lower) ? 1 : 0;
  }
  $this->iterations = $iterations;
  return $found;
}

它是如何工作的...

首先,实现Application\Generic\Search类,定义本教程中描述的方法。接下来,定义一个调用程序chap_10_binary_search.php,它设置自动加载并将customer.csv文件读取为搜索目标(如前一教程中所讨论的):

<?php
define('CUSTOMER_FILE', __DIR__ . '/../data/files/customer.csv');
include __DIR__ . '/chap_10_linked_list_include.php';
require __DIR__ . '/../Application/Autoload/Loader.php';
Application\Autoload\Loader::init(__DIR__ . '/..');
use Application\Generic\Search;
$headers = array();
$customer = readCsv(CUSTOMER_FILE, $headers);

然后,您可以创建一个新的Search实例,并在列表的中间某个位置指定一个项目。在本示例中,搜索基于列 1,即客户名称,项目为Todd Lindsey

$search = new Search($customer);
$item = 'Todd Lindsey';
$cols = [1];
echo "Searching For: $item\n";
var_dump($search->binarySearch($cols, $item));

为了说明,在Application\Generic\Search::doBinarySearch()中的switch()之前添加此行:

echo 'Upper:Mid:Lower:<=> | ' . $upper . ':' . $mid . ':' . $lower . ':' . ($item <=> $binary[$mid]);

输出如下所示。请注意,上限、中间和下限会调整,直到找到项目:

它是如何工作的...

另请参阅

有关二进制搜索的更多信息,请参阅维基百科上的一篇优秀文章,其中介绍了基本数学内容en.wikipedia.org/wiki/Binary_search_algorithm

实施搜索引擎

为了实现搜索引擎,我们需要为多个列的搜索做好准备。此外,重要的是要认识到搜索项可能在字段的中间找到,并且用户很少提供足够的信息进行精确匹配。因此,我们将大量依赖 SQL 的LIKE %value%子句。

如何做...

  1. 首先,我们定义一个基本类来保存搜索条件。该对象包含三个属性:键,最终表示数据库列;运算符(LIKE<>等);和可选的项目。项目是可选的原因是一些运算符,如IS NOT NULL,不需要特定的数据:
namespace Application\Database\Search;
class Criteria
{
  public $key;
  public $item;
  public $operator;
  public function __construct($key, $operator, $item = NULL)
  {
    $this->key  = $key;
    $this->operator = $operator;
    $this->item = $item;
  }
}
  1. 接下来,我们需要定义一个类Application\Database\Search\Engine,并提供必要的类常量和属性。$columns$mapping之间的区别在于$columns保存的信息最终将出现在 HTML 的SELECT字段(或等效字段)中。出于安全原因,我们不希望公开数据库列的实际名称,因此需要另一个数组$mapping
namespace Application\Database\Search;
use PDO;
use Application\Database\Connection;
class Engine
{
  const ERROR_PREPARE = 'ERROR: unable to prepare statement';
  const ERROR_EXECUTE = 'ERROR: unable to execute statement';
  const ERROR_COLUMN  = 'ERROR: column name not on list';
  const ERROR_OPERATOR= 'ERROR: operator not on list';
  const ERROR_INVALID = 'ERROR: invalid search criteria';

  protected $connection;
  protected $table;
  protected $columns;
  protected $mapping;
  protected $statement;
  protected $sql = '';
  1. 接下来,我们定义一组我们愿意支持的运算符。键表示实际的 SQL。值是表单中将出现的内容:
  protected $operators = [
      'LIKE'     => 'Equals',
      '<'        => 'Less Than',
      '>'        => 'Greater Than',
      '<>'       => 'Not Equals',
      'NOT NULL' => 'Exists',
  ];
  1. 构造函数接受数据库连接实例作为参数。为了我们的目的,我们将使用第五章中定义的Application\Database\Connection。我们还需要提供数据库表的名称,以及$columns,一个任意列键和标签的数组,这些将出现在 HTML 表单中。这将引用$mapping,其中键与$columns匹配,但值表示实际的数据库列名:
public function __construct(Connection $connection, 
                            $table, array $columns, array $mapping)
{
  $this->connection  = $connection;
  $this->setTable($table);
  $this->setColumns($columns);
  $this->setMapping($mapping);
}
  1. 在构造函数之后,我们提供一系列有用的 getter 和 setter:
public function setColumns($columns)
{
  $this->columns = $columns;
}
public function getColumns()
{
  return $this->columns;
}
// etc.
  1. 可能最关键的方法是构建要准备的 SQL 语句。在初始SELECT设置之后,我们添加一个WHERE子句,使用$mapping添加实际的数据库列名。然后添加操作符并实现switch(),根据操作符,可能会或可能不会添加一个表示搜索项的命名占位符:
public function prepareStatement(Criteria $criteria)
{
  $this->sql = 'SELECT * FROM ' . $this->table . ' WHERE ';
  $this->sql .= $this->mapping[$criteria->key] . ' ';
  switch ($criteria->operator) {
    case 'NOT NULL' :
      $this->sql .= ' IS NOT NULL OR ';
      break;
    default :
      $this->sql .= $criteria->operator . ' :' 
      . $this->mapping[$criteria->key] . ' OR ';
  }
  1. 现在核心的SELECT已经定义,我们删除任何尾随的OR关键字,并添加一个导致结果根据搜索列排序的子句。然后将该语句发送到数据库进行准备:
  $this->sql = substr($this->sql, 0, -4)
    . ' ORDER BY ' . $this->mapping[$criteria->key];
  $statement = $this->connection->pdo->prepare($this->sql);
  return $statement;
}
  1. 现在我们准备转向主要的展示,search()方法。我们接受一个Application\Database\Search\Criteria对象作为参数。这确保我们至少有一个项目键和操作符。为了安全起见,我们添加了一个if()语句来检查这些属性:
public function search(Criteria $criteria)
{
  if (empty($criteria->key) || empty($criteria->operator)) {
    yield ['error' => self::ERROR_INVALID];
    return FALSE;
  }
  1. 然后我们调用prepareStatement()使用try / catch来捕获错误:
try {
    if (!$statement = $this->prepareStatement($criteria)) {
      yield ['error' => self::ERROR_PREPARE];
      return FALSE;
}
  1. 接下来,我们构建一个将提供给execute()的参数数组。键表示在准备语句中用作占位符的数据库列名。请注意,我们使用=而不是LIKE %value%构造
$params = array();
switch ($criteria->operator) {
  case 'NOT NULL' :
    // do nothing: already in statement
    break;
    case 'LIKE' :
    $params[$this->mapping[$criteria->key]] = 
    '%' . $criteria->item . '%';
    break;
    default :
    $params[$this->mapping[$criteria->key]] = 
    $criteria->item;
}
  1. 该语句被执行,并使用yield关键字返回结果,这有效地将此方法转换为生成器:
    $statement->execute($params);
    while ($row = $statement->fetch(PDO::FETCH_ASSOC)) {
      yield $row;
    }
  } catch (Throwable $e) {
    error_log(__METHOD__ . ':' . $e->getMessage());
    throw new Exception(self::ERROR_EXECUTE);
  }
  return TRUE;
}

工作原理...

将本章中讨论的代码放在Application\Database\Search下的文件Criteria.phpEngine.php中。然后可以定义一个调用脚本chap_10_search_engine.php,设置自动加载。您可以利用第五章中讨论的Application\Database\Connection类,与数据库交互,以及第六章中涵盖的表单元素类,构建可扩展的网站

<?php
define('DB_CONFIG_FILE', '/../config/db.config.php');
require __DIR__ . '/../Application/Autoload/Loader.php';
Application\Autoload\Loader::init(__DIR__ . '/..');

use Application\Database\Connection;
use Application\Database\Search\ { Engine, Criteria };
use Application\Form\Generic;
use Application\Form\Element\Select;

现在您可以定义哪些数据库列将出现在表单中,以及匹配的映射文件:

$dbCols = [
  'cname' => 'Customer Name',
  'cbal' => 'Account Balance',
  'cmail' => 'Email Address',
  'clevel' => 'Level'
];

$mapping = [
  'cname' => 'name',
  'cbal' => 'balance',
  'cmail' => 'email',
  'clevel' => 'level'
];

您现在可以设置数据库连接并创建搜索引擎实例:

$conn = new Connection(include __DIR__ . DB_CONFIG_FILE);
$engine = new Engine($conn, 'customer', $dbCols, $mapping);

为了显示适当的下拉SELECT元素,我们基于Application\Form\*类定义包装器和元素:

$wrappers = [
  Generic::INPUT => ['type' => 'td', 'class' => 'content'],
  Generic::LABEL => ['type' => 'th', 'class' => 'label'],
  Generic::ERRORS => ['type' => 'td', 'class' => 'error']
];

// define elements
$fieldElement = new Select('field',
                Generic::TYPE_SELECT,
                'Field',
                $wrappers,
                ['id' => 'field']);
                $opsElement = new Select('ops',
                Generic::TYPE_SELECT,
                'Operators',
                $wrappers,
                ['id' => 'ops']);
                $itemElement = new Generic('item',
                Generic::TYPE_TEXT,
                'Searching For ...',
                $wrappers,
                ['id' => 'item','title' => 'If more than one item, separate with commas']);
                $submitElement = new Generic('submit',
                Generic::TYPE_SUBMIT,
                'Search',
                $wrappers,
                ['id' => 'submit','title' => 'Click to Search', 'value' => 'Search']);

然后我们获取输入参数(如果已定义),设置表单元素选项,创建搜索条件并运行搜索:

$key  = (isset($_GET['field'])) 
? strip_tags($_GET['field']) : NULL;
$op   = (isset($_GET['ops'])) ? $_GET['ops'] : NULL;
$item = (isset($_GET['item'])) ? strip_tags($_GET['item']) : NULL;
$fieldElement->setOptions($dbCols, $key);
$itemElement->setSingleAttribute('value', $item);
$opsElement->setOptions($engine->getOperators(), $op);
$criteria = new Criteria($key, $op, $item);
$results = $engine->search($criteria);
?>

显示逻辑主要是朝向呈现表单。更详细的介绍在第六章中讨论,构建可扩展的网站,但我们在这里展示核心逻辑:

  <form name="search" method="get">
  <table class="display" cellspacing="0" width="100%">
    <tr><?= $fieldElement->render(); ?></tr>
    <tr><?= $opsElement->render(); ?></tr>
    <tr><?= $itemElement->render(); ?></tr>
    <tr><?= $submitElement->render(); ?></tr>
    <tr>
    <th class="label">Results</th>
      <td class="content" colspan=2>
      <span style="font-size: 10pt;font-family:monospace;">
      <table>
      <?php foreach ($results as $row) : ?>
        <tr>
          <td><?= $row['id'] ?></td>
          <td><?= $row['name'] ?></td>
          <td><?= $row['balance'] ?></td>
          <td><?= $row['email'] ?></td>
          <td><?= $row['level'] ?></td>
        </tr>
      <?php endforeach; ?>
      </table>
      </span>
      </td>
    </tr>
  </table>
  </form>

这是浏览器中的示例输出:

工作原理...

显示多维数组和累积总数

如何正确显示多维数组中的数据一直是任何 Web 开发人员的经典问题。举例来说,假设您希望显示客户及其购买的列表。对于每个客户,您希望显示他们的姓名,电话号码,账户余额等。这已经代表了一个二维数组,其中x轴代表客户,y轴代表该客户的数据。现在加入购买,您就有了第三个轴!如何在二维屏幕上表示 3D 模型?一个可能的解决方案是使用简单的 JavaScript 可见性切换来结合“隐藏”的分区标签。

如何做...

  1. 首先,我们需要从使用多个JOIN子句的 SQL 语句中生成一个 3D 数组。我们将使用在第一章中介绍的Application/Database/Connection类,建立基础,来制定一个适当的 SQL 查询。我们留下两个参数minmax,以支持分页。不幸的是,在这种情况下,我们不能简单地使用LIMITOFFSET,因为行数将取决于任何给定顾客的购买数量。因此,我们可以通过对顾客 ID 的限制来限制行数,假设(希望)是递增的。为了使这个功能正常工作,我们还需要将主要的ORDER设置为顾客 ID:
define('ITEMS_PER_PAGE', 6);
define('SUBROWS_PER_PAGE', 6);
define('DB_CONFIG_FILE', '/../config/db.config.php');
include __DIR__ . '/../Application/Database/Connection.php';
use Application\Database\Connection;
$conn = new Connection(include __DIR__ . DB_CONFIG_FILE);
$sql  = 'SELECT c.id,c.name,c.balance,c.email,f.phone, '
  . 'u.transaction,u.date,u.quantity,u.sale_price,r.title '
  . 'FROM customer AS c '
  . 'JOIN profile AS f '
  . 'ON f.id = c.id '
  . 'JOIN purchases AS u '
  . 'ON u.customer_id = c.id '
  . 'JOIN products AS r '
  . 'ON u.product_id = r.id '
  . 'WHERE c.id >= :min AND c.id < :max '
  . 'ORDER BY c.id ASC, u.date DESC ';
  1. 接下来我们可以实现基于顾客 ID 的分页形式,使用简单的$_GET参数进行限制。请注意,我们添加了额外的检查,以确保$prev的值不会低于零。您可能考虑添加另一个控件,以确保$next的值不会超出最后一个顾客 ID。在这个例子中,我们只允许它递增:
$page = $_GET['page'] ?? 1;
$page = (int) $page;
$next = $page + 1;
$prev = $page - 1;
$prev = ($prev >= 0) ? $prev : 0;
  1. 然后我们计算$min$max的值,并准备并执行 SQL 语句:
$min  = $prev * ITEMS_PER_PAGE;
$max  = $page * ITEMS_PER_PAGE;
$stmt = $conn->pdo->prepare($sql);
$stmt->execute(['min' => $min, 'max' => $max]);
  1. 使用while()循环可以用来获取结果。我们在这个例子中使用了PDO::FETCH_ASSOC的简单获取模式。我们使用顾客 ID 作为键,将基本顾客信息存储为数组参数。然后我们在一个子数组中存储一组购买信息,$results[$key]['purchases'][]。当顾客 ID 改变时,这是一个信号,表示要为下一个顾客存储相同的信息。请注意,我们在一个数组键 total 中累积每个顾客的总数:
$custId = 0;
$result = array();
$grandTotal = 0.0;
while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) {
  if ($row['id'] != $custId) {
    $custId = $row['id'];
    $result[$custId] = [
      'name'    => $row['name'],
      'balance' => $row['balance'],
      'email'   => $row['email'],
      'phone'   => $row['phone'],
    ];
    $result[$custId]['total'] = 0;
  }
  $result[$custId]['purchases'][] = [
    'transaction' => $row['transaction'],
    'date'        => $row['date'],
    'quantity'    => $row['quantity'],
    'sale_price'  => $row['sale_price'],
    'title'       => $row['title'],
  ];
  $result[$custId]['total'] += $row['sale_price'];
  $grandTotal += $row['sale_price'];
}
?>
  1. 接下来我们实现视图逻辑。首先,我们从显示主要顾客信息的块开始:
<div class="container">
<?php foreach ($result as $key => $data) : ?>
<div class="mainLeft color0">
    <?= $data['name'] ?> [<?= $key ?>]
</div>
<div class="mainRight">
  <div class="row">
    <div class="left">Balance</div>
           <div class="right"><?= $data['balance']; ?></div>
  </div>
  <div class="row">
    <div class="left color2">Email</div>
           <div class="right"><?= $data['email']; ?></div>
  </div>
  <div class="row">
    <div class="left">Phone</div>
           <div class="right"><?= $data['phone']; ?></div>
    </div>
  <div class="row">
        <div class="left color2">Total Purchases</div>
    <div class="right">
<?= number_format($data['total'],2); ?>
</div>
  </div>
  1. 接下来是显示该顾客的购买列表的逻辑:
<!-- Purchases Info -->
<table>
  <tr>
  <th>Transaction</th><th>Date</th><th>Qty</th>
   <th>Price</th><th>Product</th>
  </tr>
  <?php $count  = 0; ?>
  <?php foreach ($data['purchases'] as $purchase) : ?>
  <?php $class = ($count++ & 01) ? 'color1' : 'color2'; ?>
  <tr>
  <td class="<?= $class ?>"><?= $purchase['transaction'] ?></td>
  <td class="<?= $class ?>"><?= $purchase['date'] ?></td>
  <td class="<?= $class ?>"><?= $purchase['quantity'] ?></td>
  <td class="<?= $class ?>"><?= $purchase['sale_price'] ?></td>
  <td class="<?= $class ?>"><?= $purchase['title'] ?></td>
  </tr>
  <?php endforeach; ?>
</table>
  1. 为了分页的目的,我们添加按钮来表示上一个下一个
<?php endforeach; ?>
<div class="container">
  <a href="?page=<?= $prev ?>">
        <input type="button" value="Previous"></a>
  <a href="?page=<?= $next ?>">
        <input type="button" value="Next" class="buttonRight"></a>
</div>
<div class="clearRow"></div>
</div>
  1. 到目前为止,结果非常不整洁!因此,我们添加了一个简单的 JavaScript 函数,根据其id属性切换<div>标签的可见性:
<script type="text/javascript">
function showOrHide(id) {
  var div = document.getElementById(id);
  div.style.display = div.style.display == "none" ? "block" : "none";
}
</script>
  1. 接下来我们将购买表格包裹在最初不可见的<div>标签中。然后,我们可以设置初始可见的子行数的限制,并添加一个显示剩余购买数据的链接:
<div class="row" id="<?= 'purchase' . $key ?>" style="display:none;">
  <table>
    <tr>
      <th>Transaction</th><th>Date</th><th>Qty</th>
                 <th>Price</th><th>Product</th>
    </tr>
  <?php $count  = 0; ?>
  <?php $first  = TRUE; ?>
  <?php foreach ($data['purchases'] as $purchase) : ?>
    <?php if ($count > SUBROWS_PER_PAGE && $first) : ?>
    <?php     $first = FALSE; ?>
    <?php     $subId = 'subrow' . $key; ?>
    </table>
    <a href="#" onClick="showOrHide('<?= $subId ?>')">More</a>
    <div id="<?= $subId ?>" style="display:none;">
    <table>
    <?php endif; ?>
  <?php $class = ($count++ & 01) ? 'color1' : 'color2'; ?>
  <tr>
  <td class="<?= $class ?>"><?= $purchase['transaction'] ?></td>
  <td class="<?= $class ?>"><?= $purchase['date'] ?></td>
  <td class="<?= $class ?>"><?= $purchase['quantity'] ?></td>
  <td class="<?= $class ?>"><?= $purchase['sale_price'] ?></td>
  <td class="<?= $class ?>"><?= $purchase['title'] ?></td>
  </tr>
  <?php endforeach; ?>
  </table>
  <?php if (!$first) : ?></div><?php endif; ?>
</div>
  1. 然后我们添加一个按钮,当点击时,会显示隐藏的<div>标签:
<input type="button" value="Purchases" class="buttonRight" 
    onClick="showOrHide('<?= 'purchase' . $key ?>')">

它是如何工作的...

将步骤 1 到 5 中描述的代码放入一个文件chap_10_html_table_multi_array_hidden.php中。

while()循环的内部,添加以下内容:

printf('%6s : %20s : %8s : %20s' . PHP_EOL, 
    $row['id'], $row['name'], $row['transaction'], $row['title']);

while()循环之后,添加一个exit命令。以下是输出:

它是如何工作的...

您会注意到基本的顾客信息,如 ID 和姓名,会重复出现在每个结果行中,但购买信息,如交易和产品标题,会有所不同。继续并删除printf()语句。

用以下内容替换exit命令:

echo '<pre>', var_dump($result), '</pre>'; exit;

新组成的 3D 数组如下所示:

它是如何工作的...

您现在可以添加步骤 5 到 7 中显示逻辑。虽然您现在显示了所有数据,但视觉显示并不有用。现在继续添加剩下步骤中提到的改进。这是初始输出可能会出现的样子:

它是如何工作的...

当点击购买按钮时,初始购买信息会出现。如果点击更多的链接,剩余的购买信息会显示:

它是如何工作的...

第十一章:实施软件设计模式

在本章中,我们将涵盖以下主题:

  • 创建数组到对象的水合物

  • 构建对象到数组的水合物

  • 实施策略模式

  • 定义映射器

  • 实施对象关系映射

  • 实施发布/订阅设计模式

介绍

软件设计模式融入面向对象编程OOP)代码的想法首次在一部名为《设计模式:可复用面向对象软件的基本元素》的重要著作中讨论,该著作由著名的四人组(E. Gamma,R. Helm,R. Johnson 和 J. Vlissides)于 1994 年撰写。这项工作既没有定义标准也没有协议,而是确定了多年来被证明有用的常见通用软件设计。本书讨论的模式通常被认为属于三类:创建型、结构型和行为型。

这本书中已经介绍了许多这些模式的例子。以下是一个简要总结:

设计模式 章节 食谱
单例 2 定义可见性
工厂 6 实施表单工厂
适配器 8 处理没有gettext()的翻译
代理 7 创建一个简单的 REST 客户端创建一个简单的 SOAP 客户端
迭代器 23 递归目录迭代器使用迭代器

在本章中,我们将研究一些额外的设计模式,主要关注并发和架构模式。

创建数组到对象的水合物

水合物模式是数据传输对象设计模式的一种变体。它的设计原则非常简单:将数据从一个地方移动到另一个地方。在这个示例中,我们将定义类来将数据从数组移动到对象。

如何做...

  1. 首先,我们定义一个能够使用 getter 和 setter 的Hydrator类。为了说明这一点,我们将使用Application\Generic\Hydrator\GetSet
namespace Application\Generic\Hydrator;
class GetSet
{
  // code
}
  1. 接下来,我们定义一个“hydrate()”方法,它接受数组和对象作为参数。然后调用对象上的“setXXX()”方法,以从数组中填充它的值。我们使用“get_class()”来确定对象的类,然后使用“get_class_methods()”来获取所有方法的列表。“preg_match()”用于匹配方法前缀及其后缀,随后假定为数组键:
public static function hydrate(array $array, $object)
{
  $class = get_class($object);
  $methodList = get_class_methods($class);
  foreach ($methodList as $method) {
    preg_match('/^(set)(.*?)$/i', $method, $matches);
    $prefix = $matches[1] ?? '';
    $key    = $matches[2] ?? '';
    $key    = strtolower(substr($key, 0, 1)) . substr($key, 1);
    if ($prefix == 'set' && !empty($array[$key])) {
        $object->$method($array[$key]);
    }
  }
  return $object;
}

工作原理...

为了演示如何使用数组到水合物对象,首先按照如何做...部分中的说明定义Application\Generic\Hydrator\GetSet类。接下来,定义一个实体类,用于测试这个概念。为了本示例,创建一个Application\Entity\Person类,具有适当的属性和方法。确保为所有属性定义 getter 和 setter。这里没有显示所有这样的方法:

namespace Application\Entity;
class Person
{
  protected $firstName  = '';
  protected $lastName   = '';
  protected $address    = '';
  protected $city       = '';
  protected $stateProv  = '';
  protected $postalCode = '';
  protected $country    = '';

  public function getFirstName()
  {
    return $this->firstName;
  }

  public function setFirstName($firstName)
  {
    $this->firstName = $firstName;
  }

  // etc.
}

现在可以创建一个名为chap_11_array_to_object.php的调用程序,设置自动加载,并使用适当的类:

<?php
require __DIR__ . '/../Application/Autoload/Loader.php';
Application\Autoload\Loader::init(__DIR__ . '/..');
use Application\Entity\Person;
use Application\Generic\Hydrator\GetSet;

接下来,您可以定义一个测试数组,其中包含将添加到新的Person实例中的值:

$a['firstName'] = 'Li\'l Abner';
$a['lastName']  = 'Yokum';
$a['address']   = '1 Dirt Street';
$a['city']      = 'Dogpatch';
$a['stateProv'] = 'Kentucky';
$a['postalCode']= '12345';
$a['country']   = 'USA';

现在可以以静态方式调用hydrate()extract()

$b = GetSet::hydrate($a, new Person());
var_dump($b);

结果显示在以下屏幕截图中:

工作原理...

构建对象到数组的水合物

这个食谱是创建数组到对象的水合物食谱的相反。在这种情况下,我们需要从对象属性中提取值,并返回一个关联数组,其中键将是列名。

如何做...

  1. 为了说明这一点,我们将在前一篇中定义的Application\Generic\Hydrator\GetSet类的基础上进行构建:
namespace Application\Generic\Hydrator;
class GetSet
{
  // code
}
  1. 在前一篇中定义的hydrate()方法之后,我们定义了一个extract()方法,它以对象作为参数。逻辑与hydrate()使用的逻辑类似,只是这次我们要搜索getXXX()方法。同样,使用preg_match()来匹配方法前缀及其后缀,随后假定为数组键:
public static function extract($object)
{
  $array = array();
  $class = get_class($object);
  $methodList = get_class_methods($class);
  foreach ($methodList as $method) {
    preg_match('/^(get)(.*?)$/i', $method, $matches);
    $prefix = $matches[1] ?? '';
    $key    = $matches[2] ?? '';
    $key    = strtolower(substr($key, 0, 1)) . substr($key, 1);
    if ($prefix == 'get') {
      $array[$key] = $object->$method();
    }
  }
  return $array;
}
}

注意

棘手的部分是弄清楚选择哪种填充策略。为此,我们定义了chooseStrategy(),它以对象作为参数。我们首先通过获取类方法列表来进行一些侦探工作。然后我们扫描列表,看看是否有任何getXXX()setXXX()方法。如果有,我们选择GetSet填充器作为我们选择的策略:

它是如何工作的...

定义一个名为chap_11_object_to_array.php的调用程序,设置自动加载,并使用适当的类:

<?php
require __DIR__ . '/../Application/Autoload/Loader.php';
Application\Autoload\Loader::init(__DIR__ . '/..');
use Application\Entity\Person;
use Application\Generic\Hydrator\GetSet;

我们还添加了一个addStrategy()方法,允许我们覆盖或添加新的策略,而无需重新编写类:

$obj = new Person();
$obj->setFirstName('Li\'lAbner');
$obj->setLastName('Yokum');
$obj->setAddress('1DirtStreet');
$obj->setCity('Dogpatch');
$obj->setStateProv('Kentucky');
$obj->setPostalCode('12345');
$obj->setCountry('USA');

通常情况下,运行时条件会迫使开发人员定义多种解决同一问题的方式。传统上,这涉及到一个庞大的if/elseif/else命令块。然后,您要么必须在if语句内定义大块逻辑,要么创建一系列函数或方法来实现不同的方法。策略模式试图通过使主类封装一系列代表解决同一问题的不同方法的子类来规范化这个过程。

$a = GetSet::extract($obj);
var_dump($a);

输出如下:

请注意,我们已将hydrate()extract()定义为静态方法,以方便使用。

实施策略模式

我们首先定义反映可用内置策略的类常量:

接下来,定义一个Person的实例,为其属性设置值:

  1. 在这个示例中,我们将使用之前定义的GetSet填充器类作为策略。我们将定义一个主要的Application\Generic\Hydrator\Any类,然后在Application\Generic\Hydrator\Strategy命名空间中使用策略类,包括GetSetPublicPropsExtending

  2. 以下是输出的截图:

namespace Application\Generic\Hydrator;
use InvalidArgumentException;
use Application\Generic\Hydrator\Strategy\ { 
GetSet, PublicProps, Extending };
class Any
{
  const STRATEGY_PUBLIC  = 'PublicProps';
  const STRATEGY_GET_SET = 'GetSet';
  const STRATEGY_EXTEND  = 'Extending';
  protected $strategies;
  public $chosen;
  1. 在新的命名空间中,我们定义一个接口,允许我们识别任何可以被Application\Generic\Hydrator\Any消耗的策略:
public function __construct()
{
  $this->strategies[self::STRATEGY_GET_SET] = new GetSet();
  $this->strategies[self::STRATEGY_PUBLIC] = new PublicProps();
  $this->strategies[self::STRATEGY_EXTEND] = new Extending();
}
  1. hydrate()方法是最困难的,因为我们假设没有定义 getter 或 setter,也没有使用public可见级别定义属性。因此,我们需要定义一个扩展要被填充的对象类的类。我们首先定义一个字符串,将用作构建新类的模板:
public function addStrategy($key, HydratorInterface $strategy)
{
  $this->strategies[$key] = $strategy;
}
  1. hydrate()extract()方法只是调用所选择策略的方法:
public function hydrate(array $array, $object)
{
  $strategy = $this->chooseStrategy($object);
  $this->chosen = get_class($strategy);
  return $strategy::hydrate($array, $object);
}

public function extract($object)
{
  $strategy = $this->chooseStrategy($object);
  $this->chosen = get_class($strategy);
  return $strategy::extract($object);
}
  1. 现在我们将注意力转向策略本身。首先,我们定义一个新的Application\Generic\Hydrator\Strategy命名空间。
public function chooseStrategy($object)
{
  $strategy = NULL;
  $methodList = get_class_methods(get_class($object));
  if (!empty($methodList) && is_array($methodList)) {
      $getSet = FALSE;
      foreach ($methodList as $method) {
        if (preg_match('/^get|set.*$/i', $method)) {
            $strategy = $this->strategies[self::STRATEGY_GET_SET];
      break;
    }
  }
}
  1. 在我们的chooseStrategy()方法中,如果没有 getter 或 setter,我们接下来使用get_class_vars()来确定是否有任何可用的属性。如果有,我们选择PublicProps作为我们的填充器:
if (!$strategy) {
    $vars = get_class_vars(get_class($object));
    if (!empty($vars) && count($vars)) {
        $strategy = $this->strategies[self::STRATEGY_PUBLIC];
    }
}
  1. 如果一切都失败了,我们将退回到Extending填充器,它返回一个简单地扩展对象类的新类,从而使任何publicprotected属性可用:
if (!$strategy) {
    $strategy = $this->strategies[self::STRATEGY_EXTEND];
}
return $strategy;
}
}
  1. 然后,我们定义一个构造函数,将所有内置策略添加到$strategies属性中:

  2. 如何做...

namespace Application\Generic\Hydrator\Strategy;
interface HydratorInterface
{
  public static function hydrate(array $array, $object);
  public static function extract($object);
}
  1. GetSet填充器与前两个示例中定义的完全相同,唯一的添加是它将实现新的接口:
namespace Application\Generic\Hydrator\Strategy;
class GetSet implements HydratorInterface
{

  public static function hydrate(array $array, $object)
  {
    // defined in the recipe:
    // "Creating an Array to Object Hydrator"
  }

  public static function extract($object)
  {
    // defined in the recipe:
    // "Building an Object to Array Hydrator"
  }
}
  1. 下一个填充器只是读取和写入公共属性:
namespace Application\Generic\Hydrator\Strategy;
class PublicProps implements HydratorInterface
{
  public static function hydrate(array $array, $object)
  {
    $propertyList= array_keys(
      get_class_vars(get_class($object)));
    foreach ($propertyList as $property) {
      $object->$property = $array[$property] ?? NULL;
    }
    return $object;
  }

  public static function extract($object)
  {
    $array = array();
    $propertyList = array_keys(
      get_class_vars(get_class($object)));
    foreach ($propertyList as $property) {
      $array[$property] = $object->$property;
    }
    return $array;
  }
}
  1. 最后,Extending,填充器的瑞士军刀,扩展了对象类,从而直接访问属性。我们进一步定义了魔术 getter 和 setter,以提供对属性的访问。

  2. 最后,以静态方式调用新的extract()方法:

namespace Application\Generic\Hydrator\Strategy;
class Extending implements HydratorInterface
{
  const UNDEFINED_PREFIX = 'undefined';
  const TEMP_PREFIX = 'TEMP_';
  const ERROR_EVAL = 'ERROR: unable to evaluate object';
  public static function hydrate(array $array, $object)
  {
    $className = get_class($object);
    $components = explode('\\', $className);
    $realClass  = array_pop($components);
    $nameSpace  = implode('\\', $components);
    $tempClass = $realClass . self::TEMP_SUFFIX;
    $template = 'namespace ' 
      . $nameSpace . '{'
      . 'class ' . $tempClass 
      . ' extends ' . $realClass . ' '
  1. hydrate()方法中,我们定义了一个$values属性和一个将要被填充到对象中的数组的构造函数。我们循环遍历值数组,将值分配给属性。我们还定义了一个有用的getArrayCopy()方法,如果需要,返回这些值,以及一个模拟直接属性访问的魔术__get()方法:
. '{ '
. '  protected $values; '
. '  public function __construct($array) '
. '  { $this->values = $array; '
. '    foreach ($array as $key => $value) '
. '       $this->$key = $value; '
. '  } '
. '  public function getArrayCopy() '
. '  { return $this->values; } '
  1. 为方便起见,我们定义了一个魔术__get()方法,模拟直接变量访问,就像它们是公共的一样:
. '  public function __get($key) '
. '  { return $this->values[$key] ?? NULL; } '
  1. 在新类的模板中,我们还定义了一个魔术__call()方法,模拟 getter 和 setter:
. '  public function __call($method, $params) '
. '  { '
. '    preg_match("/^(get|set)(.*?)$/i", '
. '        $method, $matches); '
. '    $prefix = $matches[1] ?? ""; '
. '    $key    = $matches[2] ?? ""; '
. '    $key    = strtolower(substr($key, 0, 1)) ' 
. '              substr($key, 1); '
. '    if ($prefix == "get") { '
. '        return $this->values[$key] ?? NULL; '
. '     } else { '
. '        $this->values[$key] = $params[0]; '
. '     } '
. '  } '
. '} '
. '} // ends namespace ' . PHP_EOL
  1. 最后,在新类的模板中,我们添加一个函数,在全局命名空间中构建并返回类实例:
. 'namespace { '
. 'function build($array) '
. '{ return new ' . $nameSpace . '\\' 
.    $tempClass . '($array); } '
. '} // ends global namespace '
. PHP_EOL;
  1. 仍然在hydrate()方法中,我们使用eval()执行完成的模板。然后运行刚在模板末尾定义的build()方法。请注意,由于我们不确定要填充的类的命名空间,我们从全局命名空间中定义和调用build()
try {
    eval($template);
} catch (ParseError $e) {
    error_log(__METHOD__ . ':' . $e->getMessage());
    throw new Exception(self::ERROR_EVAL);
}
return \build($array);
}
  1. extract()方法更容易定义,因为我们的选择非常有限。通过魔术方法扩展类并从数组中填充它很容易实现。反之则不然。如果我们扩展类,我们将丢失所有属性值,因为我们扩展的是类,而不是对象实例。因此,我们唯一的选择是使用 getter 和公共属性的组合:
public static function extract($object)
{
  $array = array();
  $class = get_class($object);
  $methodList = get_class_methods($class);
  foreach ($methodList as $method) {
    preg_match('/^(get)(.*?)$/i', $method, $matches);
    $prefix = $matches[1] ?? '';
    $key    = $matches[2] ?? '';
    $key    = strtolower(substr($key, 0, 1)) 
    . substr($key, 1);
    if ($prefix == 'get') {
        $array[$key] = $object->$method();
    }
  }
  $propertyList= array_keys(get_class_vars($class));
  foreach ($propertyList as $property) {
    $array[$property] = $object->$property;
  }
  return $array;
  }
}

它是如何工作的...

您可以首先定义三个具有相同属性的测试类:firstNamelastName等。第一个Person应该有受保护的属性以及 getter 和 setter。第二个PublicPerson将具有公共属性。第三个ProtectedPerson具有受保护的属性,但没有 getter 或 setter:

<?php
namespace Application\Entity;
class Person
{
  protected $firstName  = '';
  protected $lastName   = '';
  protected $address    = '';
  protected $city       = '';
  protected $stateProv  = '';
  protected $postalCode = '';
  protected $country    = '';

    public function getFirstName()
    {
      return $this->firstName;
    }

    public function setFirstName($firstName)
    {
      $this->firstName = $firstName;
    }

  // be sure to define remaining getters and setters

}

<?php
namespace Application\Entity;
class PublicPerson
{
  private $id = NULL;
  public $firstName  = '';
  public $lastName   = '';
  public $address    = '';
  public $city       = '';
  public $stateProv  = '';
  public $postalCode = '';
  public $country    = '';
}

<?php
namespace Application\Entity;

class ProtectedPerson
{
  private $id = NULL;
  protected $firstName  = '';
  protected $lastName   = '';
  protected $address    = '';
  protected $city       = '';
  protected $stateProv  = '';
  protected $postalCode = '';
  protected $country    = '';
}

现在,您可以定义一个名为chap_11_strategy_pattern.php的调用程序,该程序设置自动加载并使用适当的类:

<?php
require __DIR__ . '/../Application/Autoload/Loader.php';
Application\Autoload\Loader::init(__DIR__ . '/..');
use Application\Entity\ { Person, PublicPerson, ProtectedPerson };
use Application\Generic\Hydrator\Any;
use Application\Generic\Hydrator\Strategy\ { GetSet, Extending, PublicProps };

接下来,创建一个Person的实例,并运行 setter 来定义属性的值:

$obj = new Person();
$obj->setFirstName('Li\'lAbner');
$obj->setLastName('Yokum');
$obj->setAddress('1 Dirt Street');
$obj->setCity('Dogpatch');
$obj->setStateProv('Kentucky');
$obj->setPostalCode('12345');
$obj->setCountry('USA');

接下来,创建Any水合剂的实例,调用extract(),并使用var_dump()查看结果:

$hydrator = new Any();
$b = $hydrator->extract($obj);
echo "\nChosen Strategy: " . $hydrator->chosen . "\n";
var_dump($b);

请注意,在以下输出中,选择了GetSet策略:

它是如何工作的...

注意

请注意,id属性未设置,因为它的可见性级别是private

接下来,您可以定义一个具有相同值的数组。在Any实例上调用hydrate(),并提供一个新的PublicPerson实例作为参数:

$a = [
  'firstName'  => 'Li\'lAbner',
  'lastName'   => 'Yokum',
  'address'    => '1 Dirt Street',
  'city'       => 'Dogpatch',
  'stateProv'  => 'Kentucky',
  'postalCode' => '12345',
  'country'    => 'USA'
];

$p = $hydrator->hydrate($a, new PublicPerson());
echo "\nChosen Strategy: " . $hydrator->chosen . "\n";
var_dump($p);

这是结果。请注意,这种情况下选择了PublicProps策略:

它是如何工作的...

最后,再次调用hydrate(),但这次提供一个ProtectedPerson的实例作为对象参数。然后我们调用getFirstName()getLastName()来测试魔术 getter。我们还以直接变量访问的方式访问名字和姓氏:

$q = $hydrator->hydrate($a, new ProtectedPerson());
echo "\nChosen Strategy: " . $hydrator->chosen . "\n";
echo "Name: {$q->getFirstName()} {$q->getLastName()}\n";
echo "Name: {$q->firstName} {$q->lastName}\n";
var_dump($q);

这是最后的输出,显示选择了Extending策略。您还会注意到实例是一个新的ProtectedPerson_TEMP类,并且受保护的属性已完全填充:

它是如何工作的...

定义一个映射器

映射器数据映射器的工作方式与水合剂类似:将数据从一个模型(数组或对象)转换为另一个模型。一个关键的区别是,水合剂是通用的,不需要预先编程对象属性名称,而映射器则相反:它需要精确的属性名称信息来定义两个模型。在这个示例中,我们将演示使用映射器将数据从一个数据库表转换为另一个数据库表。

如何做...

  1. 我们首先定义一个Application\Database\Mapper\FieldConfig类,其中包含单个字段的映射指令。我们还定义适当的类常量:
namespace Application\Database\Mapper;
use InvalidArgumentException;
class FieldConfig
{
  const ERROR_SOURCE = 
    'ERROR: need to specify destTable and/or source';
  const ERROR_DEST   = 'ERROR: need to specify either '
    . 'both destTable and destCol or neither';
  1. 关键属性与适当的类常量一起定义。$key用于标识对象。$source表示源数据库表中的列。$destTable$destCol表示目标数据库表和列。如果定义了$default,则包含默认值或产生适当值的回调:
public $key;
public $source;
public $destTable;
public $destCol;
public $default;
  1. 现在我们转向构造函数,它分配默认值,构建密钥,并检查$source$destTable$destCol是否已定义:
public function __construct($source = NULL,
                            $destTable = NULL,
                            $destCol   = NULL,
                            $default   = NULL)
{
  // generate key from source + destTable + destCol
  $this->key = $source . '.' . $destTable . '.' . $destCol;
  $this->source = $source;
  $this->destTable = $destTable;
  $this->destCol = $destCol;
  $this->default = $default;
  if (($destTable && !$destCol) || 
      (!$destTable && $destCol)) {
      throw new InvalidArgumentException(self::ERROR_DEST);
  }
  if (!$destTable && !$source) {
      throw new InvalidArgumentException(
        self::ERROR_SOURCE);
  }
}

注意

请注意,我们允许源列和目标列为空。这是因为我们可能有一个源列在目标表中没有位置。同样,目标表中可能有强制列,在源表中没有表示。

  1. 在默认值的情况下,我们需要检查值是否是一个回调。如果是,我们运行回调;否则,我们返回直接值。请注意,回调应该被定义为接受数据库表行作为参数:
public function getDefault()
{
  if (is_callable($this->default)) {
      return call_user_func($this->default, $row);
  } else {
      return $this->default;
  }
}
  1. 最后,为了完成这个类,我们为这五个属性定义了 getter 和 setter:
public function getKey()
{
  return $this->key;
}

public function setKey($key)
{
  $this->key = $key;
}

// etc.
  1. 接下来,我们定义一个Application\Database\Mapper\Mapping映射类,它接受源表和目标表的名称,以及一个FieldConfig对象数组作为参数。您将看到我们允许目标表属性为数组,因为映射可能是到两个或更多目标表:
namespace Application\Database\Mapper;
class Mapping
{
  protected $sourceTable;
  protected $destTable;
  protected $fields;
  protected $sourceCols;
  protected $destCols;

  public function __construct(
    $sourceTable, $destTable, $fields = NULL)
  {
    $this->sourceTable = $sourceTable;
    $this->destTable = $destTable;
    $this->fields = $fields;
  }
  1. 然后我们为这些属性定义 getter 和 setter:
public function getSourceTable()
{
  return $this->sourceTable;
}
public function setSourceTable($sourceTable)
{
  $this->sourceTable = $sourceTable;
}
// etc.
  1. 对于字段配置,我们还需要提供添加单个字段的能力。无需提供键作为单独的参数,因为这可以从FieldConfig实例中获取:
public function addField(FieldConfig $field)
{
  $this->fields[$field->getKey()] = $field;
  return $this;
}
  1. 获取源列名的数组非常重要。问题在于源列名是FieldConfig对象中的一个属性。因此,当调用这个方法时,我们循环遍历FieldConfig对象数组,并在每个对象上调用getSource()来获取源列名:
public function getSourceColumns()
{
  if (!$this->sourceCols) {
      $this->sourceCols = array();
      foreach ($this->getFields() as $field) {
        if (!empty($field->getSource())) {
            $this->sourceCols[$field->getKey()] = 
              $field->getSource();
        }
      }
  }
  return $this->sourceCols;
}
  1. 我们对getDestColumns()使用了类似的方法。与获取源列列表相比的一个重大区别是,我们只想要一个特定目标表的列,如果定义了多个这样的表,这一点至关重要。我们不需要检查$destCol是否设置,因为这已经在FieldConfig的构造函数中处理了:
public function getDestColumns($table)
{
  if (empty($this->destCols[$table])) {
      foreach ($this->getFields() as $field) {
        if ($field->getDestTable()) {
          if ($field->getDestTable() == $table) {
              $this->destCols[$table][$field->getKey()] = 
                $field->getDestCol();
          }
        }
      }
  }
  return $this->destCols[$table];
}
  1. 最后,我们定义一个方法,它的第一个参数是表示来自源表的一行数据的数组。第二个参数是目标表的名称。该方法生成一个准备插入到目标表中的数据数组。

  2. 我们必须做出一个决定,哪个优先级更高:默认值(可以由回调提供)还是来自源表的数据。我们决定首先测试默认值。如果默认值返回NULL,则使用来自源表的数据。请注意,如果需要进一步处理,默认值应该被定义为回调。

public function mapData($sourceData, $destTable)
{
  $dest = array();
  foreach ($this->fields as $field) {
    if ($field->getDestTable() == $destTable) {
        $dest[$field->getDestCol()] = NULL;
        $default = $field->getDefault($sourceData);
        if ($default) {
            $dest[$field->getDestCol()] = $default;
        } else {
            $dest[$field->getDestCol()] = 
                  $sourceData[$field->getSource()];
        }
    }
  }
  return $dest;
}
}

注意

请注意,目标插入中会出现一些在源行中不存在的列。在这种情况下,FieldConfig对象的$source属性被留空,然后提供一个默认值,可以是标量值或回调函数。

  1. 现在我们准备定义两个方法来生成 SQL。第一个方法将生成一个 SQL 语句,用于从源表中读取数据。该语句将包括要准备的占位符(例如,使用PDO::prepare()):
public function getSourceSelect($where = NULL)
{
  $sql = 'SELECT ' 
  . implode(',', $this->getSourceColumns()) . ' ';
  $sql .= 'FROM ' . $this->getSourceTable() . ' ';
  if ($where) {
    $where = trim($where);
    if (stripos($where, 'WHERE') !== FALSE) {
        $sql .= $where;
    } else {
        $sql .= 'WHERE ' . $where;
    }
  }
  return trim($sql);
}
  1. 另一个 SQL 生成方法生成一个要为特定目标表准备的语句。请注意,占位符与列名相同,前面加上“:”:
public function getDestInsert($table)
{
  $sql = 'INSERT INTO ' . $table . ' ';
  $sql .= '( ' 
  . implode(',', $this->getDestColumns($table)) 
  . ' ) ';
  $sql .= ' VALUES ';
  $sql .= '( :' 
  . implode(',:', $this->getDestColumns($table)) 
  . ' ) ';
  return trim($sql);
}

工作原理...

使用步骤 1 到 5 中显示的代码生成一个Application\Database\Mapper\FieldConfig类。将步骤 6 到 14 中显示的代码放入第二个Application\Database\Mapper\Mapping类中。

在定义执行映射的调用程序之前,重要的是考虑源和目标数据库表。源表prospects_11的定义如下:

CREATE TABLE `prospects_11` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `first_name` varchar(128) NOT NULL,
  `last_name` varchar(128) NOT NULL,
  `address` varchar(256) DEFAULT NULL,
  `city` varchar(64) DEFAULT NULL,
  `state_province` varchar(32) DEFAULT NULL,
  `postal_code` char(16) NOT NULL,
  `phone` varchar(16) NOT NULL,
  `country` char(2) NOT NULL,
  `email` varchar(250) NOT NULL,
  `status` char(8) DEFAULT NULL,
  `budget` decimal(10,2) DEFAULT NULL,
  `last_updated` datetime DEFAULT NULL,
  PRIMARY KEY (`id`),
  UNIQUE KEY `UNIQ_35730C06E7927C74` (`email`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;

在这个例子中,您可以使用两个目标表,customer_11profile_11,它们之间存在 1:1 的关系:

CREATE TABLE `customer_11` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `name` varchar(256) CHARACTER SET latin1 
     COLLATE latin1_general_cs NOT NULL,
  `balance` decimal(10,2) NOT NULL,
  `email` varchar(250) NOT NULL,
  `password` char(16) NOT NULL,
  `status` int(10) unsigned NOT NULL DEFAULT '0',
  `security_question` varchar(250) DEFAULT NULL,
  `confirm_code` varchar(32) DEFAULT NULL,
  `profile_id` int(11) DEFAULT NULL,
  `level` char(3) NOT NULL,
  PRIMARY KEY (`id`),
  UNIQUE KEY `UNIQ_81398E09E7927C74` (`email`)
) ENGINE=InnoDB AUTO_INCREMENT=80 DEFAULT CHARSET=utf8 COMMENT='Customers';

CREATE TABLE `profile_11` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `address` varchar(256) NOT NULL,
  `city` varchar(64) NOT NULL,
  `state_province` varchar(32) NOT NULL,
  `postal_code` varchar(10) NOT NULL,
  `country` varchar(3) NOT NULL,
  `phone` varchar(16) NOT NULL,
  `photo` varchar(128) NOT NULL,
  `dob` datetime NOT NULL,
  PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=80 DEFAULT CHARSET=utf8 COMMENT='Customers';

现在可以定义一个名为chap_11_mapper.php的调用程序,设置自动加载并使用前面提到的两个类。还可以使用第五章中定义的Connection类,与数据库交互

<?php
define('DB_CONFIG_FILE', '/../config/db.config.php');
define('DEFAULT_PHOTO', 'person.gif');
require __DIR__ . '/../Application/Autoload/Loader.php';
Application\Autoload\Loader::init(__DIR__ . '/..');
use Application\Database\Mapper\ { FieldConfig, Mapping };
use Application\Database\Connection;
$conn = new Connection(include __DIR__ . DB_CONFIG_FILE);

为了演示目的,在确保两个目标表存在后,可以清空这两个表,以确保显示的任何数据都是干净的:

$conn->pdo->query('DELETE FROM customer_11');
$conn->pdo->query('DELETE FROM profile_11');

现在可以构建Mapping实例并用FieldConfig对象填充它。每个FieldConfig对象表示源和目标之间的映射。在构造函数中,以数组形式提供源表的名称和两个目标表的名称:

$mapper = new Mapping('prospects_11', ['customer_11','profile_11']);

可以从prospects_11customer_11之间简单地进行字段映射,其中没有默认值:

$mapper>addField(new FieldConfig('email','customer_11','email'))

请注意,addField()返回当前的映射实例,因此无需不断指定$mapper->addField()。这种技术被称为流畅接口

名称字段比较棘手,在prospects_11表中由两列表示,但在customer_11表中只有一列。因此,您可以添加一个回调作为first_name的默认值,将这两个字段合并为一个。您还需要为last_name定义一个条目,但没有目标映射:

->addField(new FieldConfig('first_name','customer_11','name',
  function ($row) { return trim(($row['first_name'] ?? '') 
. ' ' .  ($row['last_name'] ?? ''));}))
->addField(new FieldConfig('last_name'))

customer_11::status字段可以使用空值合并运算符(??)来确定是否已设置:

->addField(new FieldConfig('status','customer_11','status',
  function ($row) { return $row['status'] ?? 'Unknown'; }))

customer_11::level字段在源表中没有表示,因此可以为源字段创建一个NULL条目,但确保设置了目标表和列。同样,customer_11::password在源表中也不存在。在这种情况下,回调使用电话号码作为临时密码:

->addField(new FieldConfig(NULL,'customer_11','level','BEG'))
->addField(new FieldConfig(NULL,'customer_11','password',
  function ($row) { return $row['phone']; }))

还可以将prospects_11profile_11的映射设置如下。请注意,由于源照片和出生日期列在prospects_11中不存在,因此可以设置任何适当的默认值:

->addField(new FieldConfig('address','profile_11','address'))
->addField(new FieldConfig('city','profile_11','city'))
->addField(new FieldConfig('state_province','profile_11', 
'state_province', function ($row) { 
  return $row['state_province'] ?? 'Unknown'; }))
->addField(new FieldConfig('postal_code','profile_11',
'postal_code'))
->addField(new FieldConfig('phone','profile_11','phone'))
->addField(new FieldConfig('country','profile_11','country'))
->addField(new FieldConfig(NULL,'profile_11','photo',
DEFAULT_PHOTO))
->addField(new FieldConfig(NULL,'profile_11','dob',
date('Y-m-d')));

为了建立profile_11customer_11表之间的 1:1 关系,我们使用回调将customer_11::idcustomer_11::profile_idprofile_11::id的值设置为$row['id']的值:

$idCallback = function ($row) { return $row['id']; };
$mapper->addField(new FieldConfig('id','customer_11','id',
$idCallback))
->addField(new FieldConfig(NULL,'customer_11','profile_id',
$idCallback))
->addField(new FieldConfig('id','profile_11','id',$idCallback));

现在可以调用适当的方法生成三个 SQL 语句,一个用于从源表中读取数据,另外两个用于插入到两个目标表中:

$sourceSelect  = $mapper->getSourceSelect();
$custInsert    = $mapper->getDestInsert('customer_11');
$profileInsert = $mapper->getDestInsert('profile_11');

这三个语句可以立即准备好以供以后执行:

$sourceStmt  = $conn->pdo->prepare($sourceSelect);
$custStmt    = $conn->pdo->prepare($custInsert);
$profileStmt = $conn->pdo->prepare($profileInsert);

然后执行SELECT语句,从源表中生成行。然后在循环中为每个目标表生成INSERT数据,并执行适当的预处理语句:

$sourceStmt->execute();
while ($row = $sourceStmt->fetch(PDO::FETCH_ASSOC)) {
  $custData = $mapper->mapData($row, 'customer_11');
  $custStmt->execute($custData);
  $profileData = $mapper->mapData($row, 'profile_11');
  $profileStmt->execute($profileData);
  echo "Processing: {$custData['name']}\n";
}

以下是生成的三个 SQL 语句:

工作原理...

然后可以使用 SQL JOIN直接从数据库中查看数据,以确保关系已经保持:

工作原理...

实现对象关系映射

有两种主要技术可以实现对象之间的关系映射。第一种技术涉及将相关的子对象预先加载到父对象中。这种方法的优势在于易于实现,并且所有父子信息都可以立即使用。缺点是可能会消耗大量内存,并且性能曲线会被扭曲。

第二种技术是将次要查找嵌入到父对象中。在后一种方法中,当需要访问子对象时,可以运行一个 getter 来执行次要查找。这种方法的优势在于性能需求在请求周期内得到了分散,并且内存使用更容易管理。这种方法的缺点是会生成更多的查询,这意味着对数据库服务器的更多工作。

请注意

然而,请注意,我们将展示如何使用预处理语句来大大抵消这种劣势。

如何做…

让我们看一下实现对象关系映射的两种技术。

技术#1-预加载所有子信息

首先,我们将讨论如何通过预加载所有子信息到父类中来实现对象关系映射。对于此示例,我们将使用与三个相关数据库表customerpurchasesproducts对应的实体类定义:

  1. 我们将使用现有的Application\Entity\Customer类(在第五章中定义,与数据库交互,在定义实体类以匹配数据库表的方法中)作为开发Application\Entity\Purchase类的模型。与以前一样,我们将使用数据库定义作为实体类定义的基础。以下是purchases表的数据库定义:
CREATE TABLE `purchases` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `transaction` varchar(8) NOT NULL,
  `date` datetime NOT NULL,
  `quantity` int(10) unsigned NOT NULL,
  `sale_price` decimal(8,2) NOT NULL,
  `customer_id` int(11) DEFAULT NULL,
  `product_id` int(11) DEFAULT NULL,
  PRIMARY KEY (`id`),
  KEY `IDX_C3F3` (`customer_id`),
  KEY `IDX_665A` (`product_id`),
  CONSTRAINT `FK_665A` FOREIGN KEY (`product_id`) REFERENCES `products` (`id`),
  CONSTRAINT `FK_C3F3` FOREIGN KEY (`customer_id`) REFERENCES `customer` (`id`)
);
  1. 根据客户实体类,Application\Entity\Purchase可能如下所示。请注意,未显示所有的 getter 和 setter:
namespace Application\Entity;

class Purchase extends Base
{

  const TABLE_NAME = 'purchases';
  protected $transaction = '';
  protected $date = NULL;
  protected $quantity = 0;
  protected $salePrice = 0.0;
  protected $customerId = 0;
  protected $productId = 0;

  protected $mapping = [
    'id'            => 'id',
    'transaction'   => 'transaction',
    'date'          => 'date',
    'quantity'      => 'quantity',
    'sale_price'    => 'salePrice',
    'customer_id'   => 'customerId',
    'product_id'    => 'productId',
  ];

  public function getTransaction() : string
  {
    return $this->transaction;
  }
  public function setTransaction($transaction)
  {
    $this->transaction = $transaction;
  }
  // NOTE: other getters / setters are not shown here
}
  1. 现在我们准备定义Application\Entity\Product。以下是products表的数据库定义:
CREATE TABLE `products` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `sku` varchar(16) DEFAULT NULL,
  `title` varchar(255) NOT NULL,
  `description` varchar(4096) DEFAULT NULL,
  `price` decimal(10,2) NOT NULL,
  `special` int(11) NOT NULL,
  `link` varchar(128) NOT NULL,
  PRIMARY KEY (`id`),
  UNIQUE KEY `UNIQ_38C4` (`sku`)
);
  1. 根据客户实体类,Application\Entity\Product可能如下所示:
namespace Application\Entity;

class Product extends Base
{

  const TABLE_NAME = 'products';
  protected $sku = '';
  protected $title = '';
  protected $description = '';
  protected $price = 0.0;
  protected $special = 0;
  protected $link = '';

  protected $mapping = [
    'id'          => 'id',
    'sku'         => 'sku',
    'title'       => 'title',
    'description' => 'description',
    'price'       => 'price',
    'special'     => 'special',
    'link'        => 'link',
  ];

  public function getSku() : string
  {
    return $this->sku;
  }
  public function setSku($sku)
  {
    $this->sku = $sku;
  }
  // NOTE: other getters / setters are not shown here
}
  1. 接下来,我们需要实现一种嵌入相关对象的方法。我们将从Application\Entity\Customer父类开始。在本节中,我们将假设以下关系,如下图所示:
  • 一个客户,多个购买

  • 一个购买,一个产品

技术#1-预加载所有子信息

  1. 因此,我们定义了一个处理购买的 getter 和 setter,以对象数组的形式进行处理:
protected $purchases = array();
public function addPurchase($purchase)
{
  $this->purchases[] = $purchase;
}
public function getPurchases()
{
  return $this->purchases;
}
  1. 现在我们将注意力转向Application\Entity\Purchase。在这种情况下,购买和产品之间存在 1:1 的关系,因此无需处理数组:
protected $product = NULL;
public function getProduct()
{
  return $this->product;
}
public function setProduct(Product $product)
{
  $this->product = $product;
}

注意

请注意,在两个实体类中,我们不会更改$mapping数组。这是因为实现对象关系映射对实体属性名称和数据库列名称之间的映射没有影响。

  1. 由于仍然需要获取基本客户信息的核心功能,我们只需要扩展第五章中描述的Application\Database\CustomerService类,与数据库交互,在将实体类与 RDBMS 查询绑定中。我们可以创建一个新的Application\Database\CustomerOrmService_1类,它扩展了Application\Database\CustomerService
namespace Application\Database;
use PDO;
use PDOException;
use Application\Entity\Customer;
use Application\Entity\Product;
use Application\Entity\Purchase;
class CustomerOrmService_1 extends CustomerService
{
  // add methods here
}
  1. 然后,我们向新的服务类添加一个方法,该方法执行查找并将结果嵌入到核心客户实体中,以ProductPurchase实体的形式。此方法执行JOIN形式的查找。这是可能的,因为购买和产品之间存在 1:1 的关系。因为id列在两个表中的名称相同,所以我们需要将购买 ID 列添加为别名。然后,我们遍历结果,创建ProductPurchase实体。在覆盖 ID 之后,我们可以将Product实体嵌入Purchase实体中,然后将Purchase实体添加到Customer实体中的数组中:
protected function fetchPurchasesForCustomer(Customer $cust)
{
  $sql = 'SELECT u.*,r.*,u.id AS purch_id '
    . 'FROM purchases AS u '
    . 'JOIN products AS r '
    . 'ON r.id = u.product_id '
    . 'WHERE u.customer_id = :id '
    . 'ORDER BY u.date';
  $stmt = $this->connection->pdo->prepare($sql);
  $stmt->execute(['id' => $cust->getId()]);
  while ($result = $stmt->fetch(PDO::FETCH_ASSOC)) {
    $product = Product::arrayToEntity($result, new Product());
    $product->setId($result['product_id']);
    $purch = Purchase::arrayToEntity($result, new Purchase());
    $purch->setId($result['purch_id']);
    $purch->setProduct($product);
    $cust->addPurchase($purch);
  }
  return $cust;
}
  1. 接下来,我们提供原始fetchById()方法的包装器。这段代码块不仅需要获取原始的Customer实体,还需要查找并嵌入Product 和 Purchase实体。我们可以调用新的fetchByIdAndEmbedPurchases()方法,并接受客户 ID 作为参数:
public function fetchByIdAndEmbedPurchases($id)
{
  return $this->fetchPurchasesForCustomer(
    $this->fetchById($id));
}

技术#2-嵌入次要查找

现在我们将介绍将次要查找嵌入到相关实体类中的方法。我们将继续使用与上述相同的示例,使用与三个相关数据库表customerpurchasesproducts对应的实体类定义:

  1. 这种方法的机制与前一节中描述的方法非常相似。主要区别在于,我们不会立即进行数据库查找和生成实体类,而是嵌入一系列匿名函数,这些函数将从视图逻辑中调用相同的操作。

  2. 我们需要向Application\Entity\Customer类添加一个新方法,将单个条目添加到purchases属性中。我们将提供一个匿名函数,而不是Purchase实体数组:

public function setPurchases(Closure $purchaseLookup)
{
  $this->purchases = $purchaseLookup;
}
  1. 接下来,我们将复制Application\Database\CustomerOrmService_1类,并将其命名为Application\Database\CustomerOrmService_2
namespace Application\Database;
use PDO;
use PDOException;
use Application\Entity\Customer;
use Application\Entity\Product;
use Application\Entity\Purchase;
class CustomerOrmService_2 extends CustomerService
{
  // code
}
  1. 然后,我们定义了一个fetchPurchaseById()方法,根据其 ID 查找单个购买并生成一个Purchase实体。因为在这种方法中,我们最终将进行一系列重复的请求以获取单个购买,所以我们可以通过使用相同的预处理语句(在本例中称为$purchPreparedStmt属性)来恢复数据库效率:
public function fetchPurchaseById($purchId)
{
  if (!$this->purchPreparedStmt) {
      $sql = 'SELECT * FROM purchases WHERE id = :id';
      $this->purchPreparedStmt = 
      $this->connection->pdo->prepare($sql);
  }
  $this->purchPreparedStmt->execute(['id' => $purchId]);
  $result = $this->purchPreparedStmt->fetch(PDO::FETCH_ASSOC);
  return Purchase::arrayToEntity($result, new Purchase());
}
  1. 之后,我们需要一个fetchProductById()方法,根据其 ID 查找单个产品并生成一个Product实体。鉴于客户可能多次购买同一产品,我们可以通过在$products数组中存储已获取的产品实体来提高效率。此外,与购买一样,我们可以在同一个预处理语句上执行查找:
public function fetchProductById($prodId)
{
  if (!isset($this->products[$prodId])) {
      if (!$this->prodPreparedStmt) {
          $sql = 'SELECT * FROM products WHERE id = :id';
          $this->prodPreparedStmt = 
          $this->connection->pdo->prepare($sql);
      }
      $this->prodPreparedStmt->execute(['id' => $prodId]);
      $result = $this->prodPreparedStmt
      ->fetch(PDO::FETCH_ASSOC);
      $this->products[$prodId] = 
        Product::arrayToEntity($result, new Product());
  }
  return $this->products[$prodId];
}
  1. 现在,我们可以重新设计fetchPurchasesForCustomer()方法,使其嵌入一个匿名函数,该函数调用fetchPurchaseById()fetchProductById(),然后将结果的产品实体分配给新找到的购买实体。在本例中,我们进行了一个初始查找,只返回该客户的所有购买的 ID。然后,我们在Customer::$purchases属性中嵌入了一系列匿名函数,将购买 ID 作为数组键,匿名函数作为其值:
public function fetchPurchasesForCustomer(Customer $cust)
{
  $sql = 'SELECT id '
    . 'FROM purchases AS u '
    . 'WHERE u.customer_id = :id '
    . 'ORDER BY u.date';
  $stmt = $this->connection->pdo->prepare($sql);
  $stmt->execute(['id' => $cust->getId()]);
  while ($result = $stmt->fetch(PDO::FETCH_ASSOC)) {
    $cust->addPurchaseLookup(
    $result['id'],
    function ($purchId, $service) { 
      $purchase = $service->fetchPurchaseById($purchId);
      $product  = $service->fetchProductById(
                  $purchase->getProductId());
      $purchase->setProduct($product);
      return $purchase; }
    );
  }
  return $cust;
}

它是如何工作的...

根据本教程的步骤,定义以下类如下:

技术#1 步骤
Application\Entity\Purchase 1 - 2, 7
Application\Entity\Product 3 - 4
Application\Entity\Customer 6, 16, + 在第五章, 与数据库交互中描述。
Application\Database\CustomerOrmService_1 8 - 10

这种方法的第二种途径如下:

技术#2 步骤
Application\Entity\Customer 2
Application\Database\CustomerOrmService_2 3 - 6

为了实现第一种方法,即嵌入实体,定义一个名为chap_11_orm_embedded.php的调用程序,设置自动加载并使用适当的类:

<?php
define('DB_CONFIG_FILE', '/../config/db.config.php');
require __DIR__ . '/../Application/Autoload/Loader.php';
Application\Autoload\Loader::init(__DIR__ . '/..');
use Application\Database\Connection;
use Application\Database\CustomerOrmService_1;

接下来,创建一个服务的实例,并使用随机 ID 查找一个客户:

$service = new CustomerOrmService_1(new Connection(include __DIR__ . DB_CONFIG_FILE));
$id   = rand(1,79);
$cust = $service->fetchByIdAndEmbedPurchases($id);

在视图逻辑中,您将通过fetchByIdAndEmbedPurchases()方法获取一个完全填充的Customer实体。现在,您只需要调用正确的 getter 来显示信息:

  <!-- Customer Info -->
  <h1><?= $cust->getname() ?></h1>
  <div class="row">
    <div class="left">Balance</div><div class="right">
      <?= $cust->getBalance(); ?></div>
  </div>
    <!-- etc. -->

然后,显示购买信息所需的逻辑将类似于以下 HTML。请注意,Customer::getPurchases()返回一个Purchase实体数组。要从Purchase实体获取产品信息,在循环内调用Purchase::getProduct(),这将生成一个Product实体。然后可以调用任何Product的 getter,在本例中为Product::getTitle()

  <!-- Purchases Info -->
  <table>
  <?php foreach ($cust->getPurchases() as $purchase) : ?>
  <tr>
  <td><?= $purchase->getTransaction() ?></td>
  <td><?= $purchase->getDate() ?></td>
  <td><?= $purchase->getQuantity() ?></td>
  <td><?= $purchase->getSalePrice() ?></td>
  <td><?= $purchase->getProduct()->getTitle() ?></td>
  </tr>
  <?php endforeach; ?>
</table>

将注意力转向使用辅助查找的第二种方法,定义一个名为chap_11_orm_secondary_lookups.php的调用程序,设置自动加载并使用适当的类:

<?php
define('DB_CONFIG_FILE', '/../config/db.config.php');
require __DIR__ . '/../Application/Autoload/Loader.php';
Application\Autoload\Loader::init(__DIR__ . '/..');
use Application\Database\Connection;
use Application\Database\CustomerOrmService_2;

接下来,创建一个服务的实例,并使用随机 ID 查找一个客户:

$service = new CustomerOrmService_2(new Connection(include __DIR__ . DB_CONFIG_FILE));
$id   = rand(1,79);

现在,您可以检索一个Application\Entity\Customer实例,并为该客户调用fetchPurchasesForCustomer(),这将嵌入一系列匿名函数:

$cust = $service->fetchById($id);
$cust = $service->fetchPurchasesForCustomer($cust);

显示核心客户信息的视图逻辑与之前描述的相同。然后,显示购买信息所需的逻辑看起来像以下 HTML 代码片段。请注意,Customer::getPurchases()返回一个匿名函数数组。每个函数调用返回一个特定的购买和相关产品:

<table>
  <?php foreach($cust->getPurchases() as $purchId => $function) : ?>
  <tr>
  <?php $purchase = $function($purchId, $service); ?>
  <td><?= $purchase->getTransaction() ?></td>
  <td><?= $purchase->getDate() ?></td>
  <td><?= $purchase->getQuantity() ?></td>
  <td><?= $purchase->getSalePrice() ?></td>
  <td><?= $purchase->getProduct()->getTitle() ?></td>
  </tr>
  <?php endforeach; ?>
</table>

以下是输出的示例:

它是如何工作的...

提示

最佳实践

尽管循环的每次迭代代表两个独立的数据库查询(一个用于购买,一个用于产品),但通过使用准备好的语句保留了效率。两个语句提前准备好:一个查找特定的购买,一个查找特定的产品。然后多次执行这些准备好的语句。此外,每个产品检索都独立存储在一个数组中,从而实现了更高的效率。

另请参阅

可能最好的实现对象关系映射的库的例子是 Doctrine。Doctrine 使用其文档称为代理的嵌入式方法。有关更多信息,请参阅www.doctrine-project.org/projects/orm.html

您可能还考虑观看来自 O'Reilly Media 的学习 Doctrine的培训视频,网址为shop.oreilly.com/product/0636920041382.do。(免责声明:这是本书和本视频的作者的厚颜无耻的宣传!)

实施发布/订阅设计模式

发布/订阅Pub/Sub)设计模式通常是软件事件驱动编程的基础。这种方法允许不同软件应用程序之间或单个应用程序中的不同软件模块之间进行异步通信。该模式的目的是允许方法或函数在发生重要动作时发布信号。然后一个或多个类将订阅并在特定信号被发布时采取行动。

这种行为的例子是当数据库被修改时,或者用户已登录时。此设计模式的另一个常见用途是应用程序提供新闻订阅。如果发布了紧急新闻项目,应用程序将发布此事实,允许客户端订阅者刷新其新闻列表。

如何做...

  1. 首先,我们定义我们的发布者类Application\PubSub\Publisher。您会注意到我们正在使用两个有用的标准 PHP 库SPL)接口,SplSubjectSplObserver
namespace Application\PubSub;
use SplSubject;
use SplObserver;
class Publisher implements SplSubject
{
  // code
}
  1. 接下来,我们添加属性来表示发布者名称,要传递给订阅者的数据以及订阅者的数组(也称为监听器)。您还会注意到我们将使用链表(在第十章中描述,查看高级算法)以允许优先级:
protected $name;
protected $data;
protected $linked;
protected $subscribers;
  1. 构造函数初始化这些属性。我们还加入了__toString(),以防我们需要快速访问此发布者的名称:
public function __construct($name)
{
  $this->name = $name;
  $this->data = array();
  $this->subscribers = array();
  $this->linked = array();
}

public function __toString()
{
  return $this->name;
}
  1. 为了将订阅者与此发布者关联,我们定义attach(),它在SplSubject接口中指定。我们接受一个SplObserver实例作为参数。请注意,我们需要向$subscribers$linked属性添加条目。然后使用arsort()按优先级对$linked进行排序,表示值排序,以维护键:
public function attach(SplObserver $subscriber)
{
  $this->subscribers[$subscriber->getKey()] = $subscriber;
  $this->linked[$subscriber->getKey()] = 
    $subscriber->getPriority();
  arsort($this->linked);
}
  1. 接口还要求我们定义detach(),它从列表中移除订阅者:
public function detach(SplObserver $subscriber)
{
  unset($this->subscribers[$subscriber->getKey()]);
  unset($this->linked[$subscriber->getKey()]);
}
  1. 接口还要求我们定义notify(),它调用所有订阅者的update()。请注意,我们循环遍历链接列表以确保按优先级顺序调用订阅者:
public function notify()
{
  foreach ($this->linked as $key => $value)
  {
    $this->subscribers[$key]->update($this);
  }
}
  1. 接下来,我们定义适当的 getter 和 setter。我们这里没有全部显示出来以节省空间:
public function getName()
{
  return $this->name;
}

public function setName($name)
{
  $this->name = $name;
}
  1. 最后,我们需要提供一种通过键设置数据项的方法,然后在调用notify()时这些数据项将对订阅者可用:
public function setDataByKey($key, $value)
{
  $this->data[$key] = $value;
}
  1. 现在我们可以看一下Application\PubSub\Subscriber。通常,我们会为每个发布者定义多个订阅者。在这种情况下,我们实现了SplObserver接口:
namespace Application\PubSub;
use SplSubject;
use SplObserver;
class Subscriber implements SplObserver
{
  // code
}
  1. 每个订阅者都需要一个唯一的标识符。在这种情况下,我们使用md5()和日期/时间信息以及随机数来创建密钥。构造函数初始化属性如下。订阅者执行的实际逻辑功能以回调的形式进行:
protected $key;
protected $name;
protected $priority;
protected $callback;
public function __construct(
  string $name, callable $callback, $priority = 0)
{
  $this->key = md5(date('YmdHis') . rand(0,9999));
  $this->name = $name;
  $this->callback = $callback;
  $this->priority = $priority;
}
  1. 当调用发布者的notify()时,将调用update()函数。我们将一个发布者实例作为参数传递,并调用为该订阅者定义的回调函数:
public function update(SplSubject $publisher)
{
  call_user_func($this->callback, $publisher);
}
  1. 我们还需要为方便起见定义 getter 和 setter。这里没有展示所有的内容:
public function getKey()
{
  return $this->key;
}

public function setKey($key)
{
  $this->key = $key;
}

// other getters and setters not shown

工作原理...

为了说明这一点,定义一个名为chap_11_pub_sub_simple_example.php的调用程序,设置自动加载并使用适当的类:

<?php
require __DIR__ . '/../Application/Autoload/Loader.php';
Application\Autoload\Loader::init(__DIR__ . '/..');
use Application\PubSub\ { Publisher, Subscriber };

接下来,创建一个发布者实例并分配数据:

$pub = new Publisher('test');
$pub->setDataByKey('1', 'AAA');
$pub->setDataByKey('2', 'BBB');
$pub->setDataByKey('3', 'CCC');
$pub->setDataByKey('4', 'DDD');

现在可以创建测试订阅者,从发布者那里读取数据并输出结果。第一个参数是名称,第二个是回调,最后一个是优先级:

$sub1 = new Subscriber(
  '1',
  function ($pub) {
    echo '1:' . $pub->getData()[1] . PHP_EOL;
  },
  10
);
$sub2 = new Subscriber(
  '2',
  function ($pub) {
    echo '2:' . $pub->getData()[2] . PHP_EOL;
  },
  20
);
$sub3 = new Subscriber(
  '3',
  function ($pub) {
    echo '3:' . $pub->getData()[3] . PHP_EOL;
  },
  99
);

为了测试目的,以不正确的顺序附加订阅者,并调用notify()两次:

$pub->attach($sub2);
$pub->attach($sub1);
$pub->attach($sub3);
$pub->notify();
$pub->notify();

接下来,定义并附加另一个订阅者,查看订阅者 1 的数据,如果不为空则退出:

$sub4 = new Subscriber(
  '4',
  function ($pub) {
    echo '4:' . $pub->getData()[4] . PHP_EOL;
    if (!empty($pub->getData()[1]))
      die('1 is set ... halting execution');
  },
  25
);
$pub->attach($sub4);
$pub->notify();

这是输出。请注意,输出按优先级顺序排列(优先级较高的排在前面),第二个输出块被中断:

工作原理...

还有更多...

一个密切相关的软件设计模式是Observer。机制类似,但普遍认为的区别是 Observer 以同步方式运行,当接收到信号(通常也称为消息或事件)时,会调用所有观察者方法。相比之下,Pub/Sub 模式以异步方式运行,通常使用消息队列。另一个区别是,在 Pub/Sub 模式中,发布者不需要知道订阅者。

参见

有关观察者和 Pub/Sub 模式之间的区别的讨论,请参阅stackoverflow.com/questions/15594905/difference-between-observer-pub-sub-and-data-binding上的文章。

第十二章:改进 Web 安全

在本章中,我们将涵盖以下主题:

  • 过滤$_POST数据

  • 验证$_POST数据

  • 保护 PHP 会话

  • 使用令牌保护表单

  • 构建一个安全的密码生成器

  • 使用 CAPTCHA 保护表单

  • 加密/解密而不使用mcrypt

介绍

在本章中,我们将向您展示如何建立一个简单而有效的机制,用于过滤和验证一块发布数据。然后,我们将介绍如何保护您的 PHP 会话免受潜在的会话劫持和其他形式的攻击。下一个配方将展示如何使用随机生成的令牌保护表单免受跨站点请求伪造CSRF)攻击。有关密码生成的配方将向您展示如何将 PHP 7 真正的随机化结合起来生成安全密码。然后,我们将向您展示两种形式的CAPTCHA:一种是基于文本的,另一种是使用扭曲图像的。最后,有一个配方涵盖了强加密,而不使用被废弃和即将被弃用的mcrypt扩展。

过滤$_POST数据

过滤数据的过程可以包括以下任何或所有内容:

  • 删除不需要的字符(即删除<script>标签)

  • 对数据执行转换(即将引号转换为&quot;

  • 加密或解密数据

加密在本章的最后一个配方中有所涵盖。否则,我们将介绍一个基本机制,可用于过滤表单提交后到达的$_POST数据。

如何做...

  1. 首先,您需要了解将出现在$_POST中的数据。而且,也许更重要的是,您需要了解表单数据将被存储的数据库表所施加的限制。例如,看一下prospects表的数据库结构:
COLUMN          TYPE              NULL   DEFAULT
first_name      varchar(128)      No     None     NULL
last_name       varchar(128)      No     None     NULL
address         varchar(256)      Yes    None     NULL
city            varchar(64)       Yes    None     NULL
state_province  varchar(32)       Yes    None     NULL
postal_code     char(16)          No     None     NULL
phone           varchar(16)       No     None     NULL
country         char(2)           No     None     NULL
email           varchar(250)      No     None     NULL
status          char(8)           Yes    None     NULL
budget          decimal(10,2)     Yes    None     NULL
last_updated    datetime          Yes    None     NULL
  1. 完成对要发布和存储的数据的分析后,可以确定要发生的过滤类型,以及哪些 PHP 函数将用于此目的。

  2. 例如,如果您需要摆脱用户提供的表单数据中的前导和尾随空格,这是完全可能的,您可以使用 PHP 的trim()函数。根据数据库结构,所有字符数据都有长度限制。因此,您可能需要考虑使用substr()来确保长度不超过。如果您想要删除非字母字符,您可能需要考虑使用preg_replace()与适当的模式。

  3. 现在,我们可以将所需的 PHP 函数集合成一个回调函数的单一数组。以下是一个基于表单数据过滤需求的示例,最终将存储在prospects表中:

$filter = [
  'trim' => function ($item) { return trim($item); },
  'float' => function ($item) { return (float) $item; },
  'upper' => function ($item) { return strtoupper($item); },
  'email' => function ($item) { 
     return filter_var($item, FILTER_SANITIZE_EMAIL); },
  'alpha' => function ($item) { 
     return preg_replace('/[^A-Za-z]/', '', $item); },
  'alnum' => function ($item) { 
     return preg_replace('/[⁰-9A-Za-z ]/', '', $item); },
  'length' => function ($item, $length) { 
     return substr($item, 0, $length); },
  'stripTags' => function ($item) { return strip_tags($item); },
];
  1. 接下来,我们定义一个与$_POST中预期的字段名称匹配的数组。在此数组中,我们指定$filter数组中的键,以及任何参数。请注意第一个键*。我们将使用它作为应用于所有字段的通配符:
$assignments = [
  '*'             => ['trim' => NULL, 'stripTags' => NULL],
  'first_name'    => ['length' => 32, 'alnum' => NULL],
  'last_name'     => ['length' => 32, 'alnum' => NULL],
  'address'       => ['length' => 64, 'alnum' => NULL],
  'city'          => ['length' => 32],
  'state_province'=> ['length' => 20],
  'postal_code'   => ['length' => 12, 'alnum' => NULL],
  'phone'         => ['length' => 12],
  'country'       => ['length' => 2, 'alpha' => NULL, 
                      'upper' => NULL],
  'email'         => ['length' => 128, 'email' => NULL],
  'budget'        => ['float' => NULL],
];
  1. 然后,我们循环遍历数据集(即来自$_POST)并依次应用回调。我们首先运行分配给通配符(*)键的所有回调。

注意

重要的是要实现通配符过滤器以避免冗余设置。在前面的示例中,我们希望应用代表 PHP 函数strip_tags()trim()的过滤器到每个项目。

  1. 接下来,我们运行分配给特定数据字段的所有回调。完成后,$data中的所有值都将被过滤:
foreach ($data as $field => $item) {
  foreach ($assignments['*'] as $key => $option) {
    $item = $filter$key;
  }
  foreach ($assignments[$field] as $key => $option) {
    $item = $filter$key;
  }
}

工作原理...

将步骤 4 到 6 中显示的代码放入一个名为chap_12_post_data_filtering_basic.php的文件中。您还需要定义一个数组来模拟$_POST中可能存在的数据。在这种情况下,您可以定义两个数组,一个包含数据,另一个包含数据:

$testData = [
  'goodData'   => [
    'first_name'    => 'Doug',
    'last_name'     => 'Bierer',
    'address'       => '123 Main Street',
    'city'          => 'San Francisco',
    'state_province'=> 'California',
    'postal_code'   => '94101',
    'phone'         => '+1 415-555-1212',
    'country'       => 'US',
    'email'         => 'doug@unlikelysource.com',
    'budget'        => '123.45',
  ],
  'badData' => [
    'first_name' => 'This+Name<script>bad tag</script>Valid!',
    'last_name' 	=> 'ThisLastNameIsWayTooLongAbcdefghijklmnopqrstuvwxyz0123456789Abcdefghijklmnopqrstuvwxyz0123456789Abcdefghijklmnopqrstuvwxyz0123456789Abcdefghijklmnopqrstuvwxyz0123456789',
    //'address' 	=> '',    // missing
    'city'      => 'ThisCityNameIsTooLong012345678901234567890123456789012345678901234567890123456789  ',
    //'state_province'=> '',    // missing
    'postal_code'     => '!"£$%^Non Alpha Chars',
    'phone'           => ' 12345 ',
    'country'         => '12345',
    'email'           => 'this.is@not@an.email',
    'budget'          => 'XXX',
  ]
];

最后,您需要循环遍历过滤器分配,呈现好的和坏的数据:

foreach ($testData as $data) {
  foreach ($data as $field => $item) {
    foreach ($assignments['*'] as $key => $option) {
      $item = $filter$key;
    }
    foreach ($assignments[$field] as $key => $option) {
      $item = $filter$key;
    }
    printf("%16s : %s\n", $field, $item);
  }
}

这是此示例的输出可能如何出现的:

工作原理...

请注意,名称已被截断,标签已被删除。 您还会注意到,尽管电子邮件地址已经被过滤,但它仍然不是一个有效的地址。 需要注意的是,为了正确处理数据,可能需要验证和过滤。

另请参阅

在第六章中,构建可扩展的网站,名为链接$_POST 过滤器的示例讨论了如何将此处介绍的基本过滤概念合并到全面的过滤器链接机制中。

验证$_POST 数据

过滤和验证之间的主要区别在于后者不会更改原始数据。 另一个区别在于意图。 验证的目的是确认数据是否符合根据客户需求建立的某些标准。

如何做...

  1. 我们将在这里介绍的基本验证机制与前面的示例相同。 与过滤一样,了解要验证的数据的性质,它如何符合客户的要求,以及它是否符合数据库强制执行的标准是至关重要的。 例如,如果在数据库中,列的最大宽度为 128,则验证回调可以使用strlen()来确认提交的数据的长度是否小于或等于 128 个字符。 同样,您可以使用ctype_alnum()来确认数据是否只包含适当的字母和数字。

  2. 验证的另一个考虑因素是提供适当的验证失败消息。 在某种意义上,验证过程也是一个确认过程,某人可能会审查验证以确认成功或失败。 如果验证失败,该人将需要知道失败的原因。

  3. 对于本示例,我们将再次专注于prospects表。 现在,我们可以将一组所需的 PHP 函数集合到一个回调函数的数组中。 以下是基于表单数据的验证需求的示例,这些数据最终将存储在prospects表中:

$validator = [
  'email' => [
    'callback' => function ($item) { 
      return filter_var($item, FILTER_VALIDATE_EMAIL); },
    'message'  => 'Invalid email address'],
  'alpha' => [
    'callback' => function ($item) { 
      return ctype_alpha(str_replace(' ', '', $item)); },
    'message'  => 'Data contains non-alpha characters'],
  'alnum' => [
    'callback' => function ($item) { 
      return ctype_alnum(str_replace(' ', '', $item)); },
    'message'  => 'Data contains characters which are '
       . 'not letters or numbers'],
  'digits' => [
    'callback' => function ($item) { 
      return preg_match('/[⁰-9.]/', $item); },
    'message'  => 'Data contains characters which '
      . 'are not numbers'],
  'length' => [
    'callback' => function ($item, $length) { 
      return strlen($item) <= $length; },
    'message'  => 'Item has too many characters'],
  'upper' => [
    'callback' => function ($item) { 
      return $item == strtoupper($item); },
    'message'  => 'Item is not upper case'],
  'phone' => [
    'callback' => function ($item) { 
      return preg_match('/[⁰-9() -+]/', $item); },
    'message'  => 'Item is not a valid phone number'],
];

注意

请注意,对于 alpha 和 alnum 回调,我们通过首先使用str_replace()来允许空格。 然后我们可以调用ctype_alpha()ctype_alnum(),这将确定是否存在任何不允许的字符。

  1. 接下来,我们定义一个分配数组,与$_POST中预期的字段名称匹配。 在此数组中,我们指定$validator数组中的键,以及任何参数:
$assignments = [
  'first_name'    => ['length' => 32, 'alpha' => NULL],
  'last_name'     => ['length' => 32, 'alpha' => NULL],
  'address'       => ['length' => 64, 'alnum' => NULL],
  'city'          => ['length' => 32, 'alnum' => NULL],
  'state_province'=> ['length' => 20, 'alpha' => NULL],
  'postal_code'   => ['length' => 12, 'alnum' => NULL],
  'phone'         => ['length' => 12, 'phone' => NULL],
  'country'       => ['length' => 2, 'alpha' => NULL, 
                      'upper' => NULL],
  'email'         => ['length' => 128, 'email' => NULL],
  'budget'        => ['digits' => NULL],
];
  1. 然后,我们使用嵌套的foreach()循环逐个字段地遍历数据块。 对于每个字段,我们循环遍历分配给该字段的回调函数:
foreach ($data as $field => $item) {
  echo 'Processing: ' . $field . PHP_EOL;
  foreach ($assignments[$field] as $key => $option) {
    if ($validator[$key]'callback') {
        $message = 'OK';
    } else {
        $message = $validator[$key]['message'];
    }
    printf('%8s : %s' . PHP_EOL, $key, $message);
  }
}

提示

而不是直接回显输出,如所示,您可以记录验证成功/失败的结果,以便在以后向审阅人呈现。 此外,如第六章中所示,构建可扩展的网站,您可以将验证机制集成到表单中,显示验证消息与其匹配的表单元素旁边。

工作原理...

将步骤 3 到 5 中显示的代码放入名为chap_12_post_data_validation_basic.php的文件中。 您还需要定义一个数据数组,模拟将出现在$_POST中的数据。 在这种情况下,您使用前面示例中提到的两个数组,一个包含数据,另一个包含数据。 最终输出应该看起来像这样:

工作原理...

另请参阅

  • 在第六章中,构建可扩展的网站,名为链接$_POST 验证器的示例讨论了如何将此处介绍的基本验证概念合并到全面的过滤器链接机制中。

保护 PHP 会话

PHP 会话机制非常简单。一旦使用session_start()php.ini session.autostart设置开始会话,PHP 引擎会生成一个唯一的令牌,默认情况下通过 cookie 传递给用户。在后续请求中,当会话仍然被视为活动状态时,用户的浏览器(或等效物)再次通常通过 cookie 呈现会话标识符进行检查。然后,PHP 引擎使用此标识符定位服务器上的适当文件,并使用存储的信息填充$_SESSION。当会话标识符是识别返回的网站访问者的唯一手段时,存在巨大的安全问题。在本教程中,我们将介绍几种技术,这些技术将帮助您保护会话,从而大大提高网站的整体安全性。

如何做...

  1. 首先,重要的是要认识到将会话作为唯一的身份验证手段可能是危险的。想象一下,当有效用户登录到您的网站时,您在$_SESSION中设置了一个loggedIn标志:
session_start();
$loggedIn = $_SESSION['isLoggedIn'] ?? FALSE;
if (isset($_POST['login'])) {
  if ($_POST['username'] == // username lookup
      && $_POST['password'] == // password lookup) {
      $loggedIn = TRUE;
      $_SESSION['isLoggedIn'] = TRUE;
  }
}
  1. 在您的程序逻辑中,如果$_SESSION['isLoggedIn']设置为TRUE,则允许用户查看敏感信息:
<br>Secret Info
<br><?php if ($loggedIn) echo // secret information; ?>
  1. 如果攻击者能够获取会话标识符,例如,通过成功执行的跨站脚本XSS)攻击,他/她只需要将PHPSESSID cookie 的值设置为非法获取的值,他们现在被您的应用程序视为有效用户。

  2. 缩小PHPSESSID有效时间窗口的一种快速简单方法是使用session_regenerate_id()。这个非常简单的命令生成一个新的会话标识符,使旧的会话标识符无效,保持会话数据完整,并对性能影响很小。此命令只能在会话开始后执行:

session_start();
session_regenerate_id();
  1. 另一个经常被忽视的技术是确保网站访问者有注销选项。然而,重要的是不仅使用session_destroy()销毁会话,还要取消$_SESSION数据并使会话 cookie 过期:
session_unset();
session_destroy();
setcookie('PHPSESSID', 0, time() - 3600);
  1. 另一种可以用来防止会话劫持的简单技术是开发网站访问者的指纹。实现这种技术的一种方法是收集与会话标识符不同的网站访问者的唯一信息。这些信息包括用户代理(即浏览器)、接受的语言和远程 IP 地址。您可以从这些信息中派生出一个简单的哈希,并将哈希存储在服务器上的一个单独文件中。下次用户访问网站时,如果您已经确定他们基于会话信息已登录,那么您可以通过匹配指纹进行二次验证:
$remotePrint = md5($_SERVER['REMOTE_ADDR'] 
                   . $_SERVER['HTTP_USER_AGENT'] 
                   . $_SERVER['HTTP_ACCEPT_LANGUAGE']);
$printsMatch = file_exists(THUMB_PRINT_DIR . $remotePrint);
if ($loggedIn && !$printsMatch) {
    $info = 'SESSION INVALID!!!';
    error_log('Session Invalid: ' . date('Y-m-d H:i:s'), 0);
    // take appropriate action
}

注意

我们使用md5()作为快速哈希算法,非常适合内部使用。不建议在任何外部使用中使用md5(),因为它容易受到暴力攻击。

它是如何工作的...

为了演示会话的漏洞性,编写一个简单的登录脚本,成功登录后设置$_SESSION['isLoggedIn'] flag。您可以将文件命名为chap_12_session_hijack.php

session_start();
$loggedUser = $_SESSION['loggedUser'] ?? '';
$loggedIn = $_SESSION['isLoggedIn'] ?? FALSE;
$username = 'test';
$password = 'password';
$info = 'You Can Now See Super Secret Information!!!';

if (isset($_POST['login'])) {
  if ($_POST['username'] == $username
      && $_POST['password'] == $password) {
        $loggedIn = TRUE;
        $_SESSION['isLoggedIn'] = TRUE;
        $_SESSION['loggedUser'] = $username;
        $loggedUser = $username;
  }
} elseif (isset($_POST['logout'])) {
  session_destroy();
}

然后,您可以添加显示简单登录表单的代码。要测试会话漏洞,请按照使用我们刚刚创建的chap_12_session_hijack.php文件的步骤:

  1. 切换到包含文件的目录。

  2. 运行php -S localhost:8080命令。

  3. 使用一个浏览器,打开 URL http://localhost:8080/<filename>

  4. 使用用户名test和密码password登录。

  5. 您应该能够看到您现在可以查看超级秘密信息!!!

  6. 刷新页面:每次都应该看到一个新的会话标识符。

  7. 复制PHPSESSID cookie 的值。

  8. 在同一个网页上用另一个浏览器打开。

  9. 通过复制PHPSESSID的值修改浏览器发送的 cookie。

为了说明,我们还在以下截图中使用 Vivaldi 浏览器显示了$_COOKIE$_SESSION的值:

它是如何工作的...

然后我们复制PHPSESSID的值,打开 Firefox 浏览器,并使用一个名为 Tamper Data 的工具来修改 cookie 的值:

它是如何工作的...

您可以在下一个截图中看到,我们现在是经过身份验证的用户,而无需输入用户名或密码:

它是如何工作的...

现在,您可以实施前面步骤中讨论的更改。将先前创建的文件复制到chap_12_session_protected.php。现在继续重新生成会话 ID:

<?php
define('THUMB_PRINT_DIR', __DIR__ . '/../data/');
session_start();
session_regenerate_id();

接下来,初始化变量并确定登录状态(与以前一样):

$username = 'test';
$password = 'password';
$info = 'You Can Now See Super Secret Information!!!';
$loggedIn = $_SESSION['isLoggedIn'] ?? FALSE;
$loggedUser = $_SESSION['user'] ?? 'guest';

您可以使用远程地址、用户代理和语言设置添加会话指纹:

$remotePrint = md5($_SERVER['REMOTE_ADDR']
  . $_SERVER['HTTP_USER_AGENT']
  . $_SERVER['HTTP_ACCEPT_LANGUAGE']);
$printsMatch = file_exists(THUMB_PRINT_DIR . $remotePrint);

如果登录成功,我们将会话中的指纹信息和登录状态存储起来:

if (isset($_POST['login'])) {
  if ($_POST['username'] == $username
      && $_POST['password'] == $password) {
        $loggedIn = TRUE;
        $_SESSION['user'] = strip_tags($username);
        $_SESSION['isLoggedIn'] = TRUE;
        file_put_contents(
          THUMB_PRINT_DIR . $remotePrint, $remotePrint);
  }

您还可以检查注销选项并实施适当的注销过程:取消设置$_SESSION变量,使会话无效,并使 cookie 过期。您还可以删除指纹文件并实施重定向:

} elseif (isset($_POST['logout'])) {
  session_unset();
  session_destroy();
  setcookie('PHPSESSID', 0, time() - 3600);
  if (file_exists(THUMB_PRINT_DIR . $remotePrint)) 
    unlink(THUMB_PRINT_DIR . $remotePrint);
    header('Location: ' . $_SERVER['REQUEST_URI'] );
  exit;

否则,如果操作不是登录或注销,您可以检查用户是否被视为已登录,如果指纹不匹配,则会话被视为无效,并采取适当的操作:

} elseif ($loggedIn && !$printsMatch) {
    $info = 'SESSION INVALID!!!';
    error_log('Session Invalid: ' . date('Y-m-d H:i:s'), 0);
    // take appropriate action
}

现在,您可以使用新的chap_12_session_protected.php文件运行与之前提到的相同的过程。您将首先注意到的是会话现在被视为无效。输出将类似于这样:

它是如何工作的...

原因是指纹不匹配,因为您现在正在使用不同的浏览器。同样,如果您刷新第一个浏览器的页面,会话标识符将被重新生成,使任何先前复制的标识符都将变得过时。最后,注销按钮将完全清除会话信息。

另请参阅

有关网站漏洞的出色概述,请参阅www.owasp.org/index.php/Category:Vulnerability上的文章。有关会话劫持的信息,请参阅www.owasp.org/index.php/Session_hijacking_attack

使用令牌保护表单

这个配方提供了另一种非常简单的技术,可以保护您的表单免受跨站点请求伪造CSRF)攻击。简而言之,当攻击者可能使用其他技术时,可以在您网站上的网页上感染一个 CSRF 攻击。在大多数情况下,受感染的页面将开始发出请求(即使用 JavaScript 购买物品或进行设置更改)使用有效的已登录用户的凭据。您的应用程序极其难以检测到这种活动。可以采取的一个措施是生成一个随机令牌,该令牌包含在要提交的每个表单中。由于受感染的页面将无法访问令牌,也无法生成与之匹配的令牌,因此表单验证将失败。

如何做...

  1. 首先,为了演示问题,我们创建一个模拟受感染页面的网页,该页面生成一个请求以将条目发布到数据库。为此示例,我们将文件命名为chap_12_form_csrf_test_unprotected.html
<!DOCTYPE html>
  <body onload="load()">
  <form action="/chap_12_form_unprotected.php" 
    method="post" id="csrf_test" name="csrf_test">
    <input name="name" type="hidden" value="No Goodnick" />
    <input name="email" type="hidden" value="malicious@owasp.org" />
    <input name="comments" type="hidden" 
       value="Form is vulnerable to CSRF attacks!" />
    <input name="process" type="hidden" value="1" />
  </form>
  <script>
    function load() { document.forms['csrf_test'].submit(); }
  </script>
</body>
</html>
  1. 接下来,我们创建一个名为chap_12_form_unprotected.php的脚本,用于响应表单提交。与本书中的其他调用程序一样,我们设置自动加载并使用第五章中介绍的Application\Database\Connection类,与数据库交互
<?php
define('DB_CONFIG_FILE', '/../config/db.config.php');
require __DIR__ . '/../Application/Autoload/Loader.php';
Application\Autoload\Loader::init(__DIR__ . '/..');
use Application\Database\Connection;
$conn = new Connection(include __DIR__ . DB_CONFIG_FILE);
  1. 然后我们检查处理按钮是否已被按下,并实施一个过滤机制,如本章中过滤$_POST 数据食谱中所述。这是为了证明 CSRF 攻击很容易绕过过滤器:
if ($_POST['process']) {
    $filter = [
      'trim' => function ($item) { return trim($item); },
      'email' => function ($item) { 
        return filter_var($item, FILTER_SANITIZE_EMAIL); },
      'length' => function ($item, $length) { 
        return substr($item, 0, $length); },
      'stripTags' => function ($item) { 
      return strip_tags($item); },
  ];

  $assignments = [
    '*'         => ['trim' => NULL, 'stripTags' => NULL],
    'email'   => ['length' => 249, 'email' => NULL],
    'name'    => ['length' => 128],
    'comments'=> ['length' => 249],
  ];

  $data = $_POST;
  foreach ($data as $field => $item) {
    foreach ($assignments['*'] as $key => $option) {
      $item = $filter$key;
    }
    if (isset($assignments[$field])) {
      foreach ($assignments[$field] as $key => $option) {
        $item = $filter$key;
      }
      $filteredData[$field] = $item;
    }
  }
  1. 最后,我们使用预处理语句将过滤后的数据插入数据库。然后重定向到另一个名为chap_12_form_view_results.php的脚本,该脚本简单地转储visitors表的内容:
try {
    $filteredData['visit_date'] = date('Y-m-d H:i:s');
    $sql = 'INSERT INTO visitors '
        . ' (email,name,comments,visit_date) '
        . 'VALUES (:email,:name,:comments,:visit_date)';
    $insertStmt = $conn->pdo->prepare($sql);
    $insertStmt->execute($filteredData);
} catch (PDOException $e) {
    echo $e->getMessage();
}
}
header('Location: /chap_12_form_view_results.php');
exit;
  1. 当然,结果是,尽管进行了过滤并使用了预处理语句,攻击仍然被允许。

  2. 实际上,实现表单保护令牌非常容易!首先,您需要生成令牌并将其存储在会话中。我们利用新的random_bytes() PHP 7 函数来生成一个真正随机的令牌,这个令牌对于攻击者来说将是困难的,如果不是不可能的匹配:

session_start();
$token = urlencode(base64_encode((random_bytes(32))));
$_SESSION['token'] = $token;

注意

random_bytes()的输出是二进制的。我们使用base64_encode()将其转换为可用的字符串。然后我们使用urlencode()进一步处理它,以便在 HTML 表单中正确呈现。

  1. 当我们呈现表单时,我们将令牌呈现为隐藏字段:
<input type="hidden" name="token" value="<?= $token ?>" />
  1. 然后,我们复制并修改先前提到的chap_12_form_unprotected.php脚本,添加逻辑来首先检查会话中存储的令牌是否匹配。请注意,我们取消当前令牌以使其对将来的使用无效。我们将新脚本命名为chap_12_form_protected_with_token.php
if ($_POST['process']) {
    $sessToken = $_SESSION['token'] ?? 1;
    $postToken = $_POST['token'] ?? 2;
    unset($_SESSION['token']);
    if ($sessToken != $postToken) {
        $_SESSION['message'] = 'ERROR: token mismatch';
    } else {
        $_SESSION['message'] = 'SUCCESS: form processed';
        // continue with form processing
    }
}

它是如何工作的...

为了测试感染的网页如何发起 CSRF 攻击,创建以下文件,如前面的示例所示:

  • chap_12_form_csrf_test_unprotected.html

  • chap_12_form_unprotected.php

然后,您可以定义一个名为chap_12_form_view_results.php的文件,其中转储visitors表的内容:

<?php
session_start();
define('DB_CONFIG_FILE', '/../config/db.config.php');
require __DIR__ . '/../Application/Autoload/Loader.php';
Application\Autoload\Loader::init(__DIR__ . '/..');
use Application\Database\Connection;
$conn = new Connection(include __DIR__ . DB_CONFIG_FILE);
$message = $_SESSION['message'] ?? '';
unset($_SESSION['message']);
$stmt = $conn->pdo->query('SELECT * FROM visitors');
?>
<!DOCTYPE html>
<body>
<div class="container">
  <h1>CSRF Protection</h1>
  <h3>Visitors Table</h3>
  <?php while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) : ?>
  <pre><?php echo implode(':', $row); ?></pre>
  <?php endwhile; ?>
  <?php if ($message) : ?>
  <b><?= $message; ?></b>
  <?php endif; ?>
</div>
</body>
</html>

从浏览器中启动chap_12_form_csrf_test_unprotected.html。以下是输出可能会出现的样子:

它是如何工作的...

正如您所看到的,尽管进行了过滤并使用了预处理语句,攻击仍然成功!

接下来,将chap_12_form_unprotected.php文件复制到chap_12_form_protected.php。按照食谱中的第 8 步所示进行更改。您还需要修改测试 HTML 文件,将chap_12_form_csrf_test_unprotected.html复制到chap_12_form_csrf_test_protected.html。将FORM标签中的 action 参数的值更改如下:

<form action="/chap_12_form_protected_with_token.php" 
  method="post" id="csrf_test" name="csrf_test">

当您从浏览器运行新的 HTML 文件时,它会调用chap_12_form_protected.php,该文件寻找一个不存在的令牌。以下是预期的输出:

它是如何工作的...

最后,继续定义一个名为chap_12_form_protected.php的文件,生成一个令牌并将其显示为隐藏元素:

<?php
session_start();
$token = urlencode(base64_encode((random_bytes(32))));
$_SESSION['token'] = $token;
?>
<!DOCTYPE html>
<body onload="load()">
<div class="container">
<h1>CSRF Protected Form</h1>
<form action="/chap_12_form_protected_with_token.php" 
     method="post" id="csrf_test" name="csrf_test">
<table>
<tr><th>Name</th><td><input name="name" type="text" /></td></tr>
<tr><th>Email</th><td><input name="email" type="text" /></td></tr>
<tr><th>Comments</th><td>
<input name="comments" type="textarea" rows=4 cols=80 />
</td></tr>
<tr><th>&nbsp;</th><td>
<input name="process" type="submit" value="Process" />
</td></tr>
</table>
<input type="hidden" name="token" value="<?= $token ?>" />
</form>
<a href="/chap_12_form_view_results.php">
    CLICK HERE</a> to view results
</div>
</body>
</html>

当我们显示并提交表单中的数据时,将验证令牌并允许数据插入继续进行,如下所示:

它是如何工作的...

另请参阅

有关 CSFR 攻击的更多信息,请参阅www.owasp.org/index.php/Cross-Site_Request_Forgery_(CSRF)

构建一个安全的密码生成器

一个常见的误解是,攻击者破解哈希密码的唯一方法是使用暴力攻击彩虹表。虽然这通常是攻击序列中的第一步,但攻击者会在第二、第三或第四步使用更复杂的攻击。其他攻击包括组合字典掩码和基于规则的攻击。字典攻击使用来自字典的单词数据库来猜测密码。组合是指组合字典中的单词。掩码攻击类似于暴力攻击,但更有选择性,因此缩短了破解时间。基于规则的攻击将检测诸如用数字 0 替换字母 o 之类的事情。

好消息是,通过简单地增加密码长度超过六个字符的长度,可以指数级增加破解哈希密码所需的时间。其他因素,例如随机将大写字母与小写字母交错、随机数字和特殊字符,也会对破解所需的时间产生指数影响。最终,我们需要牢记的是,最终需要有人输入创建的密码,这意味着密码至少需要稍微记忆。

提示

最佳实践

密码应该以哈希形式存储,而不是明文。MD5 和 SHA不再被认为是安全的(尽管 SHA比 MD5 好得多)。使用诸如oclHashcat之类的实用程序,攻击者可以在密码使用 MD5 哈希后通过漏洞(即成功的 SQL 注入攻击)生成平均每秒 55 亿次尝试。

如何做...

  1. 首先,我们定义一个Application\Security\PassGen类,该类将包含生成密码所需的方法。我们还定义了一些类常量和属性,这些常量和属性将作为流程的一部分使用:
namespace Application\Security;
class PassGen
{
  const SOURCE_SUFFIX = 'src';
  const SPECIAL_CHARS = 
    '\`¬|!"£$%^&*()_-+={}[]:@~;\'#<>?,./|\\';
  protected $algorithm;
  protected $sourceList;
  protected $word;
  protected $list;
  1. 然后我们定义将用于生成密码的低级方法。正如名称所示,digits()生成随机数字,special()SPECIAL_CHARS类常量中生成一个字符:
public function digits($max = 999)
{
  return random_int(1, $max);
}

public function special()
{
  $maxSpecial = strlen(self::SPECIAL_CHARS) - 1;
  return self::SPECIAL_CHARS[random_int(0, $maxSpecial)];
}

注意

请注意,在此示例中,我们经常使用新的 PHP 7 函数random_int()。尽管稍慢,但与更陈旧的rand()函数相比,这种方法提供了真正的密码安全伪随机数生成器CSPRNG)功能。

  1. 现在是棘手的部分:生成一个难以猜测的单词。这就是$wordSource构造函数参数发挥作用的地方。它是一个网站数组,我们的单词库将从中派生。因此,我们需要一个方法,该方法将从指定的来源中提取一个唯一的单词列表,并将结果存储在文件中。我们将$wordSource数组作为参数接受,并循环遍历每个 URL。我们使用md5()生成网站名称的哈希值,然后将其构建成文件名。然后将新生成的文件名存储在$sourceList中:
public function processSource(
$wordSource, $minWordLength, $cacheDir)
{
  foreach ($wordSource as $html) {
    $hashKey = md5($html);
    $sourceFile = $cacheDir . '/' . $hashKey . '.' 
    . self::SOURCE_SUFFIX;
    $this->sourceList[] = $sourceFile;
  1. 如果文件不存在或为空字节,我们处理内容。如果来源是 HTML,我们只接受<body>标签内的内容。然后我们使用str_word_count()从字符串中提取单词列表,同时使用strip_tags()去除任何标记:
if (!file_exists($sourceFile) || filesize($sourceFile) == 0) {
    echo 'Processing: ' . $html . PHP_EOL;
    $contents = file_get_contents($html);
    if (preg_match('/<body>(.*)<\/body>/i', 
        $contents, $matches)) {
        $contents = $matches[1];
    }
    $list = str_word_count(strip_tags($contents), 1);
  1. 然后我们删除任何太短的单词,并使用array_unique()去除重复项。最终结果存储在文件中:
     foreach ($list as $key => $value) {
       if (strlen($value) < $minWordLength) {
         $list[$key] = 'xxxxxx';
       } else {
         $list[$key] = trim($value);
       }
     }
     $list = array_unique($list);
     file_put_contents($sourceFile, implode("\n",$list));
   }
  }
  return TRUE;
}
  1. 接下来,我们定义一个翻转单词中随机字母为大写的方法:
public function flipUpper($word)
{
  $maxLen   = strlen($word);
  $numFlips = random_int(1, $maxLen - 1);
  $flipped  = strtolower($word);
  for ($x = 0; $x < $numFlips; $x++) {
       $pos = random_int(0, $maxLen - 1);
       $word[$pos] = strtoupper($word[$pos]);
  }
  return $word;
}
  1. 最后,我们准备定义一个从我们的来源选择单词的方法。我们随机选择一个单词来源,并使用file()函数从适当的缓存文件中读取:
public function word()
{
  $wsKey    = random_int(0, count($this->sourceList) - 1);
  $list     = file($this->sourceList[$wsKey]);
  $maxList  = count($list) - 1;
  $key      = random_int(0, $maxList);
  $word     = $list[$key];
  return $this->flipUpper($word);
}
  1. 为了不总是生成相同模式的密码,我们定义了一个方法,允许我们将密码的各个组件放置在最终密码字符串的不同位置。算法被定义为此类中可用的方法调用数组。例如,一个['word', 'digits', 'word', 'special']的算法最终可能看起来像hElLo123aUTo!
public function initAlgorithm()
{
  $this->algorithm = [
    ['word', 'digits', 'word', 'special'],
    ['digits', 'word', 'special', 'word'],
    ['word', 'word', 'special', 'digits'],
    ['special', 'word', 'special', 'digits'],
    ['word', 'special', 'digits', 'word', 'special'],
    ['special', 'word', 'special', 'digits', 
    'special', 'word', 'special'],
  ];
}
  1. 构造函数接受单词来源数组、最小单词长度和缓存目录的位置。然后处理源文件并初始化算法:
public function __construct(
  array $wordSource, $minWordLength, $cacheDir)
{
  $this->processSource($wordSource, $minWordLength, $cacheDir);
  $this->initAlgorithm();
}
  1. 最后,我们能够定义实际生成密码的方法。它只需要随机选择一个算法,然后循环调用适当的方法:
public function generate()
{
  $pwd = '';
  $key = random_int(0, count($this->algorithm) - 1);
  foreach ($this->algorithm[$key] as $method) {
    $pwd .= $this->$method();
  }
  return str_replace("\n", '', $pwd);
}

}

工作原理...

首先,您需要将前面一篇文章中描述的代码放入Application\Security文件夹中名为PassGen.php的文件中。现在您可以创建一个名为chap_12_password_generate.php的调用程序,设置自动加载,使用PassGen,并定义缓存目录的位置:

<?php
define('CACHE_DIR', __DIR__ . '/cache');
require __DIR__ . '/../Application/Autoload/Loader.php';
Application\Autoload\Loader::init(__DIR__ . '/..');
use Application\Security\PassGen;

接下来,您需要定义一个网站数组,用作密码生成中的单词库的来源。在这个例子中,我们将从 Project Gutenberg 的文本《尤利西斯》(J.乔伊斯)、《战争与和平》(L.托尔斯泰)和《傲慢与偏见》(J.奥斯汀)中进行选择:

$source = [
  'https://www.gutenberg.org/files/4300/4300-0.txt',
  'https://www.gutenberg.org/files/2600/2600-h/2600-h.htm',
  'https://www.gutenberg.org/files/1342/1342-h/1342-h.htm',
];

接下来,我们创建PassGen实例,并运行generate()

$passGen = new PassGen($source, 4, CACHE_DIR);
echo $passGen->generate();

以下是PassGen生成的一些示例密码:

它是如何工作的...

另请参阅

关于攻击者如何破解密码的优秀文章可以在arstechnica.com/security/2013/05/how-crackers-make-minced-meat-out-of-your-passwords/上查看。要了解更多关于暴力破解攻击的信息,您可以参考www.owasp.org/index.php/Brute_force_attack。有关oclHashcat的信息,请参阅此页面:hashcat.net/oclhashcat/

使用 CAPTCHA 保护表单

CAPTCHA实际上是Completely Automated Public Turing Test to Tell Computers and Humans Apart的缩写。这种技术类似于前面的配方“使用令牌保护表单”。不同之处在于,它不是将令牌存储在隐藏的表单输入字段中,而是将令牌呈现为对自动攻击系统难以解密的图形。此外,CAPTCHA 的目的与表单令牌略有不同:它旨在确认网页访问者是人类,而不是自动系统。

操作步骤...

  1. CAPTCHA 有几种方法:基于只有人类才会知道的知识提出问题,文本技巧和需要解释的图形图像。

  2. 图像方法向网页访问者展示了一个带有严重扭曲的字母和/或数字的图像。然而,这种方法可能会很复杂,因为它依赖于 GD 扩展,而并非所有服务器都可用。GD 扩展可能很难编译,并且对主机服务器上必须存在的各种库有很重的依赖。

  3. 文本方法是呈现一系列字母和/或数字,并给网页访问者一个简单的指令,比如“请将这个倒过来输入”。另一种变化是使用 ASCII“艺术”来形成人类网页访问者能够解释的字符。

  4. 最后,您可能会有一个问题/答案方法,例如“头部是由什么身体部位连接到身体上”,并且有答案如“手臂”、“腿”和“脖子”。这种方法的缺点是自动攻击系统有三分之一的机会通过测试。

生成文本 CAPTCHA

  1. 在这个例子中,我们将从文本方法开始,然后再使用图像方法。无论哪种情况,我们首先需要定义一个生成要呈现的短语(并由网页访问者解码)的类。为此,我们定义一个Application\Captcha\Phrase类。我们还定义了在短语生成过程中使用的属性和类常量:
namespace Application\Captcha;
class Phrase
{
  const DEFAULT_LENGTH   = 5;
  const DEFAULT_NUMBERS  = '0123456789';
  const DEFAULT_UPPER    = 'ABCDEFGHJKLMNOPQRSTUVWXYZ';
  const DEFAULT_LOWER    = 'abcdefghijklmnopqrstuvwxyz';
  const DEFAULT_SPECIAL  = 
    '¬\`|!"£$%^&*()_-+={}[]:;@\'~#<,>.?/|\\';
  const DEFAULT_SUPPRESS = ['O','l'];

  protected $phrase;
  protected $includeNumbers;
  protected $includeUpper;
  protected $includeLower;
  protected $includeSpecial;
  protected $otherChars;
  protected $suppressChars;
  protected $string;
  protected $length;
  1. 构造函数如您所期望的那样,接受各种属性的值,分配默认值,以便可以创建一个实例而无需指定任何参数。$include*标志用于表示将在生成短语的基本字符串中存在哪些字符集。例如,如果您只希望有数字,则$includeUpper$includeLower都将设置为FALSE$otherChars提供了额外的灵活性。最后,$suppressChars表示将从基本字符串中删除的字符数组。默认情况下,删除大写字母O和小写字母l
public function __construct(
  $length = NULL,
  $includeNumbers = TRUE,
  $includeUpper= TRUE,
  $includeLower= TRUE,
  $includeSpecial = FALSE,
  $otherChars = NULL,
  array $suppressChars = NULL)
  {
    $this->length = $length ?? self::DEFAULT_LENGTH;
    $this->includeNumbers = $includeNumbers;
    $this->includeUpper = $includeUpper;
    $this->includeLower = $includeLower;
    $this->includeSpecial = $includeSpecial;
    $this->otherChars = $otherChars;
    $this->suppressChars = $suppressChars 
      ?? self::DEFAULT_SUPPRESS;
    $this->phrase = $this->generatePhrase();
  }
  1. 然后,我们定义一系列的 getter 和 setter,每个属性都有一个。请注意,为了节省空间,我们只显示前两个。
public function getString()
{
  return $this->string;
}

public function setString($string)
{
  $this->string = $string;
}

// other getters and setters not shown
  1. 接下来,我们需要定义一个初始化基本字符串的方法。这由一系列简单的 if 语句组成,检查各种$include*标志并根据需要附加到基本字符串。最后,我们使用str_replace()来删除$suppressChars中表示的字符:
public function initString()
{
  $string = '';
  if ($this->includeNumbers) {
      $string .= self::DEFAULT_NUMBERS;
  }
  if ($this->includeUpper) {
      $string .= self::DEFAULT_UPPER;
  }
  if ($this->includeLower) {
      $string .= self::DEFAULT_LOWER;
  }
  if ($this->includeSpecial) {
      $string .= self::DEFAULT_SPECIAL;
  }
  if ($this->otherChars) {
      $string .= $this->otherChars;
  }
  if ($this->suppressChars) {
      $string = str_replace(
        $this->suppressChars, '', $string);
  }
  return $string;
}

提示

最佳实践

摆脱可能与数字混淆的字母(即,字母O可能与数字0混淆,小写字母l可能与数字1混淆。

  1. 现在我们准备定义生成随机短语的核心方法,这是验证码呈现给网站访问者的。我们设置一个简单的for()循环,并使用新的 PHP 7 random_int()函数在基本字符串中跳转:
public function generatePhrase()
{
  $phrase = '';
  $this->string = $this->initString();
  $max = strlen($this->string) - 1;
  for ($x = 0; $x < $this->length; $x++) {
    $phrase .= substr(
      $this->string, random_int(0, $max), 1);
  }
  return $phrase;
}
}
  1. 现在我们将注意力从短语转移到将生成文本验证码的类上。为此,我们首先定义一个接口,以便将来可以创建额外的验证码类,所有这些类都使用Application\Captcha\Phrase。请注意,getImage()将返回文本、文本艺术或实际图像,具体取决于我们决定使用哪个类:
namespace Application\Captcha;
interface CaptchaInterface
{
  public function getLabel();
  public function getImage();
  public function getPhrase();
}
  1. 对于文本验证码,我们定义了一个Application\Captcha\Reverse类。这个名字的原因是这个类不仅产生文本,而且是反向的文本。__construct()方法构建了一个Phrase的实例。请注意,getImage()以反向返回短语:
namespace Application\Captcha;
class Reverse implements CaptchaInterface
{
  const DEFAULT_LABEL = 'Type this in reverse';
  const DEFAULT_LENGTH = 6;
  protected $phrase;
  public function __construct(
    $label  = self::DEFAULT_LABEL,
    $length = self:: DEFAULT_LENGTH,
    $includeNumbers = TRUE,
    $includeUpper   = TRUE,
    $includeLower   = TRUE,
    $includeSpecial = FALSE,
    $otherChars     = NULL,
    array $suppressChars = NULL)
  {
    $this->label  = $label;
    $this->phrase = new Phrase(
      $length, 
      $includeNumbers, 
      $includeUpper,
      $includeLower, 
      $includeSpecial, 
      $otherChars, 
      $suppressChars);
    }

  public function getLabel()
  {
    return $this->label;
  }

  public function getImage()
  {
    return strrev($this->phrase->getPhrase());
  }

  public function getPhrase()
  {
    return $this->phrase->getPhrase();
  }

}

生成图像验证码

  1. 正如你可以想象的那样,图像方法要复杂得多。短语生成过程是相同的。主要区别在于,我们不仅需要在图形上印刷短语,还需要以不同的方式扭曲每个字母,并引入随机点的噪音。

  2. 我们定义了一个实现CaptchaInterfaceApplication\Captcha\Image类。该类的常量和属性不仅包括短语生成所需的内容,还包括图像生成所需的内容:

namespace Application\Captcha;
use DirectoryIterator;
class Image implements CaptchaInterface
{

  const DEFAULT_WIDTH = 200;
  const DEFAULT_HEIGHT = 50;
  const DEFAULT_LABEL = 'Enter this phrase';
  const DEFAULT_BG_COLOR = [255,255,255];
  const DEFAULT_URL = '/captcha';
  const IMAGE_PREFIX = 'CAPTCHA_';
  const IMAGE_SUFFIX = '.jpg';
  const IMAGE_EXP_TIME = 300;    // seconds
  const ERROR_REQUIRES_GD = 'Requires the GD extension + '
    .  ' the JPEG library';
  const ERROR_IMAGE = 'Unable to generate image';

  protected $phrase;
  protected $imageFn;
  protected $label;
  protected $imageWidth;
  protected $imageHeight;
  protected $imageRGB;
  protected $imageDir;
  protected $imageUrl;
  1. 构造函数需要接受前面步骤中描述的短语生成所需的所有参数。此外,我们还需要接受图像生成所需的参数。两个必需参数是$imageDir$imageUrl。第一个是图形将被写入的位置。第二个是基本 URL,之后我们将附加生成的文件名。如果我们想提供 TrueType 字体,可以提供$imageFont,这将产生更安全的验证码。否则,我们只能使用默认字体,引用一部著名电影中的一句台词,不是一道美丽的风景
public function __construct(
  $imageDir,
  $imageUrl,
  $imageFont = NULL,
  $label = NULL,
  $length = NULL,
  $includeNumbers = TRUE,
  $includeUpper= TRUE,
  $includeLower= TRUE,
  $includeSpecial = FALSE,
  $otherChars = NULL,
  array $suppressChars = NULL,
  $imageWidth = NULL,
  $imageHeight = NULL,
  array $imageRGB = NULL
)
{
  1. 接下来,在构造函数中,我们检查imagecreatetruecolor函数是否存在。如果返回FALSE,我们知道 GD 扩展不可用。否则,我们将参数分配给属性,生成短语,删除旧图像,并写出验证码图形:
if (!function_exists('imagecreatetruecolor')) {
    throw new \Exception(self::ERROR_REQUIRES_GD);
}
$this->imageDir   = $imageDir;
$this->imageUrl   = $imageUrl;
$this->imageFont  = $imageFont;
$this->label      = $label ?? self::DEFAULT_LABEL;
$this->imageRGB   = $imageRGB ?? self::DEFAULT_BG_COLOR;
$this->imageWidth = $imageWidth ?? self::DEFAULT_WIDTH;
$this->imageHeight= $imageHeight ?? self::DEFAULT_HEIGHT;
if (substr($imageUrl, -1, 1) == '/') {
    $imageUrl = substr($imageUrl, 0, -1);
}
$this->imageUrl = $imageUrl;
if (substr($imageDir, -1, 1) == DIRECTORY_SEPARATOR) {
    $imageDir = substr($imageDir, 0, -1);
}

$this->phrase = new Phrase(
  $length, 
  $includeNumbers, 
  $includeUpper,
  $includeLower, 
  $includeSpecial, 
  $otherChars, 
  $suppressChars);
$this->removeOldImages();
$this->generateJpg();
}
  1. 删除旧图像的过程非常重要;否则我们最终会得到一个充满过期验证码图像的目录!我们使用DirectoryIterator类来扫描指定目录并检查访问时间。我们将旧图像文件定义为当前时间减去IMAGE_EXP_TIME指定值的文件:
public function removeOldImages()
{
  $old = time() - self::IMAGE_EXP_TIME;
  foreach (new DirectoryIterator($this->imageDir) 
           as $fileInfo) {
    if($fileInfo->isDot()) continue;
    if ($fileInfo->getATime() < $old) {
      unlink($this->imageDir . DIRECTORY_SEPARATOR 
             . $fileInfo->getFilename());
    }
  }
}
  1. 现在我们准备转向主要内容。首先,我们将$imageRGB数组分成$red$green$blue。我们使用核心的imagecreatetruecolor()函数生成指定宽度和高度的基本图形。我们使用 RGB 值对背景进行着色:
public function generateJpg()
{
  try {
      list($red,$green,$blue) = $this->imageRGB;
      $im = imagecreatetruecolor(
        $this->imageWidth, $this->imageHeight);
      $black = imagecolorallocate($im, 0, 0, 0);
      $imageBgColor = imagecolorallocate(
        $im, $red, $green, $blue);
      imagefilledrectangle($im, 0, 0, $this->imageWidth, 
        $this->imageHeight, $imageBgColor);
  1. 接下来,我们根据图像宽度和高度定义xy边距。然后,我们初始化要用于将短语写入图形的变量。然后我们循环多次,次数与短语的长度相匹配:
$xMargin = (int) ($this->imageWidth * .1 + .5);
$yMargin = (int) ($this->imageHeight * .3 + .5);
$phrase = $this->getPhrase();
$max = strlen($phrase);
$count = 0;
$x = $xMargin;
$size = 5;
for ($i = 0; $i < $max; $i++) {
  1. 如果指定了$imageFont,我们可以使用不同的大小和角度写入每个字符。我们还需要根据大小调整x轴(即水平)的值:
if ($this->imageFont) {
    $size = rand(12, 32);
    $angle = rand(0, 30);
    $y = rand($yMargin + $size, $this->imageHeight);
    imagettftext($im, $size, $angle, $x, $y, $black, 
      $this->imageFont, $phrase[$i]);
    $x += (int) ($size  + rand(0,5));
  1. 否则,我们将被默认字体所困扰。我们使用最大尺寸的5,因为较小的尺寸是不可读的。我们通过交替使用imagechar()(正常写入图像)和imagecharup()(侧向写入)来提供低级别的扭曲:
} else {
    $y = rand(0, ($this->imageHeight - $yMargin));
    if ($count++ & 1) {
        imagechar($im, 5, $x, $y, $phrase[$i], $black);
    } else {
        imagecharup($im, 5, $x, $y, $phrase[$i], $black);
    }
    $x += (int) ($size * 1.2);
  }
} // end for ($i = 0; $i < $max; $i++)
  1. 接下来,我们需要添加随机点的噪音。这是必要的,以使图像对自动化系统更难以检测。建议您也添加代码来绘制一些线条:
$numDots = rand(10, 999);
for ($i = 0; $i < $numDots; $i++) {
  imagesetpixel($im, rand(0, $this->imageWidth), 
    rand(0, $this->imageHeight), $black);
}
  1. 然后,我们使用我们的老朋友md5()创建一个随机图像文件名,其中日期和从09999的随机数作为参数。请注意,我们可以安全地使用md5(),因为我们并不试图隐藏任何秘密信息;我们只是想快速生成一个唯一的文件名。我们也清除图像对象以节省内存:
$this->imageFn = self::IMAGE_PREFIX 
. md5(date('YmdHis') . rand(0,9999)) 
. self::IMAGE_SUFFIX;
imagejpeg($im, $this->imageDir . DIRECTORY_SEPARATOR 
. $this->imageFn);
imagedestroy($im);
  1. 整个结构都在一个try/catch块中。如果发生错误或异常,我们会记录消息并采取适当的措施:
} catch (\Throwable $e) {
    error_log(__METHOD__ . ':' . $e->getMessage());
    throw new \Exception(self::ERROR_IMAGE);
}
}
  1. 最后,我们定义接口所需的方法。请注意,getImage()返回一个 HTML <img>标签,然后可以立即显示:
public function getLabel()
{
  return $this->label;
}

public function getImage()
{
  return sprintf('<img src="%s/%s" />', 
    $this->imageUrl, $this->imageFn);
}

public function getPhrase()
{
  return $this->phrase->getPhrase();
}

}

它是如何工作的...

确保定义本配方中讨论的类,总结如下表:

子节 出现的步骤
Application\Captcha\Phrase 生成文本 CAPTCHA 1 - 5
Application\Captcha\CaptchaInterface 6
Application\Captcha\Reverse 7
Application\Captcha\Image 生成图像 CAPTCHA 2 - 13

接下来,定义一个名为chap_12_captcha_text.php的调用程序,实现文本 CAPTCHA。您首先需要设置自动加载并使用适当的类:

<?php
require __DIR__ . '/../Application/Autoload/Loader.php';
Application\Autoload\Loader::init(__DIR__ . '/..');
use Application\Captcha\Reverse;

之后,请确保启动会话。您还应该采取适当的措施来保护会话。为了节省空间,我们只显示了一个简单的措施,session_regenerate_id()

session_start();
session_regenerate_id();

接下来,您可以定义一个函数,该函数创建 CAPTCHA;检索短语、标签和图像(在本例中为反向文本);并将该值存储在会话中:

function setCaptcha(&$phrase, &$label, &$image)
{
  $captcha = new Reverse();
  $phrase  = $captcha->getPhrase();
  $label   = $captcha->getLabel();
  $image   = $captcha->getImage();
  $_SESSION['phrase'] = $phrase;
}

现在是初始化变量并确定loggedIn状态的好时机:

$image      = '';
$label      = '';
$phrase     = $_SESSION['phrase'] ?? '';
$message    = '';
$info       = 'You Can Now See Super Secret Information!!!';
$loggedIn   = $_SESSION['isLoggedIn'] ?? FALSE;
$loggedUser = $_SESSION['user'] ?? 'guest';

然后您可以检查登录按钮是否已被按下。如果是,则检查 CAPTCHA 短语是否已输入。如果没有,初始化一条消息,告知用户他们需要输入 CAPTCHA 短语:

if (!empty($_POST['login'])) {
  if (empty($_POST['captcha'])) {
    $message = 'Enter Captcha Phrase and Login Information';

如果 CAPTCHA 短语存在,请检查它是否与会话中存储的内容匹配。如果不匹配,请继续处理表单无效。否则,处理登录与以往一样。为了说明这一点,您可以使用用户名和密码的硬编码值模拟登录:

} else {
    if ($_POST['captcha'] == $phrase) {
        $username = 'test';
        $password = 'password';
        if ($_POST['user'] == $username 
            && $_POST['pass'] == $password) {
            $loggedIn = TRUE;
            $_SESSION['user'] = strip_tags($username);
            $_SESSION['isLoggedIn'] = TRUE;
        } else {
            $message = 'Invalid Login';
        }
    } else {
        $message = 'Invalid Captcha';
    }
}

您可能还想添加注销选项的代码,如Safeguarding the PHP session配方中所述:

} elseif (isset($_POST['logout'])) {
  session_unset();
  session_destroy();
  setcookie('PHPSESSID', 0, time() - 3600);
  header('Location: ' . $_SERVER['REQUEST_URI'] );
  exit;
}

然后您可以运行setCaptcha()

setCaptcha($phrase, $label, $image);

最后,不要忘记视图逻辑,在本例中,它呈现一个基本的登录表单。在表单标记内,您需要添加视图逻辑来显示 CAPTCHA 和标签:

<tr>
  <th><?= $label; ?></th>
  <td><?= $image; ?><input type="text" name="captcha" /></td>
</tr>

以下是生成的输出:

它是如何工作的...

为了演示如何使用图像 CAPTCHA,将chap_12_captcha_text.php中的代码复制到cha_12_captcha_image.php中。我们定义代表我们将写入 CAPTCHA 图像的目录位置的常量。(确保创建此目录!)否则,自动加载和使用语句结构类似。请注意,我们还定义了一个 TrueType 字体。差异以粗体标出:

<?php
define('IMAGE_DIR', __DIR__ . '/captcha');
define('IMAGE_URL', '/captcha');
define('IMAGE_FONT', __DIR__ . '/FreeSansBold.ttf');
require __DIR__ . '/../Application/Autoload/Loader.php';
Application\Autoload\Loader::init(__DIR__ . '/..');
use Application\Captcha\Image;

session_start();
session_regenerate_id();

提示

重要!

字体可能受版权、商标、专利或其他知识产权法的保护。如果您使用未经许可的字体,您和您的客户可能会在法庭上承担责任!请使用开源字体,或者您拥有有效许可的 Web 服务器上可用的字体。

当然,在setCaptcha()函数中,我们使用Image类而不是Reverse

function setCaptcha(&$phrase, &$label, &$image)
{
  $captcha = new Image(IMAGE_DIR, IMAGE_URL, IMAGE_FONT);
  $phrase  = $captcha->getPhrase();
  $label   = $captcha->getLabel();
  $image   = $captcha->getImage();
  $_SESSION['phrase'] = $phrase;
  return $captcha;
}

变量初始化与先前的脚本相同,登录处理与先前的脚本相同:

$image      = '';
$label      = '';
$phrase     = $_SESSION['phrase'] ?? '';
$message    = '';
$info       = 'You Can Now See Super Secret Information!!!';
$loggedIn   = $_SESSION['isLoggedIn'] ?? FALSE;
$loggedUser = $_SESSION['user'] ?? 'guest';

if (!empty($_POST['login'])) {

  // etc.  -- identical to chap_12_captcha_text.php

即使视图逻辑保持不变,因为我们正在使用getImage(),在图像验证码的情况下,它返回直接可用的 HTML。这是使用 TrueType 字体的输出:

工作原理...

还有更多...

如果您不愿意使用上述代码生成自己的内部 CAPTCHA,那么有很多库可用。大多数流行的框架都具有此功能。例如,Zend Framework 有其 Zend\Captcha 组件类。还有 reCAPTCHA,通常作为一项服务调用,您的应用程序会调用外部网站生成 CAPTCHA 和令牌。开始寻找的好地方是www.captcha.net/网站。

另请参阅

有关字体作为知识产权的保护的更多信息,请参阅en.wikipedia.org/wiki/Intellectual_property_protection_of_typefaces上的文章。

加密/解密无需 mcrypt

在一般 PHP 社区的成员中,一个鲜为人知的事实是,被认为是安全的大多数基于 PHP 的加密的核心mcrypt扩展实际上并不安全。从安全的角度来看,最大的问题之一是mcrypt扩展需要对加密学有高级知识才能成功操作,而这是很少有程序员具备的。这导致了严重的误用,最终会出现诸如 256 分之 1 的数据损坏的问题。这不是一个好的几率。此外,对于libmcryptmcrypt扩展所基于的核心库,开发支持在 2007 年就被放弃了,这意味着代码库已经过时,存在错误,并且没有机制来应用补丁。因此,非常重要的是要了解如何在使用mcrypt的情况下执行强大的加密/解密!

如何做...

  1. 解决之前提出的问题的方法是使用openssl。这个扩展是被很好地维护的,并且具有现代和非常强大的加密/解密能力。

提示

重要

为了使用任何openssl*函数,必须编译和启用openssl PHP 扩展!此外,您需要在 Web 服务器上安装最新的 OpenSSL 软件包。

  1. 首先,您需要确定安装中有哪些密码方法。为此,您可以使用openssl_get_cipher_methods()命令。示例将包括基于高级 加密标准AES),BlowFishBF),CAMELLIACAST5数据 加密标准DES),Rivest CipherRC)(也可亲切地称为Ron's Code),和SEED的算法。您会注意到,此方法显示了大小写重复的密码方法。

  2. 接下来,您需要弄清楚哪种方法最适合您的需求。这里有一个快速总结各种方法的表:

方法 发布 密钥大小(位) 密钥块大小(字节) 注释
camellia 2000 128, 192, 256 16 由三菱和 NTT 开发
aes 1998 128, 192, 256 16 由 Joan Daemen 和 Vincent Rijmen 开发。最初提交为 Rijndael
seed 1998 128 16 由韩国信息安全机构开发
cast5 1996 40 到 128 8 由 Carlisle Adams 和 Stafford Tavares 开发
bf 1993 1 到 448 8 由 Bruce Schneier 设计
rc2 1987 8 到 1,024 默认为 64 8 由 Ron Rivest 设计(RSA 的核心创始人之一)
des 1977 56(+8 奇偶校验位) 8 由 IBM 开发,基于 Horst Feistel 的工作
  1. 另一个考虑因素是您首选的块密码操作模式是什么。常见选择总结在这个表中:
模式 代表 注释
ECB 电子密码本 不需要初始化向量(IV);支持加密和解密的并行化;简单快速;不隐藏数据模式;不推荐!!!
CBC 密码块链接 需要 IV;即使相同,后续块也与前一个块进行异或运算,从而获得更好的整体加密;如果 IV 可预测,第一个块可以被解码,剩余消息暴露;消息必须填充到密码块大小的倍数;仅支持解密的并行化
CFB 密码反馈 与 CBC 密切相关,只是加密是反向进行的
OFB 输出反馈 非常对称:加密和解密相同;不支持任何并行化
CTR 计数器 在操作上类似于 OFB;支持加密和解密的并行化
CCM 计数器与 CBC-MAC CTR 的派生;仅设计用于块长度为 128 位;提供认证和保密性;CBC-MAC代表密码块链接 - 消息认证码
GCM Galois/Counter Mode 基于 CTR 模式;每个要加密的流应使用不同的 IV;吞吐量异常高(与其他模式相比);支持加密和解密的并行化
XTS 基于 XEX 的改进密码本模式与密文窃取 相对较新(2010 年)和快速;使用两个密钥;增加了可以安全加密的数据量
  1. 在选择密码方法和模式之前,您还需要确定加密内容是否需要在 PHP 应用程序之外解密。例如,如果您将数据库凭据加密存储到独立的文本文件中,您是否需要能够从命令行解密?如果是这样,请确保您选择的密码方法和操作模式受目标操作系统支持。

  2. 提供的IV的字节数取决于所选择的密码方法。为了获得最佳结果,使用random_bytes()(PHP 7 中的新功能),它返回真正的CSPRNG字节序列。IV 的长度差别很大。首先尝试大小为 16。如果生成了警告,将显示应为该算法提供的正确字节数,因此请相应调整大小:

$iv  = random_bytes(16);
  1. 要执行加密,使用openssl_encrypt()。以下是应该传递的参数:
Parameter 注释
Data 需要加密的明文。
Method 使用openssl_get_cipher_methods()识别的方法之一。识别如下:method - key_size - cipher_mode。所以,例如,如果您想要 AES 方法,密钥大小为 256,以及 GCM 模式,您将输入aes-256-gcm
Password 虽然文档中称为password,但这个参数可以被视为key。使用random_bytes()生成一个与所需密钥大小匹配的密钥。
Options 在您对openssl加密有更多经验之前,建议您坚持使用默认值0
IV 使用random_bytes()生成一个与密码方法匹配字节数的 IV。
  1. 举个例子,假设你想选择 AES 密码方法,密钥大小为 256,并且选择 XTS 模式。以下是用于加密的代码:
$plainText = 'Super Secret Credentials';
$key = random_bytes(16);
$method = 'aes-256-xts';
$cipherText = openssl_encrypt($plainText, $method, $key, 0, $iv);
  1. 要解密,使用相同的$key$iv值,以及openssl_decrypt()函数:
$plainText = openssl_decrypt($cipherText, $method, $key, 0, $iv);

工作原理...

为了查看可用的密码方法,创建一个名为chap_12_openssl_encryption.php的 PHP 脚本,并运行以下命令:

<?php
echo implode(', ', openssl_get_cipher_methods());

输出应该看起来像这样:

工作原理...

接下来,您可以添加要加密的明文、方法、密钥和 IV 的值。例如,尝试 AES,密钥大小为 256,使用 XTS 操作模式:

$plainText = 'Super Secret Credentials';
$method = 'aes-256-xts';
$key = random_bytes(16);
$iv  = random_bytes(16);

要进行加密,可以使用openssl_encrypt(),指定之前配置的参数:

$cipherText = openssl_encrypt($plainText, $method, $key, 0, $iv);

您可能还希望对结果进行 base 64 编码,以使其更易于使用:

$cipherText = base64_encode($cipherText);

要解密,请使用相同的 $key$iv 值。不要忘记先解码 base 64 值:

$plainText = openssl_decrypt(base64_decode($cipherText), 
$method, $key, 0, $iv);

这里是输出,显示了 base 64 编码的密文,然后是解密后的明文:

工作原理...

如果您为 IV 提供了不正确数量的字节,对于所选择的密码方法,将显示警告消息:

工作原理...

还有更多...

在 PHP 7 中,使用 open_ssl_encrypt()open_ssl_decrypt() 以及支持的 Authenticated Encrypt with Associated Data (AEAD) 模式:GCM 和 CCM 时存在问题。因此,在 PHP 7.1 中,这些函数已添加了三个额外的参数,如下所示:

参数 描述
$tag 通过引用传递的认证标签;如果认证失败,变量值保持不变
$aad 附加的认证数据
$tag_length GCM 模式为 4 到 16;CCM 模式没有限制;仅适用于 open_ssl_encrypt()

有关更多信息,您可以参考 wiki.php.net/rfc/openssl_aead

另请参阅

有关在 PHP 7.1 中为什么要弃用 mcrypt 扩展的出色讨论,请参阅 wiki.php.net/rfc/mcrypt-viking-funeral 上的文章。有关分组密码的良好描述,这构成了各种密码方法的基础,请参阅 en.wikipedia.org/wiki/Block_cipher 上的文章。有关 AES 的出色描述,请参阅 en.wikipedia.org/wiki/Advanced_Encryption_Standard。可以在 en.wikipedia.org/wiki/Block_cipher_mode_of_operation 上看到描述加密操作模式的出色文章。

注意

对于一些较新的模式,如果要加密的数据小于块大小,openssl_decrypt() 将不返回任何值。如果 填充 要至少达到块大小的数据,则问题就消失了。大多数模式实现了内部填充,因此这不是问题。对于一些较新的模式(即 xts),您可能会遇到这个问题。在将代码投入生产之前,请务必对少于八个字符的短数据字符串进行测试。

第十三章:最佳实践、测试和调试

在本章中,我们将涵盖以下主题:

  • 使用特征和接口

  • 通用异常处理程序

  • 通用错误处理程序

  • 编写一个简单的测试

  • 编写测试套件

  • 生成假测试数据

  • 使用session_start参数自定义会话

介绍

在本章中,我们将向您展示特征和接口如何一起工作。然后,我们将把注意力转向设计一个回退机制,它将在您无法(或忘记)定义特定的try/catch块的情况下捕获错误和异常。然后,我们将进入单元测试的世界,首先向您展示如何编写简单的测试,然后如何将这些测试组合成测试套件。接下来,我们定义一个类,让您可以创建任意数量的通用测试数据。最后,我们讨论如何利用新的 PHP 7 功能轻松管理会话。

使用特征和接口

使用接口作为一种建立一组类的分类并保证某些方法存在的手段被认为是最佳实践。特征和接口经常一起工作,是实现的重要方面。无论何时您有一个经常使用的接口,定义了一个代码不会改变的方法(比如一个 setter 或 getter),也定义一个包含实际代码实现的特征是有用的。

如何做...

  1. 在这个例子中,我们将使用ConnectionAwareInterface,首次在第四章中介绍,使用 PHP 面向对象编程。这个接口定义了一个setConnection()方法,用于设置一个$connection属性。Application\Generic命名空间中的两个类,CountryListCustomerList,包含了冗余的代码,与接口中定义的方法相匹配。

  2. 在进行更改之前,CountryList的样子如下:

class CountryList
{
  protected $connection;
  protected $key   = 'iso3';
  protected $value = 'name';
  protected $table = 'iso_country_codes';

  public function setConnection(Connection $connection)
  {
    $this->connection = $connection;
  }
  public function list()
  {
    $list = [];
    $sql  = sprintf('SELECT %s,%s FROM %s', $this->key, 
                    $this->value, $this->table);
    $stmt = $this->connection->pdo->query($sql);
    while ($item = $stmt->fetch(PDO::FETCH_ASSOC)) {
      $list[$item[$this->key]] =  $item[$this->value];
    }
    return $list;
  }

}
  1. 我们现在将list()移到一个名为ListTrait的特征中:
trait ListTrait
{
  public function list()
  {
    $list = [];
    $sql  = sprintf('SELECT %s,%s FROM %s', 
                    $this->key, $this->value, $this->table);
    $stmt = $this->connection->pdo->query($sql);
    while ($item = $stmt->fetch(PDO::FETCH_ASSOC)) {
           $list[$item[$this->key]] = $item[$this->value];
    }
    return $list;
  }
}
  1. 然后,我们可以将ListTrait中的代码插入到一个新的类CountryListUsingTrait中,如下所示:
class CountryListUsingTrait
{
  use ListTrait;   
  protected $connection;
  protected $key   = 'iso3';
  protected $value = 'name';
  protected $table = 'iso_country_codes';
  public function setConnection(Connection $connection)
  {
    $this->connection = $connection;
  }

}
  1. 接下来,我们注意到许多类需要设置一个连接实例。同样,这需要一个特征。然而,这一次,我们将特征放在Application\Database命名空间中。这是新的特征:
namespace Application\Database;
trait ConnectionTrait
{
  protected $connection;
  public function setConnection(Connection $connection)
  {
    $this->connection = $connection;
  }
}
  1. 特征经常用于避免代码重复。通常情况下,您还需要确定使用特征的类。一个好的方法是开发一个与特征匹配的接口。在这个例子中,我们将定义Application\Database\ConnectionAwareInterface
namespace Application\Database;
use Application\Database\Connection;
interface ConnectionAwareInterface
{
  public function setConnection(Connection $connection);
}
  1. 这是修订后的CountryListUsingTrait类。请注意,由于特征的位置受到其命名空间的影响,我们需要在类的顶部添加一个use语句。您还会注意到,我们实现了ConnectionAwareInterface来确定这个类需要特征中定义的方法。请注意,我们正在利用新的 PHP 7 组使用语法:
namespace Application\Generic;
use PDO;
use Application\Database\ { 
Connection, ConnectionTrait, ConnectionAwareInterface 
};
class CountryListUsingTrait implements ConnectionAwareInterface
{
  use ListTrait;
  use ConnectionTrait;

  protected $key   = 'iso3';
  protected $value = 'name';
  protected $table = 'iso_country_codes';

}

它是如何工作的...

首先,确保在第四章中开发的类已经创建。这些包括在第四章中讨论的Application\Generic\CountryListApplication\Generic\CustomerList类,在使用接口一节中。将每个类保存在Application\Generic文件夹中的一个新文件中,分别命名为CountryListUsingTrait.phpCustomerListUsingTrait.php。确保更改类名以匹配新文件的名称!

如第 3 步所讨论的,从CountryListUsingTrait.phpCustomerListUsingTrait.php中删除list()方法。在删除的方法的位置添加use ListTrait;。将删除的代码放入同一文件夹中的一个单独的文件中,命名为ListTrait.php

您还会注意到两个列表类之间进一步的代码重复,这种情况下是setConnection()方法。这需要另一个 trait!

CountryListUsingTrait.phpCustomerListUsingTrait.php列表类中剪切setConnection()方法,并将删除的代码放入名为ConnectionTrait.php的单独文件中。由于这个 trait 在逻辑上与ConnectionAwareInterfaceConnection相关,因此将文件放在Application\Database文件夹中,并相应地指定其命名空间是有意义的。

最后,在步骤 6 中讨论的地方定义Application\Database\ConnectionAwareInterface。在所有更改之后,以下是最终的Application\Generic\CustomerListUsingTrait类:

<?php
namespace Application\Generic;
use PDO;
use Application\Database\Connection;
use Application\Database\ConnectionTrait;
use Application\Database\ConnectionAwareInterface;
class CustomerListUsingTrait implements ConnectionAwareInterface
{

  use ListTrait;
  use ConnectionTrait;

  protected $key   = 'id';
  protected $value = 'name';
  protected $table = 'customer';
}

您现在可以将第四章中提到的chap_04_oop_simple_interfaces_example.php文件复制到一个名为chap_13_trait_and_interface.php的新文件中。将引用从CountryList改为CountryListUsingTrait。同样,将引用从CustomerList改为CustomerListUsingTrait。否则,代码可以保持不变:

<?php
define('DB_CONFIG_FILE', '/../config/db.config.php');
require __DIR__ . '/../Application/Autoload/Loader.php';
Application\Autoload\Loader::init(__DIR__ . '/..');
$params = include __DIR__ . DB_CONFIG_FILE;
try {
    $list = Application\Generic\ListFactory::factory(
      new Application\Generic\CountryListUsingTrait(), $params);
    echo 'Country List' . PHP_EOL;
    foreach ($list->list() as $item) echo $item . ' ';
    $list = Application\Generic\ListFactory::factory(
      new Application\Generic\CustomerListUsingTrait(), 
      $params);
    echo 'Customer List' . PHP_EOL;
    foreach ($list->list() as $item) echo $item . ' ';

} catch (Throwable $e) {
    echo $e->getMessage();
}

输出将与第四章中使用接口一节中描述的完全相同,使用面向对象编程。您可以在以下截图中看到输出的国家列表部分:

它是如何工作的...

下一张图片显示了输出的客户列表部分:

它是如何工作的...

通用异常处理程序

try/catch块中与代码结合使用时,异常特别有用。然而,在某些情况下,使用这种结构可能会很笨拙,使代码几乎无法阅读。另一个考虑因素是,许多类最终会抛出您未预料到的异常。在这种情况下,拥有某种回退异常处理程序将是非常理想的。

如何做...

  1. 首先,我们定义一个通用异常处理类,Application\Error\Handler
namespace Application\Error;
class Handler
{
  // code goes here
}
  1. 我们定义了代表日志文件的属性。如果未提供名称,则以年、月和日命名。在构造函数中,我们使用set_exception_handler()exceptionHandler()方法(在这个类中)分配为回退处理程序:
protected $logFile;
public function __construct(
  $logFileDir = NULL, $logFile = NULL)
{
  $logFile = $logFile    ?? date('Ymd') . '.log';
  $logFileDir = $logFileDir ?? __DIR__;
  $this->logFile = $logFileDir . '/' . $logFile;
  $this->logFile = str_replace('//', '/', $this->logFile);
  set_exception_handler([$this,'exceptionHandler']);
}
  1. 接下来,我们定义exceptionHandler()方法,它以Exception对象作为参数。我们记录异常的日期和时间、异常的类名以及其消息在日志文件中:
public function exceptionHandler($ex)
{
  $message = sprintf('%19s : %20s : %s' . PHP_EOL,
    date('Y-m-d H:i:s'), get_class($ex), $ex->getMessage());
  file_put_contents($this->logFile, $message, FILE_APPEND); 
}
  1. 如果我们在代码中明确放置了try/catch块,这将覆盖我们的通用异常处理程序。另一方面,如果我们不使用 try/catch 并且抛出异常,通用异常处理程序将发挥作用。

提示

最佳实践

您应该始终使用 try/catch 来捕获异常,并可能在应用程序中继续。这里描述的异常处理程序仅旨在允许您的应用程序在未捕获的异常情况下“优雅”地结束。

它是如何工作的...

首先,将前面一节中显示的代码放入Application\Error文件夹中的Handler.php文件中。接下来,定义一个将抛出异常的测试类。为了举例,创建一个Application\Error\ThrowsException类,它将抛出一个异常。例如,设置一个 PDO 实例,错误模式设置为PDO::ERRMODE_EXCEPTION。然后编写一个肯定会失败的 SQL 语句:

namespace Application\Error;
use PDO;
class ThrowsException
{
  protected $result;
  public function __construct(array $config)
  {
    $dsn = $config['driver'] . ':';
    unset($config['driver']);
    foreach ($config as $key => $value) {
      $dsn .= $key . '=' . $value . ';';
    }
    $pdo = new PDO(
      $dsn, 
      $config['user'],
      $config['password'],
      [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]);
      $stmt = $pdo->query('This Is Not SQL');
      while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) {
        $this->result[] = $row;
      }
  }
}

接下来,定义一个名为chap_13_exception_handler.php的调用程序,设置自动加载,使用适当的类:

<?php
define('DB_CONFIG_FILE', __DIR__ . '/../config/db.config.php');
$config = include DB_CONFIG_FILE;
require __DIR__ . '/../Application/Autoload/Loader.php';
Application\Autoload\Loader::init(__DIR__ . '/..');
use Application\Error\ { Handler, ThrowsException };

此时,如果您创建一个没有实现通用处理程序的ThrowsException实例,将生成一个致命错误,因为已经抛出了一个异常但没有被捕获:

$throws1 = new ThrowsException($config);

它是如何工作的...

另一方面,如果你使用try/catch块,异常将被捕获,你的应用程序将被允许继续,如果它足够稳定的话。

try {
    $throws1 = new ThrowsException($config);
} catch (Exception $e) {
    echo 'Exception Caught: ' . get_class($e) . ':' . $e->getMessage() . PHP_EOL;
}
echo 'Application Continues ...' . PHP_EOL;

你会观察到以下输出:

它是如何工作的...

为了演示异常处理程序的使用,首先定义一个Handler实例,传递一个表示包含日志文件的目录的参数,然后在try/catch块之前。在try/catch块之后,块外部,创建另一个ThrowsException实例。当运行这个示例程序时,你会注意到第一个异常在try/catch块内被捕获,第二个异常被处理程序捕获。你还会注意到,在处理程序之后,应用程序结束了。

$handler = new Handler(__DIR__ . '/logs');
try {
    $throws1 = new ThrowsException($config);
} catch (Exception $e) {
    echo 'Exception Caught: ' . get_class($e) . ':' 
      . $e->getMessage() . PHP_EOL;
}
$throws1 = new ThrowsException($config);
echo 'Application Continues ...' . PHP_EOL;

这是完成示例程序的输出,以及日志文件的内容:

它是如何工作的...

另请参阅

通用错误处理程序

开发通用错误处理程序的过程与前面的步骤非常相似。然而,也有一些区别。首先,在 PHP 7 中,一些错误被抛出并可以被捕获,而其他错误会直接停止你的应用程序。更让人困惑的是,一些错误被视为异常,而另一些则源自新的 PHP 7 Error类。幸运的是,在 PHP 7 中,ErrorException都实现了一个叫做Throwable的新接口。因此,如果你不确定你的代码会抛出一个Exception还是一个Error,只需捕获Throwable的一个实例,你就能捕获两者。

如何做...

  1. 修改前面步骤中定义的Application\Error\Handler类。在构造函数中,将一个新的errorHandler()方法设置为默认的错误处理程序:
public function __construct($logFileDir = NULL, $logFile = NULL)
{
  $logFile    = $logFile    ?? date('Ymd') . '.log';
  $logFileDir = $logFileDir ?? __DIR__;
  $this->logFile = $logFileDir . '/' . $logFile;
  $this->logFile = str_replace('//', '/', $this->logFile);
  set_exception_handler([$this,'exceptionHandler']);
  set_error_handler([$this, 'errorHandler']);
}
  1. 然后,使用文档化的参数定义新方法。与我们的异常处理程序一样,我们将信息记录到日志文件中。
public function errorHandler($errno, $errstr, $errfile, $errline)
{
  $message = sprintf('ERROR: %s : %d : %s : %s : %s' . PHP_EOL,
    date('Y-m-d H:i:s'), $errno, $errstr, $errfile, $errline);
  file_put_contents($this->logFile, $message, FILE_APPEND);
}
  1. 另外,为了能够区分错误和异常,将EXCEPTION添加到exceptionHandler()方法中发送到日志文件的消息中:
public function exceptionHandler($ex)
{
  $message = sprintf('EXCEPTION: %19s : %20s : %s' . PHP_EOL,
    date('Y-m-d H:i:s'), get_class($ex), $ex->getMessage());
  file_put_contents($this->logFile, $message, FILE_APPEND);
}

它是如何工作的...

首先,按照之前定义的方式更改Application\Error\Handler。接下来,创建一个类,抛出一个错误,可以定义为Application\Error\ThrowsError。例如,你可以有一个尝试除以零的方法,另一个尝试使用eval()解析非 PHP 代码的方法。

<?php
namespace Application\Error;
class ThrowsError
{
  const NOT_PARSE = 'this will not parse';
  public function divideByZero()
  {
    $this->zero = 1 / 0;
  }
  public function willNotParse()
  {
    eval(self::NOT_PARSE);
  }
}

然后,你可以定义一个名为chap_13_error_throwable.php的调用程序,设置自动加载,使用适当的类,并创建一个ThrowsError实例。

<?php
require __DIR__ . '/../Application/Autoload/Loader.php';
Application\Autoload\Loader::init(__DIR__ . '/..');
use Application\Error\ { Handler, ThrowsError };
$error = new ThrowsError();

如果你调用这两个方法,没有try/catch块,也没有定义通用错误处理程序,第一个方法会生成一个Warning,而第二个会抛出一个ParseError

$error->divideByZero();
$error->willNotParse();
echo 'Application continues ... ' . PHP_EOL;

因为这是一个错误,程序执行停止,你将看不到Application continues ...

它是如何工作的...

如果你将方法调用包装在try/catch块中,并捕获Throwable,代码执行将继续:

try {
    $error->divideByZero();
} catch (Throwable $e) {
    echo 'Error Caught: ' . get_class($e) . ':' 
      . $e->getMessage() . PHP_EOL;
}
try {
    $error->willNotParse();
} catch (Throwable $e) {
    echo 'Error Caught: ' . get_class($e) . ':' 
    . $e->getMessage() . PHP_EOL;
}
echo 'Application continues ... ' . PHP_EOL;

从以下输出中,你还会注意到程序以code 0退出,这告诉我们一切都很好:

它是如何工作的...

最后,在try/catch块之后再次运行错误,将 echo 语句移到最后。你会在输出中看到错误被捕获,但在日志文件中,注意到DivisionByZeroError被异常处理程序捕获,而ParseError被错误处理程序捕获:

$handler = new Handler(__DIR__ . '/logs');
$error->divideByZero();
$error->willNotParse();
echo 'Application continues ... ' . PHP_EOL;

它是如何工作的...

另请参阅

  • PHP 7.1 允许您在catch ()子句中指定多个类。因此,您可以说catch (Exception | Error $e) { xxx }

编写一个简单的测试

测试 PHP 代码的主要方法是使用PHPUnit,它基于一种称为单元测试的方法论。单元测试背后的哲学非常简单:将代码分解为尽可能小的逻辑单元。然后分别测试每个单元,以确认其表现如预期。这些期望被编码为一系列断言。如果所有断言返回TRUE,则该单元通过了测试。

注意

在程序化 PHP 的情况下,一个单元是一个函数。对于 OOP PHP,单元是类中的一个方法。

如何做...

  1. 首要任务是直接在开发服务器上安装 PHPUnit,或者下载源代码,源代码以单个pharPHP 存档)文件的形式提供。快速访问 PHPUnit 的官方网站(phpunit.de/)让我们可以直接从主页下载。

  2. 然而,最佳实践是使用软件包管理器来安装和维护 PHPUnit。为此,我们将使用一个名为Composer的软件包管理程序。要安装 Composer,请访问主网站getcomposer.org/,并按照下载页面上的说明进行操作。在撰写本文时,当前的过程如下。请注意,您需要用当前版本的哈希替换<hash>

php -r "copy('https://getcomposer.org/installer', 'composer-setup.php');"
php -r "if (hash_file('SHA384', 'composer-setup.php') === '<hash>') { 
    echo 'Installer verified'; 
} else { 
    echo 'Installer corrupt'; unlink('composer-setup.php'); 
} echo PHP_EOL;"
php composer-setup.php
php -r "unlink('composer-setup.php');"

提示

最佳实践

使用 Composer 等软件包管理程序的优势在于它不仅可以安装,还可以用于更新应用程序使用的任何外部软件(如 PHPUnit)。

  1. 接下来,我们使用 Composer 来安装 PHPUnit。这是通过创建一个包含一系列指令的composer.json文件来实现的,这些指令概述了项目参数和依赖关系。这些指令的完整描述超出了本书的范围;然而,为了这个示例,我们使用require关键参数创建了一组最小的指令。您还会注意到文件的内容是以JavaScript 对象表示JSON)格式呈现的:
{
  "require-dev": {
    "phpunit/phpunit": "*"
  }
}
  1. 要从命令行执行安装,我们运行以下命令。输出如下所示:
**php composer.phar install**

如何做...

  1. PHPUnit 及其依赖项被放置在vendor文件夹中,如果不存在,Composer 将创建它。然后,调用 PHPUnit 的主要命令被符号链接到vendor/bin文件夹中。如果您将此文件夹放在您的PATH中,您只需要运行此命令,它将检查版本并顺便确认安装:
**phpunit --version**

运行简单测试

  1. 为了说明这一点,让我们假设我们有一个包含add()函数的chap_13_unit_test_simple.php文件:
<?php
function add($a = NULL, $b = NULL)
{
  return $a + $b;
}
  1. 测试然后被写成扩展PHPUnit\Framework\TestCase的类。如果你正在测试一个函数库,在测试类的开头,包括包含函数定义的文件。然后你会写一些以单词test开头的方法,通常后面跟着你正在测试的函数的名称,可能还有一些额外的驼峰命名的单词来进一步描述测试。为了这个示例,我们将定义一个SimpleTest测试类:
<?php
use PHPUnit\Framework\TestCase;
require_once __DIR__ . '/chap_13_unit_test_simple.php';
class SimpleTest extends TestCase
{
  // testXXX() methods go here
}
  1. 断言构成了任何一组测试的核心。另请参阅部分为您提供了完整断言列表的文档参考。断言是一个 PHPUnit 方法,它比较一个已知值与您希望测试的值产生的值。例如assertEquals(),它检查第一个参数是否等于第二个参数。以下示例测试了一个名为add()的方法,并确认add(1,1)的返回值为2
public function testAdd()
{
  $this->assertEquals(2, add(1,1));
}
  1. 您也可以测试某些事情是否成立。这个例子断言 1 + 1 不等于 3:
$this->assertNotEquals(3, add(1,1));
  1. 在测试字符串时,使用assertRegExp()断言非常有用。假设,为了举例说明,我们正在测试一个函数,该函数从多维数组中生成 HTML 表:
function table(array $a)
{
  $table = '<table>';
  foreach ($a as $row) {
    $table .= '<tr><td>';
    $table .= implode('</td><td>', $row);
    $table .= '</td></tr>';
  }
  $table .= '</table>';
  return $table;
}
  1. 我们可以构建一个简单的测试,确认输出包含<table>,一个或多个字符,然后是</table>。此外,我们希望确认存在一个<td>B</td>元素。在编写测试时,我们构建一个测试数组,其中包含三个子数组,分别包含字母 A—C,D—F 和 G—I。然后我们将测试数组传递给函数,并对结果运行断言:
public function testTable()
{
  $a = [range('A', 'C'),range('D', 'F'),range('G','I')];
  $table = table($a);
  $this->assertRegExp('!^<table>.+</table>$!', $table);
  $this->assertRegExp('!<td>B</td>!', $table);
}
  1. 为了测试一个类,而不是包含一个函数库,只需包含定义要测试的类的文件。为了举例说明,让我们将先前显示的函数库移动到一个Demo类中:
<?php
class Demo
{
  public function add($a, $b)
  {
    return $a + $b;
  }

  public function sub($a, $b)
  {
    return $a - $b;
  }
  // etc.
}
  1. 在我们的SimpleClassTest测试类中,我们不包含库文件,而是包含代表Demo类的文件。我们需要Demo的一个实例来运行测试。为此,我们使用一个专门设计的setup()方法,在每次测试之前运行。此外,您会注意到一个teardown()方法,在每次测试后立即运行:
<?php
use PHPUnit\Framework\TestCase;
require_once __DIR__ . '/Demo.php';
class SimpleClassTest extends TestCase
{
  protected $demo;
  public function setup()
  {
    $this->demo = new Demo();
  }
  public function teardown()
  {
    unset($this->demo);
  }
  public function testAdd()
  {
    $this->assertEquals(2, $this->demo->add(1,1));
  }
  public function testSub()
  {
    $this->assertEquals(0, $this->demo->sub(1,1));
  }
  // etc.
}

注意

在每次测试之前和之后运行setup()teardown()的原因是确保一个新鲜的测试环境。这样,一个测试的结果不会影响另一个测试的结果。

测试数据库模型类

  1. 在测试具有数据库访问权限的类(例如模型类)时,还有其他考虑因素。主要考虑因素是,您应该针对测试数据库而不是生产中使用的真实数据库运行测试。最后一点是,通过使用测试数据库,您可以提前使用适当的受控数据填充它。setup()teardown()也可以用于添加或删除测试数据。

  2. 作为使用数据库的类的示例,我们将定义一个VisitorOps类。新类将包括添加、删除和查找访问者的方法。请注意,我们还添加了一个方法来返回最新执行的 SQL 语句:

<?php
require __DIR__ . '/../Application/Database/Connection.php';
use Application\Database\Connection;
class VisitorOps
{

const TABLE_NAME = 'visitors';
protected $connection;
protected $sql;

public function __construct(array $config)
{
  $this->connection = new Connection($config);
}

public function getSql()
{
  return $this->sql;
}

public function findAll()
{
  $sql = 'SELECT * FROM ' . self::TABLE_NAME;
  $stmt = $this->runSql($sql);
  while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) {
    yield $row;
  }
}

public function findById($id)
{
  $sql = 'SELECT * FROM ' . self::TABLE_NAME;
  $sql .= ' WHERE id = ?';
  $stmt = $this->runSql($sql, [$id]);
  return $stmt->fetch(PDO::FETCH_ASSOC);
}

public function removeById($id)
{
  $sql = 'DELETE FROM ' . self::TABLE_NAME;
  $sql .= ' WHERE id = ?';
  return $this->runSql($sql, [$id]);
}

public function addVisitor($data)
{
  $sql = 'INSERT INTO ' . self::TABLE_NAME;
  $sql .= ' (' . implode(',',array_keys($data)) . ') ';
  $sql .= ' VALUES ';
  $sql .= ' ( :' . implode(',:',array_keys($data)) . ') ';
  $this->runSql($sql, $data);
  return $this->connection->pdo->lastInsertId();
}

public function runSql($sql, $params = NULL)
{
  $this->sql = $sql;
  try {
      $stmt = $this->connection->pdo->prepare($sql);
      $result = $stmt->execute($params);
  } catch (Throwable $e) {
      error_log(__METHOD__ . ':' . $e->getMessage());
      return FALSE;
  }
  return $stmt;
}
}
  1. 对于涉及数据库的测试,建议使用测试数据库,而不是实际生产数据库。因此,您需要额外的数据库连接参数集,可以在setup()方法中用于建立数据库连接。

  2. 您可能希望建立一个一致的样本数据块。这可以在setup()方法中插入到测试数据库中。

  3. 最后,您可能希望在每次测试后重置测试数据库,这可以在teardown()方法中完成。

使用模拟类

  1. 在某些情况下,测试将访问需要外部资源的复杂组件。一个例子是需要访问数据库的服务类。最佳实践是尽量减少测试套件中对数据库的访问。另一个考虑因素是我们不是在测试数据库访问;我们只是在测试一个特定类的功能。因此,有时需要定义模拟类,模仿其父类的行为,但限制对外部资源的访问。

提示

最佳实践

在测试中,将实际数据库访问限制在模型(或等效)类中。否则,运行整套测试所需的时间可能会变得过多。

  1. 在这种情况下,为了举例说明,定义一个服务类VisitorService,它使用先前讨论的VisitorOps类:
<?php
require_once __DIR__ . '/VisitorOps.php';
require_once __DIR__ . '/../Application/Database/Connection.php';
use Application\Database\Connection;
class VisitorService
{
  protected $visitorOps;
  public function __construct(array $config)
  {
    $this->visitorOps = new VisitorOps($config);
  }
  public function showAllVisitors()
  {
    $table = '<table>';
    foreach ($this->visitorOps->findAll() as $row) {
      $table .= '<tr><td>';
      $table .= implode('</td><td>', $row);
      $table .= '</td></tr>';
    }
    $table .= '</table>';
    return $table;
  }
  1. 为了测试目的,我们为$visitorOps属性添加了 getter 和 setter。这使我们能够在真实的VisitorOps类的位置插入一个模拟类:
public function getVisitorOps()
{
  return $this->visitorOps;
}

public function setVisitorOps(VisitorOps $visitorOps)
{
  $this->visitorOps = $visitorOps;
}
} // closing brace for VisitorService
  1. 接下来,我们定义一个VisitorOpsMock模拟类,模拟其父类的功能。类常量和属性都会被继承。然后我们添加模拟测试数据,并添加一个 getter 以便以后访问测试数据:
<?php
require_once __DIR__ . '/VisitorOps.php';
class VisitorOpsMock extends VisitorOps
{
  protected $testData;
  public function __construct()
  {
    $data = array();
    for ($x = 1; $x <= 3; $x++) {
      $data[$x]['id'] = $x;
      $data[$x]['email'] = $x . 'test@unlikelysource.com';
      $data[$x]['visit_date'] = 
        '2000-0' . $x . '-0' . $x . ' 00:00:00';
      $data[$x]['comments'] = 'TEST ' . $x;
      $data[$x]['name'] = 'TEST ' . $x;
    }
    $this->testData = $data;
  }
  public function getTestData()
  {
    return $this->testData;
  }
  1. 接下来,我们覆盖findAll()以使用yield返回测试数据,就像父类一样。请注意,我们仍然构建 SQL 字符串,因为这是父类的做法:
public function findAll()
{
  $sql = 'SELECT * FROM ' . self::TABLE_NAME;
  foreach ($this->testData as $row) {
    yield $row;
  }
}
  1. 为了模拟findById(),我们只需从$this->testData返回该数组键。对于removeById(),我们从$this->testData中取消设置为参数的数组键:
public function findById($id)
{
  $sql = 'SELECT * FROM ' . self::TABLE_NAME;
  $sql .= ' WHERE id = ?';
  return $this->testData[$id] ?? FALSE;
}
public function removeById($id)
{
  $sql = 'DELETE FROM ' . self::TABLE_NAME;
  $sql .= ' WHERE id = ?';
  if (empty($this->testData[$id])) {
      return 0;
  } else {
      unset($this->testData[$id]);
      return 1;
  }
}
  1. 添加数据稍微复杂一些,因为我们需要模拟id参数可能不会被提供的情况,因为数据库通常会自动生成这个参数。为了解决这个问题,我们检查id参数。如果没有设置,我们找到最大的数组键并递增:
public function addVisitor($data)
{
  $sql = 'INSERT INTO ' . self::TABLE_NAME;
  $sql .= ' (' . implode(',',array_keys($data)) . ') ';
  $sql .= ' VALUES ';
  $sql .= ' ( :' . implode(',:',array_keys($data)) . ') ';
  if (!empty($data['id'])) {
      $id = $data['id'];
  } else {
      $keys = array_keys($this->testData);
      sort($keys);
      $id = end($keys) + 1;
      $data['id'] = $id;
  }
    $this->testData[$id] = $data;
    return 1;
  }

} // ending brace for the class VisitorOpsMock

使用匿名类作为模拟对象

  1. 模拟对象的一个很好的变化是使用新的 PHP 7 匿名类来代替创建定义模拟功能的正式类。使用匿名类的优势在于可以扩展现有类,使对象看起来合法。如果您只需要覆盖一两个方法,这种方法尤其有用。

  2. 在这个示例中,我们将修改之前介绍的VisitorServiceTest.php,将其命名为VisitorServiceTestAnonClass.php

<?php
use PHPUnit\Framework\TestCase;
require_once __DIR__ . '/VisitorService.php';
require_once __DIR__ . '/VisitorOps.php';
class VisitorServiceTestAnonClass extends TestCase
{
  protected $visitorService;
  protected $dbConfig = [
    'driver'   => 'mysql',
    'host'     => 'localhost',
    'dbname'   => 'php7cookbook_test',
    'user'     => 'cook',
    'password' => 'book',
    'errmode'  => PDO::ERRMODE_EXCEPTION,
  ];
    protected $testData;
  1. 您会注意到在setup()中,我们定义了一个匿名类,它扩展了VisitorOps。我们只需要覆盖findAll()方法:
public function setup()
{
  $data = array();
  for ($x = 1; $x <= 3; $x++) {
    $data[$x]['id'] = $x;
    $data[$x]['email'] = $x . 'test@unlikelysource.com';
    $data[$x]['visit_date'] = 
      '2000-0' . $x . '-0' . $x . ' 00:00:00';
    $data[$x]['comments'] = 'TEST ' . $x;
    $data[$x]['name'] = 'TEST ' . $x;
  }
  $this->testData = $data;
  $this->visitorService = 
    new VisitorService($this->dbConfig);
  $opsMock = 
    new class ($this->testData) extends VisitorOps {
      protected $testData;
      public function __construct($testData)
      {
        $this->testData = $testData;
      }
      public function findAll()
      {
        return $this->testData;
      }
    };
    $this->visitorService->setVisitorOps($opsMock);
}
  1. 请注意,在testShowAllVisitors()中,当执行$this->visitorService->showAllVisitors()时,访客服务会调用匿名类,然后调用覆盖的findAll()
public function teardown()
{
  unset($this->visitorService);
}
public function testShowAllVisitors()
{
  $result = $this->visitorService->showAllVisitors();
  $this->assertRegExp('!^<table>.+</table>$!', $result);
  foreach ($this->testData as $key => $value) {
    $dataWeWant = '!<td>' . $key . '</td>!';
    $this->assertRegExp($dataWeWant, $result);
  }
}
}

使用模拟构建器

  1. 另一种技术是使用getMockBuilder()。虽然这种方法不能对生成的模拟对象进行精细控制,但在您只需要确认返回某个类的对象,并且当运行指定方法时,该方法返回某个预期值的情况下,它非常有用。

  2. 在下面的例子中,我们复制了VisitorServiceTestAnonClass;唯一的区别在于在setup()中提供VisitorOps的实例的方式,在这种情况下使用getMockBuilder()。请注意,尽管在这个例子中我们没有使用with(),但它被用来向模拟方法提供受控参数:

<?php
use PHPUnit\Framework\TestCase;
require_once __DIR__ . '/VisitorService.php';
require_once __DIR__ . '/VisitorOps.php';
class VisitorServiceTestAnonMockBuilder extends TestCase
{
  // code is identical to VisitorServiceTestAnon
  public function setup()
  {
    $data = array();
    for ($x = 1; $x <= 3; $x++) {
      $data[$x]['id'] = $x;
      $data[$x]['email'] = $x . 'test@unlikelysource.com';
      $data[$x]['visit_date'] = 
        '2000-0' . $x . '-0' . $x . ' 00:00:00';
      $data[$x]['comments'] = 'TEST ' . $x;
      $data[$x]['name'] = 'TEST ' . $x;
  }
  $this->testData = $data;
    $this->visitorService = 
      new VisitorService($this->dbConfig);
    $opsMock = $this->getMockBuilder(VisitorOps::class)
                    ->setMethods(['findAll'])
                    ->disableOriginalConstructor()
                    ->getMock();
                    $opsMock->expects($this->once())
                    ->method('findAll')
                    ->with()
                    ->will($this->returnValue($this->testData));
                    $this->visitorService->setVisitorOps($opsMock);
  }
  // remaining code is the same
}

注意

我们已经展示了如何创建简单的一次性测试。然而,在大多数情况下,您将需要测试许多类,最好一次性测试所有类。这可以通过开发一个测试套件来实现,下一个示例中将更详细地讨论。

它是如何工作的...

首先,您需要安装 PHPUnit,如步骤 1 到 5 所述。确保在您的 PATH 中包含vendor/bin,这样您就可以从命令行运行 PHPUnit。

运行简单测试

接下来,定义一个chap_13_unit_test_simple.php程序文件,其中包含一系列简单的函数,如add()sub()等,如步骤 1 所述。然后,您可以按照步骤 2 和 3 中提到的方式定义一个包含在SimpleTest.php中的简单测试类。

假设phpunit在您的PATH中,从终端窗口,切换到为这个示例开发的代码所在的目录,并运行以下命令:

**phpunit SimpleTest SimpleTest.php**

您应该看到以下输出:

运行简单测试

SimpleTest.php中进行更改,使测试失败(步骤 4):

public function testDiv()
{
  $this->assertEquals(2, div(4, 2));
  $this->assertEquals(99, div(4, 0));
}

这是修改后的输出:

运行简单测试

接下来,添加table()函数到chap_13_unit_test_simple.php(步骤 5),并在SimpleTest.php中添加testTable()(步骤 6)。重新运行单元测试并观察结果。

要测试一个类,将在chap_13_unit_test_simple.php中开发的函数复制到一个Demo类中(步骤 7)。在按照步骤 8 建议的对SimpleTest.php进行修改后,重新运行简单测试并观察结果。

测试数据库模型类

首先,创建一个要测试的示例类VisitorOps,如本小节中的步骤 2 所示。现在,您可以定义一个名为SimpleDatabaseTest的类来测试VisitorOps。首先使用require_once加载要测试的类。(我们将讨论如何在下一个示例中使用自动加载!)然后定义关键属性,包括测试数据库配置和测试数据。您可以使用php7cookbook_test作为测试数据库:

<?php
use PHPUnit\Framework\TestCase;
require_once __DIR__ . '/VisitorOps.php';
class SimpleDatabaseTest extends TestCase
{
  protected $visitorOps;
  protected $dbConfig = [
    'driver'   => 'mysql',
    'host'     => 'localhost',
    'dbname'   => 'php7cookbook_test',
    'user'     => 'cook',
    'password' => 'book',
    'errmode'  => PDO::ERRMODE_EXCEPTION,
  ];
  protected $testData = [
    'id' => 1,
    'email' => 'test@unlikelysource.com',
    'visit_date' => '2000-01-01 00:00:00',
    'comments' => 'TEST',
    'name' => 'TEST'
  ];
}

接下来,定义setup(),插入测试数据,并确认最后一个 SQL 语句是INSERT。您还应该检查返回值是否为正数:

public function setup()
{
  $this->visitorOps = new VisitorOps($this->dbConfig);
  $this->visitorOps->addVisitor($this->testData);
  $this->assertRegExp('/INSERT/', $this->visitorOps->getSql());
}

之后,定义teardown(),删除测试数据,并确认id = 1的查询结果为FALSE

public function teardown()
{
  $result = $this->visitorOps->removeById(1);
  $result = $this->visitorOps->findById(1);
  $this->assertEquals(FALSE, $result);
  unset($this->visitorOps);
}

第一个测试是findAll()。首先,确认结果的数据类型。您可以使用current()来获取顶部元素。我们确认有五个元素,其中一个是name,并且该值与测试数据中的值相同:

public function testFindAll()
{
  $result = $this->visitorOps->findAll();
  $this->assertInstanceOf(Generator::class, $result);
  $top = $result->current();
  $this->assertCount(5, $top);
  $this->assertArrayHasKey('name', $top);
  $this->assertEquals($this->testData['name'], $top['name']);
}

下一个测试是findById()。它几乎与testFindAll()相同:

public function testFindById()
{
  $result = $this->visitorOps->findById(1);
  $this->assertCount(5, $result);
  $this->assertArrayHasKey('name', $result);
  $this->assertEquals($this->testData['name'], $result['name']);
}

不需要为removeById()编写测试,因为这已经在teardown()中完成了。同样,也不需要测试runSql(),因为这是其他测试的一部分。

使用模拟类

首先,按照本小节中步骤 2 和 3 的描述定义一个VisitorService服务类。接下来,定义一个VisitorOpsMock模拟类,步骤 4 到 7 中有讨论。

现在,您可以为服务类开发一个名为VisitorServiceTest的测试。请注意,您需要提供自己的数据库配置,因为最佳实践是使用测试数据库而不是生产版本:

<?php
use PHPUnit\Framework\TestCase;
require_once __DIR__ . '/VisitorService.php';
require_once __DIR__ . '/VisitorOpsMock.php';

class VisitorServiceTest extends TestCase
{
  protected $visitorService;
  protected $dbConfig = [
    'driver'   => 'mysql',
    'host'     => 'localhost',
    'dbname'   => 'php7cookbook_test',
    'user'     => 'cook',
    'password' => 'book',
    'errmode'  => PDO::ERRMODE_EXCEPTION,
  ];
}

setup()中,创建服务的实例,并将VisitorOpsMock替换原始类:

public function setup()
{
  $this->visitorService = new VisitorService($this->dbConfig);
  $this->visitorService->setVisitorOps(new VisitorOpsMock());
}
public function teardown()
{
  unset($this->visitorService);
}

在我们的测试中,从访客列表生成 HTML 表格,您可以查找特定元素,事先知道可以期望什么,因为您对测试数据有控制:

public function testShowAllVisitors()
{
  $result = $this->visitorService->showAllVisitors();
  $this->assertRegExp('!^<table>.+</table>$!', $result);
  $testData = $this->visitorService->getVisitorOps()->getTestData();
  foreach ($testData as $key => $value) {
    $dataWeWant = '!<td>' . $key . '</td>!';
    $this->assertRegExp($dataWeWant, $result);
  }
}
}

然后,您可能希望尝试最后两个小节中建议的变体,即使用匿名类作为模拟对象使用模拟构建器

还有更多...

其他断言测试操作包括数字、字符串、数组、对象、文件、JSON 和 XML,如下表所总结的:

Category 断言
General assertEquals()assertFalse()assertEmpty()assertNull()assertSame()assertThat()assertTrue()
Numeric assertGreaterThan()assertGreaterThanOrEqual()assertLessThan()assertLessThanOrEqual()assertNan()assertInfinite()
String assertStringEndsWith()assertStringEqualsFile()assertStringStartsWith()assertRegExp()assertStringMatchesFormat()assertStringMatchesFormatFile()
Array/iterator assertArrayHasKey()assertArraySubset()assertContains()assertContainsOnly()assertContainsOnlyInstancesOf()assertCount()
File assertFileEquals()assertFileExists()
Objects assertClassHasAttribute()assertClassHasStaticAttribute()assertInstanceOf()assertInternalType()assertObjectHasAttribute()
JSON assertJsonFileEqualsJsonFile()assertJsonStringEqualsJsonFile()assertJsonStringEqualsJsonString()
XML assertEqualXMLStructure()assertXmlFileEqualsXmlFile()assertXmlStringEqualsXmlFile()assertXmlStringEqualsXmlString()

另请参阅...

编写测试套件

在阅读完上一篇后,您可能已经注意到手动运行phpunit并指定测试类和 PHP 文件可能会变得乏味。特别是在处理应用程序时,这些应用程序使用数十甚至数百个类和文件。PHPUnit 项目具有处理单个命令运行多个测试的内置功能。这样的一组测试称为测试套件

如何做...

  1. 最简单的情况下,您只需要将所有测试移动到一个文件夹中:
**mkdir tests**
**cp *Test.php tests**

  1. 您需要调整包含或需要外部文件的命令,以适应新位置。所示的示例(SimpleTest)是在前一篇中开发的:
<?php
use PHPUnit\Framework\TestCase;
require_once __DIR__ . '/../chap_13_unit_test_simple.php';

class SimpleTest extends TestCase
{
  // etc.
  1. 然后,您可以简单地使用目录路径作为参数运行phpunit。PHPUnit 将自动运行该文件夹中的所有测试。在此示例中,我们假设有一个tests子目录:
**phpunit tests**

  1. 您可以使用--bootstrap选项指定在运行测试之前执行的文件。此选项的典型用法是初始化自动加载:
**phpunit --boostrap tests_with_autoload/bootstrap.php tests**

  1. 这是实现自动加载的示例bootstrap.php文件:
<?php
require __DIR__ . '/../../Application/Autoload/Loader.php';
Application\Autoload\Loader::init([__DIR__]);
  1. 另一种可能性是使用 XML 配置文件定义一个或多个测试集。以下是一个示例,仅运行 Simple*测试:
<phpunit>
  <testsuites>
    <testsuite name="simple">
      <file>SimpleTest.php</file>
      <file>SimpleDbTest.php</file>
      <file>SimpleClassTest.php</file>
    </testsuite>
  </testsuites>
</phpunit>
  1. 以下是另一个示例,它基于目录运行测试,并指定了一个引导文件:
<phpunit bootstrap="bootstrap.php">
  <testsuites>
    <testsuite name="visitor">
      <directory>Simple</directory>
    </testsuite>
  </testsuites>
</phpunit>

它是如何工作...

确保在上一篇中讨论的所有测试“编写简单测试”中已经定义。然后可以创建一个tests文件夹,并将所有*Test.php文件移动或复制到此文件夹中。然后需要调整require_once语句中的路径,如第 2 步所示。

为了演示 PHPUnit 如何运行本章为您定义的源代码中的所有测试,运行以下命令:

**phpunit tests**

您应该看到以下输出:

它是如何工作的...

为了演示通过引导文件进行自动加载,创建一个新的tests_with_autoload目录。在此文件夹中,使用步骤 5 中显示的代码定义一个bootstrap.php文件。在tests_with_autoload中创建两个目录:DemoSimple

从包含本章源代码的目录中,将文件(在上一篇中的第 12 步中讨论)复制到tests_with_autoload/Demo/Demo.php中。在开头的<?php标记后,添加这一行:

namespace Demo;

接下来,将SimpleTest.php文件复制到tests_with_autoload/Simple/ClassTest.php中(注意文件名更改!)。您需要将前几行更改为以下内容:

<?php
namespace Simple;
use Demo\Demo;
use PHPUnit\Framework\TestCase;

class ClassTest extends TestCase
{
  protected $demo;
  public function setup()
  {
    $this->demo = new Demo();
  }
// etc.

之后,创建一个tests_with_autoload/phpunit.xml文件,将所有内容整合在一起:

<phpunit bootstrap="bootstrap.php">
  <testsuites>
    <testsuite name="visitor">
      <directory>Simple</directory>
    </testsuite>
  </testsuites>
</phpunit>

最后,切换到包含本章代码的目录。现在,您可以运行一个包含引导文件的单元测试,以及自动加载和命名空间,如下所示:

**phpunit -c tests_with_autoload/phpunit.xml**

输出应如下所示:

它是如何工作的...

另请参阅...

生成虚假测试数据

测试和调试过程的一部分涉及整合真实的测试数据。在某些情况下,特别是在测试数据库访问和生成基准时,需要大量的测试数据。可以通过从网站中抓取数据的过程,然后将数据以真实但随机的组合放入数据库中来实现这一点。

如何做...

  1. 第一步是确定测试应用程序所需的数据。另一个考虑因素是网站是否面向国际受众,还是市场主要来自单一国家?

  2. 为了生成一致的假数据工具,将数据从其来源移动到可用的数字格式非常重要。第一选择是一系列数据库表。另一个不太吸引人的选择是 CSV 文件。

  3. 您可能会分阶段转换数据。例如,您可以从列出国家代码和国家名称的网页中提取数据到一个文本文件中。操作步骤...

  4. 由于这个列表很短,将其直接剪切并粘贴到文本文件中非常容易。

  5. 然后我们可以搜索“ ”并替换为“\n”,得到如下结果:操作步骤...

  6. 然后可以将其导入电子表格,然后可以将其导出为 CSV 文件。从那里,将其导入数据库就变得很简单。例如,phpMyAdmin 就有这样的功能。

  7. 为了说明这一点,我们假设我们正在生成最终将出现在prospects表中的数据。以下是用于创建此表的 SQL 语句:

CREATE TABLE 'prospects' (
  'id' int(11) NOT NULL AUTO_INCREMENT,
  'first_name' varchar(128) NOT NULL,
  'last_name' varchar(128) NOT NULL,
  'address' varchar(256) DEFAULT NULL,
  'city' varchar(64) DEFAULT NULL,
  'state_province' varchar(32) DEFAULT NULL,
  'postal_code' char(16) NOT NULL,
  'phone' varchar(16) NOT NULL,
  'country' char(2) NOT NULL,
  'email' varchar(250) NOT NULL,
  'status' char(8) DEFAULT NULL,
  'budget' decimal(10,2) DEFAULT NULL,
  'last_updated' datetime DEFAULT NULL,
  PRIMARY KEY ('id'),
  UNIQUE KEY 'UNIQ_35730C06E7927C74' ('email')
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
  1. 现在是时候创建一个能够生成假数据的类了。然后我们将创建方法来为上面显示的每个字段生成数据,除了id,它是自动生成的:
namespace Application\Test;

use PDO;
use Exception;
use DateTime;
use DateInterval;
use PDOException;
use SplFileObject;
use InvalidArgumentsException;
use Application\Database\Connection;

class FakeData
{
  // data generation methods here
}
  1. 接下来,我们定义将用作过程一部分的常量和属性:
const MAX_LOOKUPS     = 10;
const SOURCE_FILE     = 'file';
const SOURCE_TABLE    = 'table';
const SOURCE_METHOD   = 'method';
const SOURCE_CALLBACK = 'callback';
const FILE_TYPE_CSV   = 'csv';
const FILE_TYPE_TXT   = 'txt';
const ERROR_DB        = 'ERROR: unable to read source table';
const ERROR_FILE      = 'ERROR: file not found';
const ERROR_COUNT     = 'ERROR: unable to ascertain count or ID column missing';
const ERROR_UPLOAD    = 'ERROR: unable to upload file';
const ERROR_LOOKUP    = 'ERROR: unable to find any IDs in the source table';

protected $connection;
protected $mapping;
protected $files;
protected $tables;
  1. 然后我们定义将用于生成随机字母、街道名称和电子邮件地址的属性。您可以将这些数组视为种子,可以根据需要进行修改和/或扩展。例如,您可以为法国受众替换巴黎的街道名称片段:
protected $alpha = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
protected $street1 = ['Amber','Blue','Bright','Broad','Burning',
  'Cinder','Clear','Dewy','Dusty','Easy']; // etc. 
protected $street2 = ['Anchor','Apple','Autumn','Barn','Beacon',
  'Bear','Berry','Blossom','Bluff','Cider','Cloud']; // etc.
protected $street3 = ['Acres','Arbor','Avenue','Bank','Bend',
  'Canyon','Circle','Street'];
protected $email1 = ['northern','southern','eastern','western',
  'fast','midland','central'];
protected $email2 = ['telecom','telco','net','connect'];
protected $email3 = ['com','net'];
  1. 在构造函数中,我们接受一个用于数据库访问的Connection对象,一个映射到假数据的数组:
public function __construct(Connection $conn, array $mapping)
{
  $this->connection = $conn;
  $this->mapping = $mapping;
}
  1. 为了生成街道名称,而不是尝试创建一个数据库表,使用一组种子数组来生成随机组合可能更有效。以下是这种方法可能的工作方式的示例:
public function getAddress($entry)
{
  return random_int(1,999)
   . ' ' . $this->street1[array_rand($this->street1)]
   . ' ' . $this->street2[array_rand($this->street2)]
   . ' ' . $this->street3[array_rand($this->street3)];
}
  1. 根据所需的逼真程度,您还可以构建一个将邮政编码与城市匹配的数据库表。邮政编码也可以随机生成。以下是一个为英国生成邮政编码的示例:
public function getPostalCode($entry, $pattern = 1)
{
  return $this->alpha[random_int(0,25)]
   . $this->alpha[random_int(0,25)]
   . random_int(1, 99)
   . ' '
   . random_int(1, 9)
   . $this->alpha[random_int(0,25)]
   . $this->alpha[random_int(0,25)];
}
  1. 生成假电子邮件也可以使用一组种子数组来产生随机结果。我们还可以编程让它接收一个现有的$entry数组,并使用这些参数来创建地址的名称部分:
public function getEmail($entry, $params = NULL)
{
  $first = $entry[$params[0]] ?? $this->alpha[random_int(0,25)];
  $last  = $entry[$params[1]] ?? $this->alpha[random_int(0,25)];
  return $first[0] . '.' . $last
   . '@'
   . $this->email1[array_rand($this->email1)]
   . $this->email2[array_rand($this->email2)]
   . '.'
   . $this->email3[array_rand($this->email3)];
}
  1. 对于日期生成,一个方法是接受一个现有的$entry数组作为参数。参数将是一个数组,其中第一个值是开始日期。第二个参数将是从开始日期减去的最大天数。这实际上让您从一个范围返回一个随机日期。请注意,我们使用DateTime::sub()来减去随机天数。sub()需要一个DateInterval实例,我们使用P、随机天数和'D'来构建它:
public function getDate($entry, $params)
{
  list($fromDate, $maxDays) = $params;
  $date = new DateTime($fromDate);
  $date->sub(new DateInterval('P' . random_int(0, $maxDays) . 'D'));
  return $date->format('Y-m-d H:i:s');
}
  1. 正如本教程开始时提到的,我们用于生成假数据的数据源会有所不同。在某些情况下,如前面几个步骤所示,我们使用种子数组,并构建假数据。在其他情况下,我们可能希望使用文本或 CSV 文件作为数据源。以下是这种方法可能的样子:
public function getEntryFromFile($name, $type)
{
  if (empty($this->files[$name])) {
      $this->pullFileData($name, $type);
  }
  return $this->files[$name][
  random_int(0, count($this->files[$name]))];
}
  1. 您会注意到,我们首先需要将文件数据提取到一个数组中,这个数组形成了返回值。以下是为我们执行此操作的方法。如果找不到指定的文件,我们会抛出一个Exception。文件类型被识别为我们的类常量之一:FILE_TYPE_TEXTFILE_TYPE_CSV。根据类型,我们使用fgetcsv()fgets()
public function pullFileData($name, $type)
{
  if (!file_exists($name)) {
      throw new Exception(self::ERROR_FILE);
  }
  $fileObj = new SplFileObject($name, 'r');
  if ($type == self::FILE_TYPE_CSV) {
      while ($data = $fileObj->fgetcsv()) {
        $this->files[$name][] = trim($data);
      }
  } else {
      while ($data = $fileObj->fgets()) {
        $this->files[$name][] = trim($data);
      }
  }
  1. 这个过程中可能最复杂的部分是从数据库表中抽取随机数据。我们接受表名、包含主键的列的名称、在查找表中数据库列名和目标列名之间映射的数组作为参数:
public function getEntryFromTable($tableName, $idColumn, $mapping)
{
  $entry = array();
  try {
      if (empty($this->tables[$tableName])) {
        $sql  = 'SELECT ' . $idColumn . ' FROM ' . $tableName 
          . ' ORDER BY ' . $idColumn . ' ASC LIMIT 1';
        $stmt = $this->connection->pdo->query($sql);
        $this->tables[$tableName]['first'] = 
          $stmt->fetchColumn();
        $sql  = 'SELECT ' . $idColumn . ' FROM ' . $tableName 
          . ' ORDER BY ' . $idColumn . ' DESC LIMIT 1';
        $stmt = $this->connection->pdo->query($sql);
        $this->tables[$tableName]['last'] = 
          $stmt->fetchColumn();
    }
  1. 现在我们可以设置准备好的语句并初始化一些关键变量:
$result = FALSE;
$count = self::MAX_LOOKUPS;
$sql  = 'SELECT * FROM ' . $tableName 
  . ' WHERE ' . $idColumn . ' = ?';
$stmt = $this->connection->pdo->prepare($sql);
  1. 我们将实际的查找放在一个do...while循环中。原因是我们至少需要运行一次查询才能得到结果。只有当我们没有得到结果时,我们才继续循环。我们生成一个介于最低 ID 和最高 ID 之间的随机数,然后在查询的参数中使用这个数。请注意,我们还要减少一个计数器以防止无限循环。这是因为 ID 不是连续的情况下,我们可能会意外地生成一个不存在的 ID。如果我们超过了最大尝试次数,仍然没有结果,我们会抛出一个Exception
do {
  $id = random_int($this->tables[$tableName]['first'], 
    $this->tables[$tableName]['last']);
  $stmt->execute([$id]);
  $result = $stmt->fetch(PDO::FETCH_ASSOC);
} while ($count-- && !$result);
  if (!$result) {
      error_log(__METHOD__ . ':' . self::ERROR_LOOKUP);
      throw new Exception(self::ERROR_LOOKUP);
  }
} catch (PDOException $e) {
    error_log(__METHOD__ . ':' . $e->getMessage());
    throw new Exception(self::ERROR_DB);
}
  1. 然后,我们使用映射数组从源表中检索值,使用目标表中预期的键:
foreach ($mapping as $key => $value) {
  $entry[$value] = $result[$key] ?? NULL;
}
return $entry;
}
  1. 这个类的核心是一个getRandomEntry()方法,它生成一个假数据的数组。我们逐个遍历$mapping,并检查各种参数:
public function getRandomEntry()
{
  $entry = array();
  foreach ($this->mapping as $key => $value) {
    if (isset($value['source'])) {
      switch ($value['source']) {
  1. source参数用于实现有效地作为策略模式的功能。我们支持四种不同的source,都定义为类常量。第一个是SOURCE_FILE。在这种情况下,我们使用之前讨论过的getEntryFromFile()方法:
        case self::SOURCE_FILE :
            $entry[$key] = $this->getEntryFromFile(
            $value['name'], $value['type']);
          break;
  1. 回调选项根据$mapping数组中提供的回调返回一个值:
        case self::SOURCE_CALLBACK :
            $entry[$key] = $value['name']();
          break;
  1. SOURCE_TABLE选项使用$mapping中定义的数据库表作为查找。请注意,之前讨论的getEntryFromTable()能够返回一个值数组,这意味着我们需要使用array_merge()来合并结果:
        case self::SOURCE_TABLE :
            $result = $this->getEntryFromTable(
            $value['name'],$value['idCol'],$value['mapping']);
            $entry = array_merge($entry, $result);
          break;
  1. SOURCE_METHOD选项,也是默认选项,使用了这个类中已经包含的一个方法。我们检查是否包括了参数,如果有,就将其添加到方法调用中。注意使用{}来影响插值。如果我们进行了$this->$value['name']()的 PHP 7 调用,由于抽象语法树(AST)的重写,它会插值为${$this->$value}['name'](),这不是我们想要的:
        case self::SOURCE_METHOD :
        default :
          if (!empty($value['params'])) {
              $entry[$key] = $this->{$value['name']}(
                $entry, $value['params']);
          } else {
              $entry[$key] = $this->{$value['name']}($entry);
          }
        }
    }
  }
  return $entry;
}
  1. 我们定义一个循环遍历getRandomEntry()以生成多行假数据的方法。我们还添加了一个选项来插入到目标表。如果启用了这个选项,我们设置一个准备好的语句来插入,并检查是否需要截断当前表中的任何数据:
public function generateData(
$howMany, $destTableName = NULL, $truncateDestTable = FALSE)
{
  try {
      if ($destTableName) {
        $sql = 'INSERT INTO ' . $destTableName
          . ' (' . implode(',', array_keys($this->mapping)) 
          . ') '. ' VALUES ' . ' (:' 
          . implode(',:', array_keys($this->mapping)) . ')';
        $stmt = $this->connection->pdo->prepare($sql);
        if ($truncateDestTable) {
          $sql = 'DELETE FROM ' . $destTableName;
          $this->connection->pdo->query($sql);
        }
      }
  } catch (PDOException $e) {
      error_log(__METHOD__ . ':' . $e->getMessage());
      throw new Exception(self::ERROR_COUNT);
  }
  1. 接下来,我们循环请求的数据行数,并运行getRandomEntry()。如果请求插入数据库,我们在try/catch块中执行准备好的语句。无论如何,我们使用yield关键字将这个方法转换为生成器:
for ($x = 0; $x < $howMany; $x++) {
  $entry = $this->getRandomEntry();
  if ($insert) {
    try {
        $stmt->execute($entry);
    } catch (PDOException $e) {
        error_log(__METHOD__ . ':' . $e->getMessage());
        throw new Exception(self::ERROR_DB);
    }
  }
  yield $entry;
}
}

提示

最佳实践

如果要返回的数据量很大,最好在生成数据时将数据作为生成的数据,从而节省数组所需的内存。

工作原理...

首先要做的是确保你已经准备好了随机数据生成的数据。在这个示例中,我们假设目标表是prospects,其 SQL 数据库定义如步骤 7 所示。

作为名字的数据源,你可以创建名字和姓氏的文本文件。在这个示例中,我们将引用data/files目录和文件first_names.txtsurnames.txt。对于城市、州或省、邮政编码和国家,可能有必要从www.geonames.org/这样的来源下载数据,并上传到world_city_data表中。对于其余的字段,比如地址、电子邮件、状态等,你可以使用FakeData中内置的方法,或者定义回调函数。

接下来,请确保定义Application\Test\FakeData,添加步骤 8 到 29 中讨论的内容。完成后,创建一个名为chap_13_fake_data.php的调用程序,设置自动加载并使用适当的类。您还应该定义与数据库配置路径和文件名匹配的常量:

<?php
define('DB_CONFIG_FILE', __DIR__ . '/../config/db.config.php');
define('FIRST_NAME_FILE', __DIR__ . '/../data/files/first_names.txt');
define('LAST_NAME_FILE', __DIR__ . '/../data/files/surnames.txt');
require __DIR__ . '/../Application/Autoload/Loader.php';
Application\Autoload\Loader::init(__DIR__ . '/..');
use Application\Test\FakeData;
use Application\Database\Connection;

接下来,定义一个映射数组,该数组使用目标表(prospects)中的列名作为键。然后,您需要为sourcename和任何其他所需的参数定义子键。首先,'first_name'和'last_name'都将使用文件作为源,'name'指向文件的名称,'type'表示文本文件类型:

$mapping = [
  'first_name'   => ['source' => FakeData::SOURCE_FILE,
  'name'         => FIRST_NAME_FILE,
  'type'         => FakeData::FILE_TYPE_TXT],
  'last_name'    => ['source' => FakeData::SOURCE_FILE,
  'name'         => LAST_NAME_FILE,
  'type'         => FakeData::FILE_TYPE_TXT],

'address''email''last_updated'都使用内置方法作为数据源。最后两个还定义了要传递的参数:

  'address'      => ['source' => FakeData::SOURCE_METHOD,
  'name'         => 'getAddress'],
  'email'        => ['source' => FakeData::SOURCE_METHOD,
  'name'         => 'getEmail',
  'params'       => ['first_name','last_name']],
  'last_updated' => ['source' => FakeData::SOURCE_METHOD,
  'name'         => 'getDate',
  'params'       => [date('Y-m-d'), 365*5]]

'phone''status''budget'都可以使用回调来提供虚假数据:

  'phone'        => ['source' => FakeData::SOURCE_CALLBACK,
  'name'         => function () {
                    return sprintf('%3d-%3d-%4d', random_int(101,999),
                    random_int(101,999), random_int(0,9999)); }],
  'status'       => ['source' => FakeData::SOURCE_CALLBACK,
  'name'         => function () { $status = ['BEG','INT','ADV']; 
                    return $status[rand(0,2)]; }],
  'budget'       => ['source' => FakeData::SOURCE_CALLBACK,
                     'name' => function() { return random_int(0, 99999) 
                     + (random_int(0, 99) * .01); }]

最后,'city'从查找表中获取数据,该表还为'mapping'参数中列出的字段提供数据。然后,您可以将这些键未定义。请注意,您还应指定表示表的主键的列:

'city' => ['source' => FakeData::SOURCE_TABLE,
'name' => 'world_city_data',
'idCol' => 'id',
'mapping' => [
'city' => 'city', 
'state_province' => 'state_province',
'postal_code_prefix' => 'postal_code', 
'iso2' => 'country']
],
  'state_province'=> [],
  'postal_code'  => [],
  'country'    => [],
];

然后,您可以定义目标表、Connection实例,并创建FakeData实例。foreach()循环足以显示给定数量的条目:

$destTableName = 'prospects';
$conn = new Connection(include DB_CONFIG_FILE);
$fake = new FakeData($conn, $mapping);
foreach ($fake->generateData(10) as $row) {
  echo implode(':', $row) . PHP_EOL;
}

对于 10 行,输出将如下所示:

工作原理...

还有更多...

以下是各种数据列表的网站摘要,这些数据列表在生成测试数据时可能有用:

数据类型 URL 备注
名字 nameberry.com/
www.babynamewizard.com/international-names-lists-popular-names-from-around-the-world
原始姓名列表 deron.meranda.us/data/census-dist-female-first.txt 美国女性名字
deron.meranda.us/data/census-dist-male-first.txt 美国男性名字
www.avss.ucsb.edu/NameFema.HTM 美国女性名字
www.avss.ucsb.edu/namemal.htm 美国男性名字
姓氏 names.mongabay.com/data/1000.html 美国人口普查中的美国姓氏
surname.sofeminine.co.uk/w/surnames/most-common-surnames-in-great-britain.html 英国姓氏
gist.github.com/subodhghulaxe/8148971 以 PHP 数组形式列出的美国姓氏列表
www.dutchgenealogy.nl/tng/surnames-all.php 荷兰姓氏
www.worldvitalrecords.com/browsesurnames.aspx?l=A 国际姓氏;只需更改最后一个或多个字母,即可获得以该字母开头的名字列表
城市 www.travelgis.com/default.asp?framesrc=/cities/ 世界城市
www.maxmind.com/en/free-world-cities-database
github.com/David-Haim/CountriesToCitiesJSON
www.fallingrain.com/world/index.html
邮政编码 boutell.com/zipcodes/ 仅限美国;包括城市、邮政编码、纬度和经度
www.geonames.org/export/ 国际; 城市名称,邮政编码,一切!; 免费下载

使用 session_start 参数自定义会话

直到 PHP 7,为了覆盖php.ini设置以进行安全会话管理,您必须使用一系列ini_set()命令。这种方法非常恼人,因为您还需要知道哪些设置是可用的,并且很难在其他应用程序中重复使用相同的设置。然而,从 PHP 7 开始,您可以向session_start()命令提供一系列参数,这将立即设置这些值。

如何做...

  1. 我们首先开发一个Application\Security\SessOptions类,该类将保存会话参数,并且还具有启动会话的能力。我们还定义了一个类常量,以防传递无效的会话选项:
namespace Application\Security;
use ReflectionClass;
use InvalidArgumentsException;
class SessOptions
{
  const ERROR_PARAMS = 'ERROR: invalid session options';
  1. 接下来,我们扫描php.ini会话指令列表(在php.net/manual/en/session.configuration.php中记录)。我们特别寻找Changeable列中标记为PHP_INI_ALL的指令。这些指令可以在运行时被覆盖,因此可以作为session_start()的参数使用:如何做...

  2. 然后,我们将这些定义为类常量,这将使该类更易于开发。大多数优秀的代码编辑器都能够扫描类并为您提供常量列表,从而轻松管理会话设置。请注意,并非所有设置都显示在书中,以节省空间:

const SESS_OP_NAME         = 'name';
const SESS_OP_LAZY_WRITE   = 'lazy_write';  // AVAILABLE // SINCE PHP 7.0.0.
const SESS_OP_SAVE_PATH    = 'save_path';
const SESS_OP_SAVE_HANDLER = 'save_handler';
// etc.
  1. 然后,我们可以定义构造函数,它接受一个php.ini会话设置数组作为参数。我们使用ReflectionClass来获取类常量列表,并通过循环运行$options参数来确认设置是否允许。还要注意使用array_flip(),它可以翻转键和值,以便我们的类常量的实际值形成数组键,类常量的名称成为值:
protected $options;
protected $allowed;
public function __construct(array $options)
{
  $reflect = new ReflectionClass(get_class($this));
  $this->allowed = $reflect->getConstants();
  $this->allowed = array_flip($this->allowed);
  unset($this->allowed[self::ERROR_PARAMS]);
  foreach ($options as $key => $value) {
    if(!isset($this->allowed[$key])) {
      error_log(__METHOD__ . ':' . self::ERROR_PARAMS);
      throw new InvalidArgumentsException(
      self::ERROR_PARAMS);
    }
  }
  $this->options = $options;
}
  1. 最后,我们以另外两种方法结束;一种方法让我们可以从外部访问允许的参数,另一种方法启动会话:
public function getAllowed()
{
  return $this->allowed;
}

public function start()
{
  session_start($this->options);
}

它是如何工作的...

将本章讨论的所有代码放入Application\Security目录中的SessOptions.php文件中。然后,您可以定义一个名为chap_13_session_options.php的调用程序来测试新类,该程序设置自动加载并使用该类:

<?php
require __DIR__ . '/../Application/Autoload/Loader.php';
Application\Autoload\Loader::init(__DIR__ . '/..');
use Application\Security\SessOptions;

接下来,定义一个数组,使用类常量作为键,所需的值来管理会话。请注意,在此处显示的示例中,会话信息存储在一个名为session的子目录中,您需要创建该目录:

$options = [
  SessOptions::SESS_OP_USE_ONLY_COOKIES => 1,
  SessOptions::SESS_OP_COOKIE_LIFETIME => 300,
  SessOptions::SESS_OP_COOKIE_HTTPONLY => 1,
  SessOptions::SESS_OP_NAME => 'UNLIKELYSOURCE',
  SessOptions::SESS_OP_SAVE_PATH => __DIR__ . '/session'
];

现在,您可以创建SessOptions实例并运行start()来启动会话。您可以在此处使用phpinfo()显示有关会话的一些信息:

$sessOpt = new SessOptions($options);
$sessOpt->start();
$_SESSION['test'] = 'TEST';
phpinfo(INFO_VARIABLES);

如果您使用浏览器的开发人员工具查找有关 cookie 的信息,您会注意到名称设置为UNLIKELYSOURCE,到期时间是从现在开始的 5 分钟:

它是如何工作的...

如果您扫描会话目录,您会看到会话信息已存储在那里:

它是如何工作的...

另请参阅...

附录 A. 定义 PSR-7 类

在本附录中,我们将涵盖以下主题:

  • 实现 PSR-7 值对象类

  • 开发一个 PSR-7 请求类

  • 定义一个 PSR-7 响应类

介绍

PHP 标准建议编号 7PSR-7)定义了许多接口,但没有提供实际的实现。因此,我们需要定义具体的代码实现,以开始创建自定义中间件。

实现 PSR-7 值对象类

为了处理 PSR-7 请求和响应,我们首先需要定义一系列值对象。这些是代表在基于 Web 的活动中使用的逻辑对象的类,如 URI、文件上传和流式请求或响应主体。

准备工作

PSR-7 接口的源代码可作为Composer包使用。使用Composer来管理外部软件,包括 PSR-7 接口,被认为是最佳实践。

如何做到这一点...

  1. 首先,转到以下 URL 以获取 PSR-7 接口定义的最新版本:github.com/php-fig/http-message。源代码也可用。在撰写本文时,以下定义可用:
接口 扩展 注释 方法处理的内容
MessageInterface 定义 HTTP 消息的公共方法 标头、消息主体(即内容)和协议
RequestInterface MessageInterface 代表客户端生成的请求 URI、HTTP 方法和请求目标
ServerRequestInterface RequestInterface 代表来自客户端的服务器请求 服务器和查询参数、cookie、上传的文件和解析的主体
ResponseInterface MessageInterface 代表服务器对客户端的响应 HTTP 状态码和原因
StreamInterface 代表数据流 流式行为,如 seek、tell、read、write 等
UriInterface 代表 URI 方案(即 HTTP、HTTPS)、主机、端口、用户名、密码(即 FTP)、查询参数、路径和片段
UploadedFileInterface 处理上传的文件 文件大小、媒体类型、移动文件和文件名
  1. 不幸的是,我们需要创建实现这些接口的具体类,以利用 PSR-7。幸运的是,接口类在内部通过一系列注释进行了广泛的文档化。我们将从一个包含有用常量的单独类开始:

提示

请注意,我们利用了 PHP 7 中引入的一个新功能,允许我们将常量定义为数组。

namespace Application\MiddleWare;
class Constants
{
  const HEADER_HOST   = 'Host';     // host header
  const HEADER_CONTENT_TYPE = 'Content-Type';
  const HEADER_CONTENT_LENGTH = 'Content-Length';

  const METHOD_GET    = 'get';
  const METHOD_POST   = 'post';
  const METHOD_PUT    = 'put';
  const METHOD_DELETE = 'delete';
  const HTTP_METHODS  = ['get','put','post','delete'];

  const STANDARD_PORTS = [
    'ftp' => 21, 'ssh' => 22, 'http' => 80, 'https' => 443
  ];

  const CONTENT_TYPE_FORM_ENCODED = 
    'application/x-www-form-urlencoded';
  const CONTENT_TYPE_MULTI_FORM   = 'multipart/form-data';
  const CONTENT_TYPE_JSON         = 'application/json';
  const CONTENT_TYPE_HAL_JSON     = 'application/hal+json';

  const DEFAULT_STATUS_CODE    = 200;
  const DEFAULT_BODY_STREAM    = 'php://input';
  const DEFAULT_REQUEST_TARGET = '/';

  const MODE_READ = 'r';
  const MODE_WRITE = 'w';

  // NOTE: not all error constants are shown to conserve space
  const ERROR_BAD = 'ERROR: ';
  const ERROR_UNKNOWN = 'ERROR: unknown';

  // NOTE: not all status codes are shown here!
  const STATUS_CODES = [
    200 => 'OK',
    301 => 'Moved Permanently',
    302 => 'Found',
    401 => 'Unauthorized',
    404 => 'Not Found',
    405 => 'Method Not Allowed',
    418 => 'I_m A Teapot',
    500 => 'Internal Server Error',
  ];
}

注意

HTTP 状态码的完整列表可以在这里找到:tools.ietf.org/html/rfc7231#section-6.1

  1. 接下来,我们将处理代表其他 PSR-7 类使用的值对象的类。首先,这是代表 URI 的类。在构造函数中,我们接受一个 URI 字符串作为参数,并使用parse_url()函数将其分解为其组件部分:
namespace Application\MiddleWare;
use InvalidArgumentException;
use Psr\Http\Message\UriInterface;
class Uri implements UriInterface
{
  protected $uriString;
  protected $uriParts = array();

  public function __construct($uriString)
  {
    $this->uriParts = parse_url($uriString);
    if (!$this->uriParts) {
      throw new InvalidArgumentException(
        Constants::ERROR_INVALID_URI);
    }
    $this->uriString = $uriString;
  }

注意

URI代表统一资源标识符。这是在发出请求时在浏览器顶部看到的内容。有关 URI 的构成,请参阅tools.ietf.org/html/rfc3986

  1. 在构造函数之后,我们定义了访问 URI 组件部分的方法。方案代表 PHP 包装器(即 HTTP、FTP 等):
public function getScheme()
{
  return strtolower($this->uriParts['scheme']) ?? '';
}
  1. 权限代表用户名(如果存在)、主机和可选的端口号:
public function getAuthority()
{
  $val = '';
  if (!empty($this->getUserInfo()))
  $val .= $this->getUserInfo() . '@';
  $val .= $this->uriParts['host'] ?? '';
  if (!empty($this->uriParts['port']))
  $val .= ':' . $this->uriParts['port'];
  return $val;
}
  1. 用户信息代表用户名(如果存在)和可选的密码。使用密码的一个例子是访问 FTP 网站,如ftp://username:password@website.com:/path
public function getUserInfo()
{
  if (empty($this->uriParts['user'])) {
    return '';
  }
  $val = $this->uriParts['user'];
  if (!empty($this->uriParts['pass']))
    $val .= ':' . $this->uriParts['pass'];
  return $val;
}
  1. 主机是 URI 中包含的 DNS 地址:
public function getHost()
{
  if (empty($this->uriParts['host'])) {
    return '';
  }
  return strtolower($this->uriParts['host']);
}
  1. Port是 HTTP 端口,如果存在的话。您会注意到,如果端口在我们的STANDARD_PORTS常量中列出,返回值是NULL,根据 PSR-7 的要求:
public function getPort()
{
  if (empty($this->uriParts['port'])) {
      return NULL;
  } else {
      if ($this->getScheme()) {
          if ($this->uriParts['port'] == 
              Constants::STANDARD_PORTS[$this->getScheme()]) {
              return NULL;
          }
      }
      return (int) $this->uriParts['port'];
  }
}
  1. Path是跟随 DNS 地址的 URI 的一部分。根据 PSR-7,这必须进行编码。我们使用rawurlencode() PHP 函数,因为它符合 RFC 3986。然而,我们不能只对整个路径进行编码,因为路径分隔符(即/)也会被编码!因此,我们需要首先使用explode()将其分解,对部分进行编码,然后重新组装:
public function getPath()
{
  if (empty($this->urlParts['path'])) {
    return '';
  }
  return implode('/', array_map("rawurlencode", explode('/', $this->urlParts['path'])));
}
  1. 接下来,我们定义一个方法来检索query字符串(即来自$_GET)。这些也必须进行 URL 编码。首先,我们定义了getQueryParams(),它将查询字符串分解为关联数组。您会注意到重置选项,以防我们希望刷新查询参数。然后我们定义了getQuery(),它接受数组并生成一个正确的 URL 编码字符串:
public function getQueryParams($reset = FALSE)
{
  if ($this->queryParams && !$reset) {
    return $this->queryParams;
  }
  $this->queryParams = [];
  if (!empty($this->uriParts['query'])) {
    foreach (explode('&', $this->uriParts['query']) as $keyPair) {
      list($param,$value) = explode('=',$keyPair);
      $this->queryParams[$param] = $value;
    }
  }
  return $this->queryParams;
}

public function getQuery()
{
  if (!$this->getQueryParams()) {
    return '';
  }
  $output = '';
  foreach ($this->getQueryParams() as $key => $value) {
    $output .= rawurlencode($key) . '=' 
    . rawurlencode($value) . '&';
  }
  return substr($output, 0, -1);
}
  1. 之后,我们提供了一个方法来返回fragment(即 URI 中的#)以及其后的任何部分:
public function getFragment()
{
  if (empty($this->urlParts['fragment'])) {
    return '';
  }
  return rawurlencode($this->urlParts['fragment']);
}
  1. 接下来,我们定义了一系列withXXX()方法,与上面描述的getXXX()方法相匹配。这些方法旨在添加、替换或删除与请求类相关的属性(scheme、authority、user info 等)。此外,这些方法返回当前实例,允许我们在一系列连续调用中使用这些方法(通常称为流畅接口)。我们从withScheme()开始:

注意

根据 PSR-7,空参数表示删除该属性。您还会注意到,我们不允许与我们的Constants::STANDARD_PORTS数组中定义的不匹配的方案。

public function withScheme($scheme)
{
  if (empty($scheme) && $this->getScheme()) {
      unset($this->uriParts['scheme']);
  } else {
      if (isset(STANDARD_PORTS[strtolower($scheme)])) {
          $this->uriParts['scheme'] = $scheme;
      } else {
          throw new InvalidArgumentException(Constants::ERROR_BAD . __METHOD__);
      }
  }
  return $this;
}
  1. 然后,我们对覆盖、添加或替换用户信息、主机、端口、路径、查询和片段的方法应用类似的逻辑。请注意,withQuery()方法会重置查询参数数组。withHost()withPort()withPath()withFragment()使用相同的逻辑,但未显示以节省空间:
public function withUserInfo($user, $password = null)
{
  if (empty($user) && $this->getUserInfo()) {
      unset($this->uriParts['user']);
  } else {
      $this->urlParts['user'] = $user;
      if ($password) {
          $this->urlParts['pass'] = $password;
      }
  }
  return $this;
}
// Not shown: withHost(),withPort(),withPath(),withFragment()

public function withQuery($query)
{
  if (empty($query) && $this->getQuery()) {
      unset($this->uriParts['query']);
  } else {
      $this->uriParts['query'] = $query;
  }
  // reset query params array
  $this->getQueryParams(TRUE);
  return $this;
}
  1. 最后,我们用__toString()包装Application\MiddleWare\Uri类,当对象在字符串上下文中使用时,返回一个由$uriParts组装而成的正确 URI。我们还定义了一个方便的方法getUriString(),它只是调用__toString()
public function __toString()
{
    $uri = ($this->getScheme())
      ? $this->getScheme() . '://' : '';
  1. 如果authority URI 部分存在,我们会添加它。authority包括用户信息、主机和端口。否则,我们只是附加hostport
if ($this->getAuthority()) {
    $uri .= $this->getAuthority();
} else {
    $uri .= ($this->getHost()) ? $this->getHost() : '';
    $uri .= ($this->getPort())
      ? ':' . $this->getPort() : '';
}
  1. 在添加path之前,我们首先检查第一个字符是否为/。如果不是,我们需要添加这个分隔符。然后,如果存在,我们添加queryfragment
$path = $this->getPath();
if ($path) {
    if ($path[0] != '/') {
        $uri .= '/' . $path;
    } else {
        $uri .= $path;
    }
}
$uri .= ($this->getQuery())
  ? '?' . $this->getQuery() : '';
$uri .= ($this->getFragment())
  ? '#' . $this->getFragment() : '';
return $uri;
}

public function getUriString()
{
  return $this->__toString();
}

}

注意

请注意字符串解引用的使用(即$path[0]),这是 PHP 7 的一部分。

  1. 接下来,我们将注意力转向表示消息正文的类。由于不知道正文可能有多大,PSR-7 建议将正文视为。流是一种允许以线性方式访问输入和输出源的资源。在 PHP 中,所有文件命令都是在Streams子系统之上运行的,因此这是一个自然的选择。PSR-7 通过Psr\Http\Message\StreamInterface来规范化这一点,该接口定义了read()write()seek()等方法。我们现在介绍Application\MiddleWare\Stream,我们可以用它来表示传入或传出请求和/或响应的正文:
namespace Application\MiddleWare;
use SplFileInfo;
use Throwable;
use RuntimeException;
use Psr\Http\Message\StreamInterface;
class Stream implements StreamInterface
{
  protected $stream;
  protected $metadata;
  protected $info;
  1. 在构造函数中,我们使用简单的fopen()命令打开流。然后我们使用stream_get_meta_data()获取流的信息。对于其他细节,我们创建一个SplFileInfo实例:
public function __construct($input, $mode = self::MODE_READ)
{
  $this->stream = fopen($input, $mode);
  $this->metadata = stream_get_meta_data($this->stream);
  $this->info = new SplFileInfo($input);
}

注意

我们选择fopen()而不是更现代的SplFileObject的原因是,后者不允许直接访问内部文件资源对象,因此对于这个应用程序是无用的。

  1. 我们包括两个方便的方法,提供对资源的访问,以及对SplFileInfo实例的访问:
public function getStream()
{
  return $this->stream;
}

public function getInfo()
{
  return $this->info;
}
  1. 接下来,我们定义了低级核心流方法:
public function read($length)
{
  if (!fread($this->stream, $length)) {
      throw new RuntimeException(self::ERROR_BAD . __METHOD__);
  }
}
public function write($string)
{
  if (!fwrite($this->stream, $string)) {
      throw new RuntimeException(self::ERROR_BAD . __METHOD__);
  }
}
public function rewind()
{
  if (!rewind($this->stream)) {
      throw new RuntimeException(self::ERROR_BAD . __METHOD__);
  }
}
public function eof()
{
  return eof($this->stream);
}
public function tell()
{
  try {
      return ftell($this->stream);
  } catch (Throwable $e) {
      throw new RuntimeException(self::ERROR_BAD . __METHOD__);
  }
}
public function seek($offset, $whence = SEEK_SET)
{
  try {
      fseek($this->stream, $offset, $whence);
  } catch (Throwable $e) {
      throw new RuntimeException(self::ERROR_BAD . __METHOD__);
  }
}
public function close()
{
  if ($this->stream) {
    fclose($this->stream);
  }
}
public function detach()
{
  return $this->close();
}
  1. 我们还需要定义告诉我们有关流的信息方法:
public function getMetadata($key = null)
{
  if ($key) {
      return $this->metadata[$key] ?? NULL;
  } else {
      return $this->metadata;
  }
}
public function getSize()
{
  return $this->info->getSize();
}
public function isSeekable()
{
  return boolval($this->metadata['seekable']);
}
public function isWritable()
{
  return $this->stream->isWritable();
}
public function isReadable()
{
  return $this->info->isReadable();
}
  1. 遵循 PSR-7 指南,然后定义getContents()__toString()以便转储流的内容:
public function __toString()
{
  $this->rewind();
  return $this->getContents();
}

public function getContents()
{
  ob_start();
  if (!fpassthru($this->stream)) {
    throw new RuntimeException(self::ERROR_BAD . __METHOD__);
  }
  return ob_get_clean();
}
}
  1. 先前显示的Stream类的一个重要变体是TextStream,它专为主体是字符串(即以 JSON 编码的数组)而不是文件的情况而设计。由于我们需要确保传入的$input值绝对是字符串数据类型,因此我们在开标签后立即调用 PHP 7 严格类型。我们还标识了一个$pos属性(即位置),它将模拟文件指针,但实际上指向字符串中的位置:
<?php
declare(strict_types=1);
namespace Application\MiddleWare;
use Throwable;
use RuntimeException;
use SplFileInfo;
use Psr\Http\Message\StreamInterface;

class TextStream implements StreamInterface
{
  protected $stream;
  protected $pos = 0;
  1. 大多数方法都非常简单且不言自明。$stream属性是输入字符串:
public function __construct(string $input)
{
  $this->stream = $input;
}
public function getStream()
{
  return $this->stream;
}
  public function getInfo()
{
  return NULL;
}
public function getContents()
{
  return $this->stream;
}
public function __toString()
{
  return $this->getContents();
}
public function getSize()
{
  return strlen($this->stream);
}
public function close()
{
  // do nothing: how can you "close" string???
}
public function detach()
{
  return $this->close();  // that is, do nothing!
}
  1. 为了模拟流式行为,tell()eof()seek()等方法与$pos一起工作:
public function tell()
{
  return $this->pos;
}
public function eof()
{
  return ($this->pos == strlen($this->stream));
}
public function isSeekable()
{
  return TRUE;
}
public function seek($offset, $whence = NULL)
{
  if ($offset < $this->getSize()) {
      $this->pos = $offset;
  } else {
      throw new RuntimeException(
        Constants::ERROR_BAD . __METHOD__);
  }
}
public function rewind()
{
  $this->pos = 0;
}
public function isWritable()
{
  return TRUE;
}
  1. read()write()方法与$pos和子字符串一起工作:
public function write($string)
{
  $temp = substr($this->stream, 0, $this->pos);
  $this->stream = $temp . $string;
  $this->pos = strlen($this->stream);
}

public function isReadable()
{
  return TRUE;
}
public function read($length)
{
  return substr($this->stream, $this->pos, $length);
}
public function getMetadata($key = null)
{
  return NULL;
}

}
  1. 最后要介绍的值对象是Application\MiddleWare\UploadedFile。与其他类一样,我们首先定义代表文件上传方面的属性:
namespace Application\MiddleWare;
use RuntimeException;
use InvalidArgumentException;
use Psr\Http\Message\UploadedFileInterface;
class UploadedFile implements UploadedFileInterface
{

  protected $field;   // original name of file upload field
  protected $info;    // $_FILES[$field]
  protected $randomize;
  protected $movedName = '';
  1. 在构造函数中,我们允许定义文件上传表单字段的名称属性,以及$_FILES中的对应数组。我们添加最后一个参数来表示是否希望类在确认上传文件后生成新的随机文件名:
public function __construct($field, array $info, $randomize = FALSE)
{
  $this->field = $field;
  $this->info = $info;
  $this->randomize = $randomize;
}
  1. 接下来,我们为临时或移动的文件创建一个Stream类实例:
public function getStream()
{
  if (!$this->stream) {
      if ($this->movedName) {
          $this->stream = new Stream($this->movedName);
      } else {
          $this->stream = new Stream($info['tmp_name']);
      }
  }
  return $this->stream;
}
  1. moveTo()方法执行实际的文件移动。请注意,我们进行了广泛的安全检查,以帮助防止注入攻击。如果未启用随机化,则使用原始用户提供的文件名:
public function moveTo($targetPath)
{
  if ($this->moved) {
      throw new Exception(Constants::ERROR_MOVE_DONE);
  }
  if (!file_exists($targetPath)) {
      throw new InvalidArgumentException(Constants::ERROR_BAD_DIR);
  }
  $tempFile = $this->info['tmp_name'] ?? FALSE;
  if (!$tempFile || !file_exists($tempFile)) {
      throw new Exception(Constants::ERROR_BAD_FILE);
  }
  if (!is_uploaded_file($tempFile)) {
      throw new Exception(Constants::ERROR_FILE_NOT);
  }
  if ($this->randomize) {
      $final = bin2hex(random_bytes(8)) . '.txt';
  } else {
      $final = $this->info['name'];
  }
  $final = $targetPath . '/' . $final;
  $final = str_replace('//', '/', $final);
  if (!move_uploaded_file($tempFile, $final)) {
      throw new RuntimeException(Constants::ERROR_MOVE_UNABLE);
  }
  $this->movedName = $final;
  return TRUE;
}
  1. 然后,我们通过$info属性提供对$_FILES中返回的其他参数的访问。请注意,getClientFilename()getClientMediaType()的返回值应被视为不受信任,因为它们来自外部。我们还添加了一个返回移动后的文件名的方法:
public function getMovedName()
{
  return $this->movedName ?? NULL;
}
public function getSize()
{
  return $this->info['size'] ?? NULL;
}
public function getError()
{
  if (!$this->moved) {
      return UPLOAD_ERR_OK;
  }
  return $this->info['error'];
}
public function getClientFilename()
{
  return $this->info['name'] ?? NULL;
}
public function getClientMediaType()
{
  return $this->info['type'] ?? NULL;
}

}

它是如何工作的...

首先,转到github.com/php-fig/http-message/tree/master/src,PSR-7 接口的 GitHub 存储库,并下载它们。在/path/to/source中创建一个名为Psr/Http/Message的目录,并将文件放在那里。或者,您可以访问packagist.org/packages/psr/http-message并使用Composer安装源代码。(有关如何获取和使用Composer的说明,您可以访问getcomposer.org/。)

然后,继续定义先前讨论的类,总结在这个表中:

讨论中的步骤
Application\MiddleWare\Constants 2
Application\MiddleWare\Uri 3 to 16
Application\MiddleWare\Stream 17 to 22
Application\MiddleWare\TextStream 23 to 26
Application\MiddleWare\UploadedFile 27 to 31

接下来,定义一个调用程序chap_09_middleware_value_objects_uri.php,实现自动加载并使用适当的类。请注意,如果您使用Composer,除非另有说明,它将创建一个名为vendor的文件夹。Composer还会添加自己的自动加载程序,您可以在此处自由使用:

<?php
require __DIR__ . '/../Application/Autoload/Loader.php';
Application\Autoload\Loader::init(__DIR__ . '/..');
use Application\MiddleWare\Uri;

然后,您可以创建一个Uri实例,并使用with方法添加参数。然后,您可以直接将Uri实例作为__toString()定义的内容输出:

$uri = new Uri();
$uri->withScheme('https')
    ->withHost('localhost')
    ->withPort('8080')
    ->withPath('chap_09_middleware_value_objects_uri.php')
    ->withQuery('param=TEST');

echo $uri;

以下是预期结果:

它是如何工作的...

接下来,从/path/to/source/for/this/chapter创建一个名为uploads的目录。继续定义另一个调用程序chap_09_middleware_value_objects_file_upload.php,设置自动加载并使用适当的类:

<?php
define('TARGET_DIR', __DIR__ . '/uploads');
require __DIR__ . '/../Application/Autoload/Loader.php';
Application\Autoload\Loader::init(__DIR__ . '/..');
use Application\MiddleWare\UploadedFile;

try...catch块内,检查是否上传了任何文件。如果是这样,循环遍历$_FILES并创建UploadedFile实例,其中设置了tmp_name。然后可以使用moveTo()方法将文件移动到TARGET_DIR

try {
    $message = '';
    $uploadedFiles = array();
    if (isset($_FILES)) {
        foreach ($_FILES as $key => $info) {
          if ($info['tmp_name']) {
              $uploadedFiles[$key] = new UploadedFile($key, $info, TRUE);
              $uploadedFiles[$key]->moveTo(TARGET_DIR);
          }
        }
    }
} catch (Throwable $e) {
    $message =  $e->getMessage();
}
?>

在视图逻辑中,显示一个简单的文件上传表单。您还可以使用phpinfo()来显示有关上传内容的信息:

<form name="search" method="post" enctype="<?= Constants::CONTENT_TYPE_MULTI_FORM ?>">
<table class="display" cellspacing="0" width="100%">
    <tr><th>Upload 1</th><td><input type="file" name="upload_1" /></td></tr>
    <tr><th>Upload 2</th><td><input type="file" name="upload_2" /></td></tr>
    <tr><th>Upload 3</th><td><input type="file" name="upload_3" /></td></tr>
    <tr><th>&nbsp;</th><td><input type="submit" /></td></tr>
</table>
</form>
<?= ($message) ? '<h1>' . $message . '</h1>' : ''; ?>

接下来,如果有任何上传的文件,您可以显示每个文件的信息。您还可以使用getStream(),然后使用getContents()来显示每个文件(假设您正在使用短文本文件):

<?php if ($uploadedFiles) : ?>
<table class="display" cellspacing="0" width="100%">
    <tr>
        <th>Filename</th><th>Size</th>
      <th>Moved Filename</th><th>Text</th>
    </tr>
    <?php foreach ($uploadedFiles as $obj) : ?>
        <?php if ($obj->getMovedName()) : ?>
        <tr>
            <td><?= htmlspecialchars($obj->getClientFilename()) ?></td>
            <td><?= $obj->getSize() ?></td>
            <td><?= $obj->getMovedName() ?></td>
            <td><?= $obj->getStream()->getContents() ?></td>
        </tr>
        <?php endif; ?>
    <?php endforeach; ?>
</table>
<?php endif; ?>
<?php phpinfo(INFO_VARIABLES); ?>

以下是输出可能会出现的方式:

它是如何工作的...

另请参阅

开发 PSR-7 请求类

PSR-7 中间件的一个关键特征是使用RequestResponse类。应用时,这使得软件的不同块可以一起执行,而不共享它们之间的任何特定知识。在这种情况下,请求类应该包括原始用户请求的所有方面,包括浏览器设置、原始请求的 URL、传递的参数等。

如何做...

  1. 首先,确保定义类来表示UriStreamUploadedFile值对象,如前一篇中所述。

  2. 现在我们准备定义核心的Application\MiddleWare\Message类。这个类使用StreamUri,并实现Psr\Http\Message\MessageInterface。我们首先为键值对象定义属性,包括表示消息正文(即StreamInterface实例)、版本和 HTTP 标头的属性:

namespace Application\MiddleWare;
use Psr\Http\Message\ { 
  MessageInterface, 
  StreamInterface, 
  UriInterface 
};
class Message implements MessageInterface
{
  protected $body;
  protected $version;
  protected $httpHeaders = array();
  1. 接下来,我们有一个代表StreamInterface实例的getBody()方法。一个伴随方法withBody()返回当前的Message实例,并允许我们覆盖body的当前值:
public function getBody()
{
  if (!$this->body) {
      $this->body = new Stream(self::DEFAULT_BODY_STREAM);
  }
  return $this->body;
}
public function withBody(StreamInterface $body)
{
  if (!$body->isReadable()) {
      throw new InvalidArgumentException(self::ERROR_BODY_UNREADABLE);
  }
  $this->body = $body;
  return $this;
}
  1. PSR-7 建议将标头视为不区分大小写。因此,我们定义了一个findHeader()方法(不是直接由MessageInterface定义的),它使用stripos()来定位标头:
protected function findHeader($name)
{
  $found = FALSE;
  foreach (array_keys($this->getHeaders()) as $header) {
    if (stripos($header, $name) !== FALSE) {
        $found = $header;
        break;
    }
  }
  return $found;
}
  1. 下一个方法,不是由 PSR-7 定义的,旨在填充$httpHeaders属性。假定此属性是一个关联数组,其中键是标头,值是表示标头值的字符串。如果有多个值,则用逗号分隔的附加值附加到字符串中。如果$httpHeaders中没有可用的标头,有一个很好的apache_request_headers() PHP 函数来自 Apache 扩展,可以生成标头:
protected function getHttpHeaders()
{
  if (!$this->httpHeaders) {
      if (function_exists('apache_request_headers')) {
          $this->httpHeaders = apache_request_headers();
      } else {
          $this->httpHeaders = $this->altApacheReqHeaders();
      }
  }
  return $this->httpHeaders;
}
  1. 如果apache_request_headers()不可用(即,Apache 扩展未启用),我们提供一个替代方案,altApacheReqHeaders()
protected function altApacheReqHeaders()
{
  $headers = array();
  foreach ($_SERVER as $key => $value) {
    if (stripos($key, 'HTTP_') !== FALSE) {
        $headerKey = str_ireplace('HTTP_', '', $key);
        $headers[$this->explodeHeader($headerKey)] = $value;
    } elseif (stripos($key, 'CONTENT_') !== FALSE) {
        $headers[$this->explodeHeader($key)] = $value;
    }
  }
  return $headers;
}
protected function explodeHeader($header)
{
  $headerParts = explode('_', $header);
  $headerKey = ucwords(implode(' ', strtolower($headerParts)));
  return str_replace(' ', '-', $headerKey);
}
  1. 实现getHeaders()(在 PSR-7 中是必需的)现在是通过getHttpHeaders()方法产生的$httpHeaders属性的一个微不足道的循环:
public function getHeaders()
{
  foreach ($this->getHttpHeaders() as $key => $value) {
    header($key . ': ' . $value);
  }
}
  1. 同样,我们提供了一系列with方法,用于覆盖或替换标头。由于可能有许多标头,我们还有一个方法,用于添加到现有的标头集。withoutHeader()方法用于删除标头实例。请注意,在前一步骤中提到的findHeader()的一致使用,以允许对标头进行不区分大小写的处理:
public function withHeader($name, $value)
{
  $found = $this->findHeader($name);
  if ($found) {
      $this->httpHeaders[$found] = $value;
  } else {
      $this->httpHeaders[$name] = $value;
  }
  return $this;
}

public function withAddedHeader($name, $value)
{
  $found = $this->findHeader($name);
  if ($found) {
      $this->httpHeaders[$found] .= $value;
  } else {
      $this->httpHeaders[$name] = $value;
  }
  return $this;
}

public function withoutHeader($name)
{
  $found = $this->findHeader($name);
  if ($found) {
      unset($this->httpHeaders[$found]);
  }
  return $this;
}
  1. 然后,我们提供了一系列有用的与标头相关的方法,以确认标头是否存在,检索单个标头行,并按照 PSR-7 的要求以数组形式检索标头:
public function hasHeader($name)
{
  return boolval($this->findHeader($name));
}

public function getHeaderLine($name)
{
  $found = $this->findHeader($name);
  if ($found) {
      return $this->httpHeaders[$found];
  } else {
      return '';
  }
}

public function getHeader($name)
{
  $line = $this->getHeaderLine($name);
  if ($line) {
      return explode(',', $line);
  } else {
      return array();
  }
}
  1. 最后,为了完成头处理,我们提供了getHeadersAsString,它生成一个由\r\n分隔的头字符串,以便直接在 PHP 流上下文中使用:
public function getHeadersAsString()
{
  $output = '';
  $headers = $this->getHeaders();
  if ($headers && is_array($headers)) {
      foreach ($headers as $key => $value) {
        if ($output) {
            $output .= "\r\n" . $key . ': ' . $value;
        } else {
            $output .= $key . ': ' . $value;
        }
      }
  }
  return $output;
}
  1. Message类中,我们现在将注意力转向版本处理。根据 PSR-7,协议版本(即 HTTP/1.1)的返回值应该只是数字部分。因此,我们还提供了onlyVersion(),它会去掉任何非数字字符,包括句点:
public function getProtocolVersion()
{
  if (!$this->version) {
      $this->version = $this->onlyVersion($_SERVER['SERVER_PROTOCOL']);
  }
  return $this->version;
}

public function withProtocolVersion($version)
{
  $this->version = $this->onlyVersion($version);
  return $this;
}

protected function onlyVersion($version)
{
  if (!empty($version)) {
      return preg_replace('/[⁰-9\.]/', '', $version);
  } else {
      return NULL;
  }
}

}
  1. 最后,几乎可以说是一个反高潮,我们准备定义我们的Request类。然而,需要注意的是,我们需要考虑出站请求和入站请求。也就是说,我们需要一个类来表示客户端将向服务器发出的出站请求,以及服务器从客户端接收的请求。因此,我们提供Application\MiddleWare\Request(客户端将向服务器发出的请求)和Application\MiddleWare\ServerRequest(服务器从客户端接收的请求)。好消息是,我们的大部分工作已经完成:注意我们的Request类扩展了Message。我们还提供了表示 URI 和 HTTP 方法的属性:
namespace Application\MiddleWare;

use InvalidArgumentException;
use Psr\Http\Message\ { RequestInterface, StreamInterface, UriInterface };

class Request extends Message implements RequestInterface
{
  protected $uri;
  protected $method; // HTTP method
  protected $uriObj; // Psr\Http\Message\UriInterface instance
  1. 构造函数中的所有属性默认为NULL,但我们留下了立即定义适当参数的可能性。我们使用继承的onlyVersion()方法来清理版本。我们还定义了checkMethod()来确保提供的任何方法都在我们支持的 HTTP 方法列表中,该列表在Constants中定义为常量数组:
public function __construct($uri = NULL,
                            $method = NULL,
                            StreamInterface $body = NULL,
                            $headers = NULL,
                            $version = NULL)
{
  $this->uri = $uri;
  $this->body = $body;
  $this->method = $this->checkMethod($method);
  $this->httpHeaders = $headers;
  $this->version = $this->onlyVersion($version);
}
protected function checkMethod($method)
{
  if (!$method === NULL) {
      if (!in_array(strtolower($method), Constants::HTTP_METHODS)) {
          throw new InvalidArgumentException(Constants::ERROR_HTTP_METHOD);
      }
  }
  return $method;
}
  1. 我们将解释请求目标为最初请求的 URI 的字符串形式。请记住,我们的Uri类有方法可以将其解析为其组成部分,因此我们提供了$uriObj属性。在withRequestTarget()的情况下,注意我们运行了执行前述解析过程的getUri()
public function getRequestTarget()
{
  return $this->uri ?? Constants::DEFAULT_REQUEST_TARGET;
}

public function withRequestTarget($requestTarget)
{
  $this->uri = $requestTarget;
  $this->getUri();
  return $this;
}
  1. 我们的getwith方法代表 HTTP 方法,没有什么意外。我们使用在构造函数中使用的checkMethod()来确保方法与我们计划支持的方法匹配:
public function getMethod()
{
  return $this->method;
}

public function withMethod($method)
{
  $this->method = $this->checkMethod($method);
  return $this;
}
  1. 最后,我们为 URI 提供了getwith方法。如第 14 步中所述,我们保留了$uri属性中的原始请求字符串,并在$uriObj中保留了新解析的Uri实例。注意额外的标志以保留任何现有的Host头:
public function getUri()
{
  if (!$this->uriObj) {
      $this->uriObj = new Uri($this->uri);
  }
  return $this->uriObj;
}

public function withUri(UriInterface $uri, $preserveHost = false)
{
  if ($preserveHost) {
    $found = $this->findHeader(Constants::HEADER_HOST);
    if (!$found && $uri->getHost()) {
      $this->httpHeaders[Constants::HEADER_HOST] = $uri->getHost();
    }
  } elseif ($uri->getHost()) {
      $this->httpHeaders[Constants::HEADER_HOST] = $uri->getHost();
  }
  $this->uri = $uri->__toString();
  return $this;
  }
}
  1. ServerRequest类扩展了Request,并提供了额外的功能来检索对服务器处理传入请求感兴趣的信息。我们首先定义将代表从各种 PHP$_ super-globals(即$_SERVER$_POST等)读取的传入数据的属性:
namespace Application\MiddleWare;
use Psr\Http\Message\ { ServerRequestInterface, UploadedFileInterface } ;

class ServerRequest extends Request implements ServerRequestInterface
{

  protected $serverParams;
  protected $cookies;
  protected $queryParams;
  protected $contentType;
  protected $parsedBody;
  protected $attributes;
  protected $method;
  protected $uploadedFileInfo;
  protected $uploadedFileObjs;
  1. 然后,我们定义了一系列 getter 来提取超全局信息。为了节省空间,我们没有展示所有内容:
public function getServerParams()
{
  if (!$this->serverParams) {
      $this->serverParams = $_SERVER;
  }
  return $this->serverParams;
}
// getCookieParams() reads $_COOKIE
// getQueryParams() reads $_GET
// getUploadedFileInfo() reads $_FILES

public function getRequestMethod()
{
  $method = $this->getServerParams()['REQUEST_METHOD'] ?? '';
  $this->method = strtolower($method);
  return $this->method;
}

public function getContentType()
{
  if (!$this->contentType) {
      $this->contentType = $this->getServerParams()['CONTENT_TYPE'] ?? '';
      $this->contentType = strtolower($this->contentType);
  }
  return $this->contentType;
}
  1. 由于上传的文件应该表示为独立的UploadedFile对象(在上一个示例中介绍),我们还定义了一个方法,该方法接受$uploadedFileInfo并创建UploadedFile对象:
public function getUploadedFiles()
{
  if (!$this->uploadedFileObjs) {
      foreach ($this->getUploadedFileInfo() as $field => $value) {
        $this->uploadedFileObjs[$field] = new UploadedFile($field, $value);
      }
  }
  return $this->uploadedFileObjs;
}
  1. 与先前定义的其他类一样,我们提供了with方法,用于添加或覆盖属性并返回新实例:
public function withCookieParams(array $cookies)
{
  array_merge($this->getCookieParams(), $cookies);
  return $this;
}
public function withQueryParams(array $query)
{
  array_merge($this->getQueryParams(), $query);
  return $this;
}
public function withUploadedFiles(array $uploadedFiles)
{
  if (!count($uploadedFiles)) {
      throw new InvalidArgumentException(Constant::ERROR_NO_UPLOADED_FILES);
  }
  foreach ($uploadedFiles as $fileObj) {
    if (!$fileObj instanceof UploadedFileInterface) {
        throw new InvalidArgumentException(Constant::ERROR_INVALID_UPLOADED);
    }
  }
  $this->uploadedFileObjs = $uploadedFiles;
}
  1. PSR-7 消息的一个重要方面是,消息体也应以解析的方式可用,也就是说,一种结构化表示,而不仅仅是原始流。因此,我们定义了getParsedBody()及其相应的with方法。PSR-7 的建议在涉及表单提交时非常具体。请注意一系列检查Content-Type头以及方法的if语句:
public function getParsedBody()
{
  if (!$this->parsedBody) {
      if (($this->getContentType() == Constants::CONTENT_TYPE_FORM_ENCODED
           || $this->getContentType() == Constants::CONTENT_TYPE_MULTI_FORM)
           && $this->getRequestMethod() == Constants::METHOD_POST)
      {
          $this->parsedBody = $_POST;
      } elseif ($this->getContentType() == Constants::CONTENT_TYPE_JSON
                || $this->getContentType() == Constants::CONTENT_TYPE_HAL_JSON)
      {
          ini_set("allow_url_fopen", true);
          $this->parsedBody = json_decode(file_get_contents('php://input'));
      } elseif (!empty($_REQUEST)) {
          $this->parsedBody = $_REQUEST;
      } else {
          ini_set("allow_url_fopen", true);
          $this->parsedBody = file_get_contents('php://input');
      }
  }
  return $this->parsedBody;
}

public function withParsedBody($data)
{
  $this->parsedBody = $data;
  return $this;
}
  1. 我们还允许不是在 PSR-7 中精确定义的属性。相反,我们保持开放,以便开发人员可以提供适用于应用程序的任何内容。注意使用withoutAttributes()可以随意删除属性:
public function getAttributes()
{
  return $this->attributes;
}
public function getAttribute($name, $default = NULL)
{
  return $this->attributes[$name] ?? $default;
}
public function withAttribute($name, $value)
{
  $this->attributes[$name] = $value;
  return $this;
}
public function withoutAttribute($name)
{
  if (isset($this->attributes[$name])) {
      unset($this->attributes[$name]);
  }
  return $this;
}

}
  1. 最后,为了从入站请求中加载不同的属性,我们定义了initialize(),这不在 PSR-7 中,但非常方便:
public function initialize()
{
  $this->getServerParams();
  $this->getCookieParams();
  $this->getQueryParams();
  $this->getUploadedFiles;
  $this->getRequestMethod();
  $this->getContentType();
  $this->getParsedBody();
  return $this;
}

工作原理...

首先,确保完成前面的配方,因为MessageRequest类消耗UriStreamUploadedFile值对象。之后,继续定义下表中总结的类:

讨论的步骤
Application\MiddleWare\Message 2 到 9
Application\MiddleWare\Request 10 到 14
Application\MiddleWare\ServerRequest 15 到 20

之后,您可以定义一个服务器程序chap_09_middleware_server.php,设置自动加载并使用适当的类。此脚本将把传入的请求拉入ServerRequest实例中,初始化它,然后使用var_dump()来显示接收到的信息:

<?php
require __DIR__ . '/../Application/Autoload/Loader.php';
Application\Autoload\Loader::init(__DIR__ . '/..');
use Application\MiddleWare\ServerRequest;

$request = new ServerRequest();
$request->initialize();
echo '<pre>', var_dump($request), '</pre>';

要运行服务器程序,首先切换到/path/to/source/for/this/chapter文件夹。然后运行以下命令:

**php -S localhost:8080 chap_09_middleware_server.php'**

至于客户端,首先创建一个调用程序chap_09_middleware_request.php,设置自动加载,使用适当的类,并定义目标服务器和本地文本文件:

<?php
define('READ_FILE', __DIR__ . '/gettysburg.txt');
define('TEST_SERVER', 'http://localhost:8080');
require __DIR__ . '/../Application/Autoload/Loader.php';
Application\Autoload\Loader::init(__DIR__ . '/..');
use Application\MiddleWare\ { Request, Stream, Constants };

接下来,您可以使用文本作为源创建一个Stream实例。这将成为一个新请求的主体,在这种情况下,它与表单提交所期望的相同:

$body = new Stream(READ_FILE);

然后,您可以直接构建一个Request实例,根据需要提供参数:

$request = new Request(
    TEST_SERVER,
    Constants::METHOD_POST,
    $body,
    [Constants::HEADER_CONTENT_TYPE => Constants::CONTENT_TYPE_FORM_ENCODED,Constants::HEADER_CONTENT_LENGTH => $body->getSize()]
);

或者,您可以使用流畅的接口语法来产生完全相同的结果:

$uriObj = new Uri(TEST_SERVER);
$request = new Request();
$request->withRequestTarget(TEST_SERVER)
        ->withMethod(Constants::METHOD_POST)
        ->withBody($body)
        ->withHeader(Constants::HEADER_CONTENT_TYPE, Constants::CONTENT_TYPE_FORM_ENCODED)
        ->withAddedHeader(Constants::HEADER_CONTENT_LENGTH, $body->getSize());

然后,您可以设置一个 cURL 资源来模拟表单提交,其中数据参数是文本文件的内容。您可以跟着curl_init()curl_exec()等,回显结果:

$data = http_build_query(['data' => $request->getBody()->getContents()]);
$defaults = array(
    CURLOPT_URL => $request->getUri()->getUriString(),
    CURLOPT_POST => true,
    CURLOPT_POSTFIELDS => $data,
);
$ch = curl_init();
curl_setopt_array($ch, $defaults);
$response = curl_exec($ch);
curl_close($ch);

以下是直接输出的样子:

工作原理...

另请参阅

定义 PSR-7 响应类

响应类表示返回给发出原始请求的实体的出站信息。在这种情况下,HTTP 标头起着重要作用,因为我们需要知道客户端请求的格式,通常是在传入的Accept标头中。然后,我们需要在响应类中设置适当的Content-Type标头以匹配该格式。否则,响应的实际主体将是 HTML、JSON 或其他已请求(并已传递)的内容。

如何做...

  1. Response类实际上比Request类更容易实现,因为我们只关心从服务器返回响应给客户端。此外,它扩展了我们的Application\MiddleWare\Message类,其中大部分工作已经完成。因此,唯一剩下的工作就是定义一个Application\MiddleWare\Response类。正如您将注意到的,唯一的独特属性是$statusCode
namespace Application\MiddleWare;
use Psr\Http\Message\ { Constants, ResponseInterface, StreamInterface };
class Response extends Message implements ResponseInterface
{
  protected $statusCode;
  1. 构造函数没有由 PSR-7 定义,但我们为方便起见提供了它,允许开发人员创建一个具有所有部分完整的Response实例。我们使用Message的方法和Constants类的常量来验证参数:
public function __construct($statusCode = NULL,
                            StreamInterface $body = NULL,
                            $headers = NULL,
                            $version = NULL)
{
  $this->body = $body;
  $this->status['code'] = $statusCode ?? Constants::DEFAULT_STATUS_CODE;
  $this->status['reason'] = Constants::STATUS_CODES[$statusCode] ?? '';
  $this->httpHeaders = $headers;
  $this->version = $this->onlyVersion($version);
  if ($statusCode) $this->setStatusCode();
}
  1. 我们提供了一种很好的方法来设置 HTTP 状态码,而不考虑任何标头,使用http_response_code(),从 PHP 5.4 开始可用。由于这项工作是在 PHP 7 上进行的,我们可以放心地知道这个方法是存在的:
public function setStatusCode()
{
  http_response_code($this->getStatusCode());
}
  1. 否则,有兴趣使用以下方法获取状态码:
public function getStatusCode()
{
  return $this->status['code'];
}
  1. 与早期的配方中讨论的其他基于 PSR-7 的类一样,我们还定义了一个with方法,用于设置状态码并返回当前实例。请注意使用STATUS_CODES来确认其存在:
public function withStatus($statusCode, $reasonPhrase = '')
{
  if (!isset(Constants::STATUS_CODES[$statusCode])) {
      throw new InvalidArgumentException(Constants::ERROR_INVALID_STATUS);
  }
  $this->status['code'] = $statusCode;
  $this->status['reason'] = ($reasonPhrase) ? Constants::STATUS_CODES[$statusCode] : NULL;
  $this->setStatusCode();
  return $this;
}
  1. 最后,我们定义一个返回 HTTP 状态原因的方法,这是一个简短的文本短语,在本例中基于 RFC 7231。请注意使用 PHP 7 的空合并运算符??,它返回三个可能选择中的第一个非空项:
public function getReasonPhrase()
{
  return $this->status['reason'] 
    ?? Constants::STATUS_CODES[$this->status['code']] 
    ?? '';
  }
}

工作原理...

首先,请确保定义了前两个配方中讨论的类。之后,您可以创建另一个简单的服务器程序chap_09_middleware_server_with_response.php,该程序设置自动加载并使用适当的类:

<?php
require __DIR__ . '/../Application/Autoload/Loader.php';
Application\Autoload\Loader::init(__DIR__ . '/..');
use Application\MiddleWare\ { Constants, ServerRequest, Response, Stream };

然后,您可以定义一个具有键/值对的数组,其中值指向当前目录中用作内容的文本文件:

$data = [
  1 => 'churchill.txt',
  2 => 'gettysburg.txt',
  3 => 'star_trek.txt'
];

接下来,在try...catch块内,您可以初始化一些变量,初始化服务器请求,并设置临时文件名:

try {

    $body['text'] = 'Initial State';
    $request = new ServerRequest();
    $request->initialize();
    $tempFile = bin2hex(random_bytes(8)) . '.txt';
    $code = 200;

之后,检查方法是 GET 还是 POST。如果是 GET,请检查是否传递了id参数。如果是,则返回匹配文本文件的正文。否则,返回文本文件列表:

if ($request->getMethod() == Constants::METHOD_GET) {
    $id = $request->getQueryParams()['id'] ?? NULL;
    $id = (int) $id;
    if ($id && $id <= count($data)) {
        $body['text'] = file_get_contents(
        __DIR__ . '/' . $data[$id]);
    } else {
        $body['text'] = $data;
    }

否则,返回一个表示成功代码 204 和接收到的请求体大小的响应:

} elseif ($request->getMethod() == Constants::METHOD_POST) {
    $size = $request->getBody()->getSize();
    $body['text'] = $size . ' bytes of data received';
    if ($size) {
        $code = 201;
    } else {
        $code = 204;
    }
}

然后,您可以捕获任何异常并报告它们,状态代码为 500:

} catch (Exception $e) {
    $code = 500;
    $body['text'] = 'ERROR: ' . $e->getMessage();
}

响应需要包装在流中,以便将正文写入临时文件并将其创建为Stream。您还可以将Content-Type标头设置为application/json并运行getHeaders(),这将输出当前设置的标头集。之后,回显响应的正文。对于此示例,您还可以转储Response实例以确认它是否正确构造:

try {
    file_put_contents($tempFile, json_encode($body));
    $body = new Stream($tempFile);
    $header[Constants::HEADER_CONTENT_TYPE] = 'application/json';
    $response = new Response($code, $body, $header);
    $response->getHeaders();
    echo $response->getBody()->getContents() . PHP_EOL;
    var_dump($response);

最后,捕获任何错误或异常使用Throwable,并不要忘记删除临时文件:

} catch (Throwable $e) {
    echo $e->getMessage();
} finally {
   unlink($tempFile);
}

要进行测试,只需打开终端窗口,切换到/path/to/source/for/this/chapter目录,并运行以下命令:

**php -S localhost:8080**

然后,您可以从浏览器调用此程序,添加一个id参数。您可能考虑打开开发人员工具以监视响应标头。以下是预期输出的示例。请注意application/json的内容类型:

工作原理...

另请参阅

  • 有关 PSR 的更多信息,请访问www.php-fig.org/psr/

  • 以下表总结了撰写时 PSR-7 兼容性的状态。未包括在此表中的框架要么根本不支持 PSR-7,要么缺乏 PSR-7 的文档。

Framework 网站 注释
Slim www.slimframework.com/docs/concepts/value-objects.html 高 PSR-7 兼容性
Laravel/Lumen lumen.laravel.com/docs/5.2/requests 高 PSR-7 兼容性
Zend Framework 3/Expressive framework.zend.com/blog/2016-06-28-zend-framework-3.htmlzendframework.github.io/zend-expressive/ 分别 高 PSR-7 兼容性 Also Diactoros, and Straigility
Zend Framework 2 github.com/zendframework/zend-psr7bridge 可用的 PSR-7 桥
Symfony symfony.com/doc/current/cookbook/psr7.html 可用的 PSR-7 桥
Joomla www.joomla.org 有限的 PSR-7 支持
Cake PHP mark-story.com/posts/view/psr7-bridge-for-cakephp PSR-7 支持在路线图中,并将使用桥接方法
  • 已经有许多 PSR-7 中间件类可用。以下表总结了一些较受欢迎的类:
Middleware 网站 注释
Guzzle github.com/guzzle/psr7 HTTP 消息库
Relay relayphp.com/ 调度器
Radar github.com/radarphp/Radar.Project 动作/领域/响应者骨架
NegotiationMiddleware github.com/rszrama/negotiation-middleware 内容协商
psr7-csrf-middleware packagist.org/packages/schnittstabil/psr7-csrf-middleware 跨站点请求伪造预防
oauth2-server alexbilbie.com/2016/04/league-oauth2-server-version-5-is-out 支持 PSR-7 的 OAuth2 服务器
zend-diactoros zendframework.github.io/zend-diactoros/ PSR-7 HTTP 消息实现
posted @ 2024-05-05 00:11  绝不原创的飞龙  阅读(3)  评论(0编辑  收藏  举报