PHP7-学习指南-全-

PHP7 学习指南(全)

原文:zh.annas-archive.org/md5/86a97a5355e4e4a2b43142a63393bf67

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

无需说明 Web 应用程序在我们的生活中有多么重要。我们使用 Web 应用程序来了解我们的朋友在做什么,获取有关政治的最新消息,检查我们最喜欢的足球队的比赛结果,或者从在线大学毕业。而且当你拿着这本书的时候,你已经知道构建这些应用程序不是只有一群天才才能完成的工作,而恰恰相反。

构建 Web 应用程序的方式不止一种;实际上,有相当多的语言和技术专门用于此目的。然而,如果有一种语言在历史上脱颖而出,或者因为它极其易于使用,那么它就是 PHP 及其生态系统中的所有工具。

互联网上充满了详细说明如何使用 PHP 的资源,那么为什么还要费心阅读这本书呢?这很简单。我们不会像官方网站那样提供 PHP 的完整文档。我们的目标不是让你获得 PHP 认证,而是教你真正需要知道的内容,以便你自己构建 Web 应用程序。从一开始,我们将使用提供的信息来构建应用程序,这样你可以注意为什么每条信息都是有用的。

然而,我们不会止步于此。我们不仅会向你展示语言能提供什么,还会讨论编写代码的最佳方法。你将学习任何 Web 开发人员都必须掌握的所有技术,从 OOP 和 MVC 等设计模式到测试。你甚至将与大型和小型公司用于他们自己项目的现有 PHP 框架一起工作。

简而言之,你将开始一段旅程,在这段旅程中,你将学习如何掌握 Web 开发,而不是如何掌握一种编程语言。我们希望你会喜欢它。

本书涵盖的内容

第一章,设置环境,将指导你安装所需的软件。

第二章,使用 PHP 构建 Web 应用程序,将介绍 Web 应用程序是什么以及它们是如何内部工作的。

第三章,理解 PHP 基础知识,将介绍 PHP 语言的基本元素——从变量到控制结构。

第四章,使用面向对象编程创建干净代码,将描述如何遵循面向对象编程范式开发 Web 应用程序。

第五章,使用数据库,将解释如何在您的应用程序中使用 MySQL 数据库。

第六章,适应 MVC,将展示如何将最著名的 Web 设计模式 MVC 应用于你的应用程序。

第七章,测试 Web 应用程序,将广泛介绍使用 PHPUnit 进行单元测试。

第八章,使用现有的 PHP 框架,将向你介绍由多家公司和开发者使用的现有 PHP 框架,例如 Laravel 和 Silex。

第九章,构建 REST API,将解释 REST API 是什么,如何使用第三方 API,以及如何构建自己的 API。

第十章,行为测试,将介绍使用 PHP 和 Behat 进行持续集成和行为测试的概念。

你需要这本书的内容

在第一章,设置环境中,我们将详细介绍如何安装 PHP 以及你需要用于通过这本书的示例所需的其他工具。你开始阅读所需的所有东西只是一台装有 Windows、OS X 或 Linux 的计算机和互联网连接。

这本书面向谁

这本书面向任何希望用 PHP 编写 Web 应用程序的人。你不需要是计算机科学毕业生才能理解它。实际上,我们将假设你对软件开发一无所知,无论是 PHP 还是其他任何语言。我们将从头开始,以便每个人都能跟随这本书。

经验丰富的读者仍然可以充分利用这本书。你可以快速回顾第一章,以发现 PHP 7 带来的新特性,然后专注于可能引起你兴趣的章节。你不需要从头到尾阅读这本书,而是将其作为指南,以便在需要时刷新特定主题。

习惯用法

在这本书中,你会发现许多文本样式,用于区分不同类型的信息。以下是一些这些样式的示例及其含义的解释。

文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 用户名显示如下:“现在,创建一个myactions.js文件,内容如下。”

代码块设置如下:

#special {
    font-size: 30px;
}

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

<head>
  <meta charset="UTF-8">
  <title>Your first app</title>
 <link rel="stylesheet" type="text/css" href="mystyle.css">
</head>

任何命令行输入或输出都写作如下:

$ sudo apt-get update

新术语重要词汇以粗体显示。您在屏幕上看到的单词,例如在菜单或对话框中,在文本中显示如下:“点击下一步直到安装向导结束。”

注意

警告或重要注意事项以这样的框显示。

小贴士

小贴士和技巧看起来像这样。

读者反馈

我们欢迎读者的反馈。让我们知道您对这本书的看法——您喜欢或不喜欢什么。读者反馈对我们很重要,因为它帮助我们开发出您真正能从中获得最大利益的标题。

要向我们发送一般反馈,请简单地发送电子邮件至<feedback@packtpub.com>,并在邮件主题中提及书籍的标题。

如果您在某个领域有专业知识,并且您有兴趣撰写或为书籍做出贡献,请参阅我们的作者指南,网址为www.packtpub.com/authors

客户支持

现在您是 Packt 书籍的骄傲拥有者,我们有一些事情可以帮助您从您的购买中获得最大利益。

下载示例代码

您可以从您的账户中下载此书的示例代码文件。www.packtpub.com。如果您在其他地方购买了此书,您可以访问www.packtpub.com/support并注册,以便将文件直接通过电子邮件发送给您。

您可以通过以下步骤下载代码文件:

  1. 使用您的电子邮件地址和密码登录或注册我们的网站。

  2. 将鼠标指针悬停在顶部的支持标签上。

  3. 点击代码下载 & 错误清单

  4. 搜索框中输入书籍名称。

  5. 选择您想要下载代码文件的书籍。

  6. 从下拉菜单中选择您购买此书的来源。

  7. 点击代码下载

文件下载后,请确保您使用最新版本解压缩或提取文件夹:

  • Windows 上的 WinRAR / 7-Zip

  • Mac 上的 Zipeg / iZip / UnRarX

  • Linux 上的 7-Zip / PeaZip

错误清单

尽管我们已经尽最大努力确保内容的准确性,但错误仍然可能发生。如果您在我们的某本书中发现了错误——可能是文本或代码中的错误——如果您能向我们报告这一点,我们将不胜感激。这样做可以帮助其他读者避免挫败感,并帮助我们改进此书的后续版本。如果您发现任何错误清单,请通过访问www.packtpub.com/submit-errata,选择您的书籍,点击错误提交表单链接,并输入您的错误清单详情。一旦您的错误清单得到验证,您的提交将被接受,错误清单将被上传到我们的网站或添加到该标题的错误部分下的现有错误清单中。

要查看之前提交的勘误表,请访问www.packtpub.com/books/content/support,并在搜索字段中输入书籍名称。所需信息将出现在勘误部分。

盗版

互联网上对版权材料的盗版是一个跨所有媒体的持续问题。在 Packt,我们非常重视我们版权和许可证的保护。如果您在互联网上发现我们作品的任何非法副本,请立即提供位置地址或网站名称,以便我们可以寻求补救措施。

请通过<copyright@packtpub.com>与我们联系,并提供疑似盗版材料的链接。

我们感谢您在保护我们的作者和我们为您提供有价值内容的能力方面的帮助。

问题

如果您对本书的任何方面有问题,您可以通过<questions@packtpub.com>联系我们,我们将尽力解决问题。

第一章. 设置环境

你即将开始一段旅程——这是一段漫长的旅程,你将学习如何使用 PHP 编写 Web 应用程序。然而,首先,你需要设置你的环境,这在某些时候已被证明是棘手的。这项任务包括安装 PHP 7,这本书的首选语言;MySQL,我们将用于一些章节的数据库;Nginx,将允许我们通过浏览器可视化我们的应用程序的 Web 服务器;以及 Composer,最受欢迎的 PHP 依赖关系管理工具。我们将使用 Vagrant 并在三个不同的平台上完成所有这些:Windows、OS X 和 Ubuntu。

在本章中,你将了解:

  • 使用 Vagrant 设置开发环境

  • 在主要平台上手动设置环境

使用 Vagrant 设置环境

不久前,每次你为一家新公司开始工作时,你都会在最初的几天里花很多时间来设置你的新环境——也就是说,在你的新电脑上安装所有必要的工具以便能够编码。这非常令人沮丧,因为尽管要安装的软件是相同的,但总是会有一些失败或缺失的地方,你将花费较少的时间来提高生产力。

介绍 Vagrant

幸运的是,人们试图解决这个问题。首先,我们有虚拟机,这是你电脑内的计算机仿真。有了这个,我们可以在 MacBook 中运行 Linux,这允许开发者共享环境。这是一个好的步骤,但它仍然有一些问题;例如,VMs 在移动到不同环境时相当大,如果开发者想要进行更改,他们必须将相同的更改应用到组织中的所有现有虚拟机上。

经过一番深思熟虑,一群工程师提出了解决这些问题的方案,我们得到了Vagrant。这款惊人的软件允许你通过简单的配置文件来管理虚拟机。想法很简单:一个配置文件指定了我们需要从在线可用的一组基本虚拟机中选择哪一个,以及你希望如何自定义它——也就是说,你希望在启动机器时运行哪些命令——这被称为“配置”。你可能会从公共仓库中获得 Vagrant 配置,如果这个配置有任何更改,你可以获取这些更改并重新配置你的机器。很简单,对吧?

安装 Vagrant

如果你还没有安装 Vagrant,安装它相当简单。你需要访问 Vagrant 下载页面www.vagrantup.com/downloads.html并选择你正在使用的操作系统。执行安装程序,它不需要任何额外的配置,然后你就可以开始了。

使用 Vagrant

使用 Vagrant 相当简单。最重要的部分是Vagrantfile文件。此文件包含我们想要使用的基镜像的名称以及我们想要应用的其余配置。以下内容是为了获取具有 PHP 7、MySQL、Nginx 和 Composer 的 Ubuntu 虚拟机所需的配置。将其保存为Vagrantfile在本书示例的目录根中。

VAGRANTFILE_API_VERSION = "2"

Vagrant.configure(VAGRANTFILE_API_VERSION) do |config|
  config.vm.box = "ubuntu/trusty32"
  config.vm.network "forwarded_port", guest: 80, host: 8080
  config.vm.provision "shell", path: "provisioner.sh"
end

如您所见,文件相当小。基本镜像的名称是ubuntu/trusty32,发送到我们的端口8080的消息将被重定向到虚拟机的端口80,配置将基于provisioner.sh脚本。您需要创建此文件,它将包含我们需要的不同组件的所有设置。这就是您需要添加到该文件中的内容:

#!/bin/bash

sudo apt-get install python-software-properties -y
sudo LC_ALL=en_US.UTF-8 add-apt-repository ppa:ondrej/php -y
sudo apt-get update
sudo apt-get install php7.0 php7.0-fpm php7.0-mysql -y
sudo apt-get --purge autoremove -y
sudo service php7.0-fpm restart

sudo debconf-set-selections <<< 'mysql-server mysql-server/root_password password root'
sudo debconf-set-selections <<< 'mysql-server mysql-server/root_password_again password root'
sudo apt-get -y install mysql-server mysql-client
sudo service mysql start

sudo apt-get install nginx -y
sudo cat > /etc/nginx/sites-available/default <<- EOM
server {
    listen 80 default_server;
    listen [::]:80 default_server ipv6only=on;

    root /vagrant;
    index index.php index.html index.htm;

    server_name server_domain_or_IP;

    location / {
        try_files \$uri \$uri/ /index.php?\$query_string;
    }

    location ~ \.php\$ {
        try_files \$uri /index.php =404;
        fastcgi_split_path_info ^(.+\.php)(/.+)\$;
        fastcgi_pass unix:/var/run/php/php7.0-fpm.sock;
        fastcgi_index index.php;
        fastcgi_param SCRIPT_FILENAME \$document_root\$fastcgi_script_name;
        include fastcgi_params;
    }
}
EOM
sudo service nginx restart

文件看起来相当长,但我们将用它做很多事情。在文件的第一部分,我们将添加必要的仓库以便能够获取 PHP 7,因为它不包括在官方的仓库中,然后安装它。然后,我们将尝试安装 MySQL 服务器和客户端。由于我们无法使用 Vagrant 手动引入它,因此在此配置中我们将设置 root 密码。由于这是一个开发机器,这并不是真正的问题,但您完成之后可以随时更改密码。最后,我们将安装和配置 Nginx 以监听端口8080

要启动虚拟机,您需要在Vagrantfile所在的同一目录下执行以下命令:

$ vagrant up

第一次执行时,它将花费一些时间,因为它需要从仓库下载镜像,然后执行provisioner.sh文件。输出应该类似于以下内容,然后是更多的输出消息:

使用 Vagrant

为了访问您的新虚拟机,请在包含您的Vagrantfile文件的同一目录下运行以下命令:

$ vagrant ssh

Vagrant 将启动到虚拟机的 SSH 会话,这意味着您已经进入了虚拟机。您可以执行任何您会用 Ubuntu 系统命令行执行的操作。要退出,只需按Ctrl + D

从您的笔记本电脑共享文件到虚拟机非常简单;只需将它们移动或复制到包含您的Vagrantfile文件的同一目录,它们就会“神奇地”出现在您的虚拟机的/vagrant目录中。它们将同步,所以您在虚拟机中进行的任何更改都会反映在您的笔记本电脑上的文件中。

当您有一个 Web 应用程序并且想要通过 Web 浏览器测试它时,请记住我们将转发端口。这意味着为了访问您的虚拟机的端口80(Web 应用程序的常用端口),您需要将浏览器指向端口8080;以下是一个示例:http://localhost:8080

在 OS X 上设置环境

如果你不太相信 Vagrant 并且更喜欢使用 Mac 开发 PHP 应用程序,这部分内容就是为你准备的。根据你的 OS X 版本,在 Mac 上安装所有必要的工具可能有点棘手。在撰写本书时,Oracle 没有发布一个可以在 El Capitan 上通过命令行使用的 MySQL 客户端,所以我们将描述如何安装另一个可以完成类似工作的工具。

安装 PHP

如果这是你第一次使用 Mac 开发任何类型的应用程序,你将不得不从安装 Xcode 开始。你可以在 App Store 中免费找到这个应用程序:

安装 PHP

对于 Mac 用户来说,另一个不可或缺的工具是 Brew。这是 OS X 的包管理器,将帮助我们几乎无痛地安装 PHP。要安装它,请在你的命令行上运行以下命令:

$ ruby -e "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/master/install)"

如果你已经安装了 Brew,你可以通过运行这两个命令来确保一切正常:

$ brew doctor
$ brew update

现在是时候使用 Brew 安装 PHP 7 了。为此,你只需要运行一个命令,如下所示:

$ brew install homebrew/php/php70

结果应该如下所示:

安装 PHP

确保通过执行此命令将二进制文件添加到你的 PATH 环境变量中:

$ export PATH="$(brew --prefix homebrew/php/php70)/bin:$PATH"

你可以通过使用 $ php –v 命令询问你的系统正在使用哪个版本的 PHP 来检查你的安装是否成功。

安装 MySQL

如本节开头所指出的,MySQL 对于 Mac 用户来说有点棘手。你需要下载 MySQL 服务器安装程序和 MySQL Workbench 作为客户端。MySQL 服务器安装程序可以在 dev.mysql.com/downloads/mysql/ 找到。你应该会找到一个不同的选项列表,如下所示:

安装 MySQL

最简单的方法是下载 DMG 存档。你将需要使用你的 Oracle 账户登录;如果你没有,你可以创建一个。之后,下载将开始。与任何 DMG 包一样,只需双击它,然后按照选项进行操作——在这种情况下,只需一直点击 下一步。请注意,因为过程结束时,你将收到类似以下的消息:

安装 MySQL

记下它;否则,你可能需要重置根密码。下一个是 MySQL Workbench,你可以在 www.mysql.com/products/workbench/ 找到它。过程是相同的;你将被要求登录,然后你会得到一个 DMG 文件。点击 下一步 直到安装向导结束。一旦完成,你可以启动应用程序;它应该看起来像这样:

安装 MySQL

安装 Nginx

为了安装 Nginx,我们将使用 Brew,就像我们安装 PHP 一样。命令如下:

$ brew install nginx

如果你想让 Nginx 在你启动笔记本电脑时启动,请运行以下命令:

$ ln -sfv /usr/local/opt/nginx/*.plist ~/Library/LaunchAgents

如果你需要更改 Nginx 的配置,你将在 /usr/local/etc/nginx/nginx.conf 文件中找到它。你可以更改一些事情,比如 Nginx 监听哪个端口或你的代码所在的根目录(默认目录是 /usr/local/Cellar/nginx/1.8.1/html/)。记得使用 sudo nginx 命令重新启动 Nginx 以应用更改。

安装 Composer

安装 Composer 与使用 curl 命令下载一样简单;使用以下两个命令将二进制文件移动到 /usr/local/bin/

$ curl -sS https://getcomposer.org/installer | php
$ mv composer.phar /usr/local/bin/composer

在 Windows 上设置环境

虽然基于个人观点选择立场并不太专业,但开发者之间众所周知,使用 Windows 作为开发机器可能会非常困难。当涉及到安装所有软件时,它们证明非常棘手,因为安装模式始终与 OS X 和 Linux 系统大不相同,而且经常会出现依赖或配置问题。此外,命令行与 Unix 系统有不同的解释器,这使得事情变得更加复杂。这就是为什么大多数开发者会建议你如果只有一台 Windows 机器可用,就使用带有 Linux 的虚拟机。

然而,为了公平起见,PHP 7 是一个例外。安装它出奇地简单,所以如果你真的很熟悉 Windows 并且不想使用 Vagrant,这里有一个简短的说明,告诉你如何设置你的环境。

安装 PHP

为了安装 PHP 7,你首先需要从官方网站下载安装程序。为此,请访问 windows.php.net/download。选项应该类似于以下截图:

安装 PHP

对于 Windows 32 位,选择x86 Thread Safe,对于 64 位,选择x64 Thread Safe。下载后,将其解压缩到 C:\php7。是的,就是这样!

安装 MySQL

安装 MySQL 稍微复杂一些。从 dev.mysql.com/downloads/installer/ 下载安装程序并执行它。在接受许可协议后,你会得到一个类似于以下窗口:

安装 MySQL

为了本书的目的——实际上对于任何开发环境——你应该选择第一个选项:开发者默认。继续前进,保留所有默认选项,直到你得到一个类似以下窗口:

安装 MySQL

根据你的偏好,你可以只为 root 用户设置一个密码,这对于开发机器来说已经足够了,或者你可以通过点击添加用户来添加一个额外的用户。确保设置正确的名称、密码和权限。一个名为 test 的具有管理员权限的用户应该看起来像以下截图:

安装 MySQL

对于安装过程的其余部分,你可以选择所有默认选项。

安装 Nginx

Nginx 的安装几乎与 PHP 7 的安装相同。首先,从nginx.org/en/download.html下载 ZIP 文件。在撰写本文时,可用的版本如下:

安装 Nginx

您可以安全地下载主线版本 1.9.10 或更高版本,如果它是稳定的话。一旦文件下载完成,在C:\nginx中解压它,然后运行以下命令以启动 Web 服务器:

$ cd nginx
$ start nginx

安装 Composer

要完成设置,我们需要安装 Composer。要进行自动安装,只需从getcomposer.org/Composer-Setup.exe下载安装程序。下载后,执行它以在您的系统上安装 Composer 并更新您的PATH环境变量。

在 Ubuntu 上设置环境

在 Ubuntu 上设置环境是最简单的三个平台之一。实际上,您可以从使用 Vagrant 设置环境部分获取provisioner.sh脚本并在您的笔记本电脑上执行它。这应该就可以了。然而,以防您已经安装了一些工具或者您想要对正在发生的事情有更多的控制感,我们将详细说明每个步骤。

安装 PHP

在本节中需要考虑的唯一事情是删除系统上任何之前的 PHP 版本。为此,您可以运行以下命令:

$ sudo apt-get -y purge php.*

下一步是添加必要的仓库以获取正确的 PHP 版本。添加和更新它们的命令如下:

$ sudo apt-get install python-software-properties
$ sudo LC_ALL=en_US.UTF-8 add-apt-repository ppa:ondrej/php -y
$ sudo apt-get update

最后,我们需要安装 PHP 7 以及 MySQL 的驱动程序。为此,只需执行以下三个命令:

$ sudo apt-get install php7.0 php7.0-fpm php7.0-mysql -y
$ sudo apt-get --purge autoremove -y
$ sudo service php7.0-fpm start

安装 MySQL

手动安装 MySQL 可能略不同于 Vagrant 脚本。由于我们可以与控制台交互,我们不需要事先指定 root 密码;相反,我们可以强制 MySQL 提示输入密码。运行以下命令,并记住安装程序将要求你输入密码:

$ sudo apt-get -y install mysql-server mysql-client

完成后,如果您需要启动 MySQL 服务器,可以使用以下命令:

$ sudo service mysql start

安装 Nginx

您需要知道的第一件事是,您只能有一个 Web 服务器监听相同的端口。由于端口80是 Web 应用的默认端口,如果您在 Ubuntu 机器上运行 Apache,您将无法启动一个监听相同端口80的 Nginx Web 服务器。为了解决这个问题,您可以更改 Nginx 或 Apache 的端口,停止 Apache 或卸载它。无论如何,Nginx 的安装命令如下:

$ sudo apt-get install nginx –y

现在,您需要启用 Nginx 上的一个站点。这些站点位于/etc/nginx/sites-available目录下。那里已经有一个文件,default,您可以安全地将其替换为以下内容:

server {
    listen 80 default_server;
    listen [::]:80 default_server ipv6only=on;

    root /var/www/html;
    index index.php index.html index.htm;

    server_name server_domain_or_IP;

    location / {
        try_files $uri $uri/ /index.php?$query_string;
    }

    location ~ \.php$ {
        try_files $uri /index.php =404;
        fastcgi_split_path_info ^(.+\.php)(/.+)$;
        fastcgi_pass unix:/var/run/php/php7.0-fpm.sock;
        fastcgi_index index.php;
        fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
        include fastcgi_params;
    }
}

此配置基本上将您的 Web 应用程序的根目录指向/var/www/html目录。您可以选择您喜欢的选项,但请确保它具有正确的权限。它还监听端口80,您可以使用您喜欢的端口进行更改;只需记住,当您尝试通过浏览器访问您的应用程序时。最后,要应用所有更改,请运行以下命令:

$ sudo service nginx restart

小贴士

下载示例代码

您可以从您的账户中下载此书的示例代码文件www.packtpub.com。如果您在其他地方购买了这本书,您可以访问www.packtpub.com/support并注册,以便将文件直接通过电子邮件发送给您。

您可以通过以下步骤下载代码文件:

  • 使用您的电子邮件地址和密码登录或注册我们的网站。

  • 将鼠标指针悬停在顶部的支持标签上。

  • 点击代码下载与勘误表

  • 搜索框中输入书籍名称。

  • 选择您想要下载代码文件的书籍。

  • 从下拉菜单中选择您购买此书的来源。

  • 点击代码下载

文件下载完成后,请确保使用最新版本的软件解压或提取文件夹:

  • Windows 系统使用 WinRAR / 7-Zip

  • Mac 系统使用 Zipeg / iZip / UnRarX

  • Linux 系统使用 7-Zip / PeaZip

摘要

在本章中,您学习了如何轻松地使用 Vagrant 设置开发环境。如果您还没有被说服,您仍然有机会手动设置所有工具。无论哪种方式,现在您都可以开始下一章的工作。

在下一章中,我们将探讨使用 PHP 的 Web 应用程序的概念,从使用的协议到 Web 服务器如何处理请求,从而为以下章节奠定基础。

第二章:PHP 网络应用

网络应用在我们的生活中很常见,并且通常非常用户友好;用户不需要了解它们在幕后是如何工作的。然而,作为一个开发者,你需要了解你的应用程序是如何内部工作的。

在本章中,你将学习以下内容:

  • HTTP 以及网络应用如何使用它

  • 网络应用以及如何构建一个简单的应用

  • 网络服务器以及如何启动你的 PHP 内置网络服务器

HTTP 协议

如果你查看 RFC2068 标准tools.ietf.org/html/rfc2068,你会看到其描述几乎是无穷无尽的。幸运的是,你至少需要了解有关此协议的信息,其长度要短得多。

HTTP代表超文本传输协议。与其他任何协议一样,目标是允许两个实体或节点相互通信。为了实现这一点,消息需要以双方都能理解的方式进行格式化,并且实体必须遵循一些预先设定的规则。

一个简单的例子

下面的图显示了非常基本的消息交换:

一个简单的例子

一个简单的 GET 请求

如果你不懂这张图中的所有元素,请不要担心;我们很快就会描述它们。在这个表示中,有两个实体:发送者接收者。发送者向接收者发送一条消息。这条开始通信的消息被称为请求。在这种情况下,消息是一个 GET 请求。接收者接收消息,处理它,并生成第二条消息:响应。在这种情况下,响应显示 200 状态码,意味着请求已成功处理。

HTTP 是无状态的;也就是说,它独立地处理每个请求,与之前的任何请求无关。这意味着,在这个请求和响应序列之后,通信就结束了。任何新的请求都不会意识到这个特定的消息交换。

消息的部分

一个 HTTP 消息包含几个部分。我们只定义其中最重要的部分。

URL

消息的 URL 是消息的目的地。请求将包含接收者的 URL,而响应将包含发送者的。

如你所知,URL 可以包含额外的参数,称为查询字符串。当发送者想要添加额外数据时使用它。例如,考虑这个 URL:http://myserver.com/greeting?name=Alex。这个 URL 包含一个参数:name,其值为Alex。它不能作为http://myserver.com/greeting URL 的一部分来表示,因此发送者选择将其添加到 URL 的末尾。你稍后会发现,这并不是我们向消息中添加额外信息的唯一方式。

HTTP 方法

HTTP 方法是消息的动词。它标识了发送者想要通过此消息执行哪种类型的操作。最常见的是 GET 和 POST。

  • GET:这是询问接收者某事,接收者通常会发送此信息。最常见的情况是请求一个网页,接收者将响应请求页面的 HTML 代码。

  • POST:这意味着发送者想要执行一个将更新接收者所持数据的操作。例如,发送者可以要求接收者更新其个人资料名称。

还有其他方法,例如PUTDELETEOPTION,但在 Web 开发中它们的使用较少,尽管它们在 REST API 中扮演着至关重要的角色,这将在第九章构建 REST API中解释。

主体

尽管请求消息也可以包含它,但主体部分通常存在于响应消息中。消息的主体包含消息本身的内容;例如,如果用户请求了一个网页,响应的主体将包含表示此页面的 HTML 代码。

很快,我们将讨论请求也可以包含主体,这用于在请求中发送额外信息,例如表单参数。

主体可以包含任何格式的文本;它可以是一个表示网页的 HTML 文本,纯文本,图像内容,JSON 等等。

头部

HTTP 消息的头部是接收者为了理解消息内容所需的元数据。有很多头部,你将在本书中看到一些。

头部由键值对的映射组成。以下可能是一个请求的头部:

Accept: text/html
Cookie: name=Richard

这个请求告诉接收者(即服务器),它将接受文本作为 HTML,这是表示网页的常见方式;并且它有一个名为 Richard 的 cookie。

状态码

状态码存在于响应中。它使用数字代码标识请求的状态,以便浏览器和其他工具知道如何响应。例如,如果我们尝试访问一个不存在的 URL,服务器应该回复状态码 404。这样,浏览器就知道发生了什么,甚至不需要查看响应的内容。

常见的状态码有:

  • 200:请求成功

  • 401:未授权;用户没有查看此资源的权限

  • 404:页面未找到

  • 500:内部服务器错误;服务器端发生了错误,无法恢复

一个更复杂的示例

以下图显示了 POST 请求及其响应:

一个更复杂的示例

一个更复杂的 POST 请求

在这个消息交换中,我们可以看到另一个重要的方法 POST 正在发挥作用。在这种情况下,发送者试图发送一个请求以更新某个实体的数据。消息包含一个值为84的 cookie ID,这可能标识了要更新的实体。它还包含在主体中的两个参数:nameage。这是接收者需要更新的数据。

小贴士

提交网页表单

将参数作为体的一部分表示是提交表单时发送信息的一种常见方式,但并非唯一方式。你可以在 URL 中添加查询字符串,在消息体中添加 JSON,等等。

响应的状态码为 200,表示请求已成功处理。此外,响应还包含一个体,这次格式化为 JSON,它表示更新实体的新状态。

Web 应用

也许你已经注意到,在前面的章节中,我使用了不太直观的发送者和接收者术语,因为它们并不代表你可能知道的具体场景,而是以通用方式代表所有这些场景。选择这种术语的主要原因是为了尝试将 HTTP 与 Web 应用分开。你将在本书的结尾看到,HTTP 不仅仅用于网站。

如果你正在阅读这本书,你已经知道什么是 Web 应用了。或者,也许你通过其他术语了解它,比如网站或网页。让我们尝试给出一些定义。

网页是一个包含内容的单个文档。它包含链接,可以打开具有不同内容的其他网页。

网站是一组通常位于同一服务器上并相互关联的网页。

Web 应用是运行在客户端(通常是浏览器)上的一小块软件,并与服务器进行通信。服务器是一台远程机器,它接收来自客户端的请求,处理它们,并生成响应。这个响应将返回到客户端,通常由浏览器渲染以显示给用户。

尽管这超出了本书的范围,你可能想知道,不仅浏览器可以作为客户端,生成请求并将它们发送到服务器;服务器也可以是主动向浏览器发送消息的一方。

那么,网站和 Web 应用之间有什么区别呢?嗯,Web 应用可以是更大网站中具有特定功能的小部分。此外,并不是所有的网站都是 Web 应用,因为 Web 应用总是执行某些操作,而网站只是显示信息。

HTML、CSS 和 JavaScript

Web 应用由浏览器渲染,以便用户可以看到其内容。为此,服务器需要发送页面或文档的内容。文档使用 HTML 来描述其元素及其组织方式。元素可以是链接、按钮、输入字段等等。一个简单的网页示例如下:

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>Your first app</title>
</head>
<body>
 <a id="special" class="link" href="http://yourpage.com">Your page</a>
 <a class="link" href="http://theirpage.com">Their page</a>
</body>
</html>

让我们关注高亮显示的代码。正如你所见,我们正在描述两个具有一些属性的 <a> 链接。这两个链接都有一个类、一个目的地和一段文本。第一个链接还包含一个 ID。将此代码保存到名为 index.html 的文件中并执行它。你会看到默认浏览器如何打开一个包含两个链接的非常简单的页面。

如果我们想要添加一些样式,或者改变链接的颜色、大小和位置,我们需要添加 CSS。CSS 描述了 HTML 元素是如何显示的。有几种方法可以包含 CSS,但最好的方法是将它放在一个单独的文件中,然后从 HTML 中引用它。让我们更新以下代码的<head>部分:

<head>
  <meta charset="UTF-8">
  <title>Your first app</title>
 <link rel="stylesheet" type="text/css" href="mystyle.css">
</head>

现在,让我们在同一个文件夹中创建一个新的mystyle.css文件,包含以下内容:

.link {
    color: green;
    font-weight: bold;
}

#special {
    font-size: 30px;
}

这个 CSS 文件包含两个样式定义:一个用于link类,一个用于special ID。类样式将应用于两个链接,因为它们都定义了这个类,并且将它们设置为绿色和粗体。增加链接字体的 ID 样式只应用于第一个链接。

最后,为了给我们的网页添加行为,我们需要添加 JS 或 JavaScript。JS 是一种编程语言,它本身就需要一本整本书来介绍,实际上,有很多种。如果你想尝试一下,我们推荐免费的在线书籍《Eloquent JavaScript》,作者是Marijn Haverbeke,你可以在eloquentjavascript.net/找到它。与 CSS 一样,最好的方法是将它放在一个单独的文件中,然后从 HTML 中引用它。更新以下高亮代码的<body>部分:

<body>
  <a id="special" class="link" href="http://yourpage.com">Your page</a>
  <a class="link" href="http://theirpage.com">Their page</a>
 <script src="img/myactions.js"></script>
</body>

现在,创建一个包含以下内容的myactions.js文件:

document.getElementById("special").onclick = function() {
    alert("You clicked me?");
}

JS 文件添加了一个函数,当点击special链接时将被调用。这个函数只是弹出一个警告框。你可以保存所有更改并刷新浏览器,看看现在看起来如何以及链接的行为如何。

注意

包含 JS 的不同方式

你可能会注意到,我们在<head>部分的末尾包含了 CSS 文件引用,在<body>部分的末尾包含了 JS。实际上,你可以在<head><body>中包含 JS;只需记住,脚本一旦被包含就会立即执行。如果你的脚本引用了尚未定义的字段或其他稍后将被包含的 JS 文件,JS 将失败。

恭喜!你刚刚编写了你的第一个网页。不觉得有什么了不起?那么,你正在阅读正确的书籍!在本书中,你将有机会使用更多的 HTML、CSS 和 JS,尽管本书特别关注 PHP。

网服务器

因此,现在是时候学习那些著名的网服务器了。网服务器不过是在机器上运行的一块软件,并监听来自特定端口的请求。通常,这个端口是80,但可以是任何可用的其他端口。

它们是如何工作的

以下图表表示了服务器端的请求-响应流程:

它们是如何工作的

服务器端的请求-响应流程

网服务器的任务是路由外部请求到正确的应用程序,以便它们可以被处理。一旦应用程序返回响应,网服务器将发送这个响应到客户端。让我们仔细看看所有这些步骤:

  1. 客户端,即浏览器,发送一个请求。这可以是任何类型——GET 或 POST——只要它是有效的即可。

  2. 服务器接收到的请求指向一个端口。如果在这个端口上有 Web 服务器监听,那么 Web 服务器将接管情况。

  3. Web 服务器决定哪个 Web 应用程序——通常是一个文件系统中的文件——需要处理请求。为了做出决定,Web 服务器通常会考虑 URL 的路径;例如,http://myserver.com/app1/hi会尝试将请求传递给app1应用程序,无论它在文件系统中的位置如何。然而,另一种情况是http://app1.myserver.com/hi,它也会指向同一个应用程序。规则非常灵活,至于如何设置,取决于 Web 服务器和用户。

  4. Web 应用程序在收到 Web 服务器的请求后,生成一个响应并将其发送回 Web 服务器。

  5. Web 服务器将响应发送到指定的端口。

  6. 最终,响应到达客户端。

PHP 内置服务器

有一些功能强大的 Web 服务器支持高流量,如 Apache 或 Nginx,它们安装和管理相对简单。然而,本书的目的在于使用更简单的东西:PHP 内置服务器。使用它的原因是,你将不需要额外的包安装、配置和烦恼,因为 PHP 自带。只需一条命令,你就可以在你的机器上运行一个 Web 服务器。

注意

生产级 Web 服务器

注意,PHP 内置的 Web 服务器适用于测试目的,但强烈建议不要在生产环境中使用它。如果你必须设置一个需要公开的 Web 服务器,并且你的应用程序是用 PHP 编写的,我强烈建议你选择以下经典之一:Apache (httpd.apache.org) 或 Nginx (www.nginx.com)。两者几乎可以在任何服务器上运行,都是免费的,易于安装和配置,更重要的是,拥有庞大的社区,他们将支持你解决可能遇到的几乎所有问题。

最后,动手实践!让我们尝试使用内置服务器创建我们的第一个网页。为此,在你的工作区目录内创建一个index.php文件——例如,Documents/workspace/index.php。这个文件的内容应该是:

<?php
echo 'hello world';

现在,打开你的命令行,进入你的工作区目录,可能需要运行cd Documents/workspace命令,然后运行以下命令:

$ php -S localhost:8000

命令行会提示你一些信息,其中最重要的是监听什么,应该是如指定的那样localhost:8000,以及如何停止它,通常是通过按Ctrl + C。不要关闭命令行,因为它也会停止 Web 服务器。

现在,让我们打开一个浏览器并转到http://localhost:8000。你应该会在一个白色页面上看到一个hello world消息。太好了,成功了!如果你感兴趣,你可以检查你的命令行,你会看到你通过浏览器发送的每个请求的日志条目。

那么,它究竟是如何工作的呢?好吧,如果你再次查看之前的图示,php -S命令启动了一个 Web 服务器——在我们的例子中,监听端口8000而不是80。此外,PHP 知道 Web 应用程序代码将在你启动 Web 服务器的同一目录:你的workspace。还有更多具体的选项,但默认情况下,PHP 会尝试在你的workspace中执行index.php文件。

将事物组合在一起

让我们尝试将我们的第一个项目(包含其 CSS 和 JS 文件的index.html)作为内置服务器的一部分。为此,你只需要打开命令行,转到这些文件所在的目录,并使用php -S localhost:8000启动 Web 服务器。如果你在浏览器中检查localhost:8000,你应该会看到预期的两个链接页面。

现在,让我们将新的index.php文件移动到同一个目录。你不需要重新启动你的 Web 服务器;PHP 会自动知道这些更改。转到你的浏览器并刷新页面。你现在应该看到hello world消息而不是链接。这里发生了什么?

如果你没有更改默认选项,PHP 将始终尝试在启动 Web 服务器的目录中找到一个index.php文件。如果没有找到,PHP 将尝试找到一个index.html文件。之前,我们只有index.html文件,所以 PHP 未能找到index.php。现在,它能够找到它的第一个选项,index.php,它将加载它。

如果我们想从浏览器中查看我们的index.html文件,我们可以在 URL 中指定它,例如http://localhost:8000/index.html。如果 Web 服务器注意到你正在尝试访问一个特定的文件,它将尝试加载该文件而不是默认选项。

最后,如果我们尝试访问不在我们的文件系统上的文件,Web 服务器将返回一个状态码为 404 的响应——也就是说,未找到。如果我们打开浏览器中的开发者工具部分并转到网络部分,我们可以看到这个代码。

小贴士

开发者工具是你的朋友

作为一名 Web 开发者,你会发现浏览器中的开发者工具是极其有用的工具之一。它因浏览器而异,但所有的大牌浏览器,如 Chrome 或 Firefox,都有这个工具。熟悉如何使用它非常重要,因为它允许你从客户端调试你的应用程序。

我将在本书的进程中向你介绍一些这些工具。

摘要

在本章中,你学习了 HTTP 是什么以及 Web 应用程序如何使用它来与服务器交互。你现在还知道 Web 服务器是如何工作的,以及如何使用 PHP 启动一个轻量级的内置服务器。最后,你迈出了构建你的第一个 Web 应用程序的第一步。恭喜你!

在下一章中,我们将探讨 PHP 的基础知识,以便您开始构建简单的应用程序。

第三章. 理解 PHP 基础知识

学习一门新语言并不容易。你需要理解语言的语法,以及其语法规则,即何时以及为什么使用语言中的每个元素。幸运的是,一些语言来自同一个根源。例如,西班牙语和法语是罗曼语族,因为它们都源自拉丁语口语;这意味着这两种语言有很多规则是共享的,如果你已经知道法语,学习西班牙语就会容易得多。

编程语言相当相似。如果你已经知道另一种编程语言,那么通过这一章会非常容易。但如果这是你第一次,那么你需要从头开始理解所有那些语法规则,所以可能需要更多的时间。但别担心!我们在这里帮助你完成这项任务。

在本章中,你将学习以下内容:

  • PHP 文件

  • PHP 中的变量、字符串、数组运算符

  • PHP 在网络应用中

  • PHP 中的控制结构

  • PHP 中的函数

  • PHP 文件系统

PHP 文件

从现在开始,我们将专注于你的 index.php 文件,所以你可以直接启动 Web 服务器,然后访问 http://localhost:8080 来查看结果。

你可能已经注意到,为了编写 PHP 代码,你必须以 <?php 开始文件。还有其他选项,你还可以用 ?> 结束文件,但这些都是不必要的。重要的是要知道,一旦你用 <?php ?> 标签包围了 PHP 代码块,你就可以在你的 PHP 文件中混合 PHP 代码和其他内容,如 HTML、CSS 或 JavaScript。

<?php
  echo 'hello world';
?>
bye world

hello world and bye world. The reason why this happens is simple: you already know that the PHP code there prints the hello world message. What happens next is that anything outside the PHP tags will be interpreted as is. If there is an HTML code for instance, it would not be printed as is, but will be interpreted by the browser.

你将在第六章中学习,适应 MVC,为什么通常将 PHP 和 HTML 混合使用不是一个好主意。现在,假设这是不好的,让我们尽量避免。为此,你可以使用以下四个函数之一从另一个 PHP 文件中包含一个文件:

  • include: 每次调用时都会尝试查找并包含指定的文件。如果找不到文件,PHP 将抛出警告,但会继续执行。

  • require: 这将执行与 include 相同的操作,但如果找不到文件,PHP 将抛出错误而不是警告。

  • include_once: 这个函数将执行 include 的功能,但只有在第一次调用时才会包含文件。后续的调用将被忽略。

  • require_once: 这与 require 的功能相同,但只有在第一次调用时才会包含文件。后续的调用将被忽略。

每个函数都有自己的用途,所以说一个比另一个好是不正确的。只需仔细思考你的场景,然后做出决定。例如,让我们尝试从我们的 index.php 文件中包含我们的 index.html 文件,这样我们就不混合 PHP 和 HTML,而是两者兼得:

<?php
echo 'hello world';
require 'index.html';

我们选择使用require,因为我们知道文件在那里——如果它不存在,我们就不想继续执行。此外,由于它是某些 HTML 代码,我们可能希望多次包含它,所以我们没有选择require_once选项。你可以尝试引入一个不存在的文件,看看浏览器会说什么。

PHP 不会考虑空行;你可以添加尽可能多的空行来使你的代码更容易阅读,而且它不会对你的应用程序产生任何影响。另一个有助于编写可读代码的元素,但 PHP 会忽略它,是注释。让我们看看它们在实际中的应用:

<?php

/*
 * This is the first file loaded by the web server.
 * It prints some messages and html from other files.
 */

// let's print a message from php
echo 'hello world';

// and then include the rest of html
require 'index.html';

这段代码与上一个代码做的是同样的工作,但现在每个人都会很容易理解我们试图做什么。我们可以看到两种类型的注释:单行注释和多行注释。第一种类型由以//开头的一行组成,第二种类型在/**/之间包含多行。我们以星号开始每个注释行,但这完全是可选的。

变量

变量保存一个值以供将来引用。如果我们想改变它,这个值可以改变;这就是为什么它们被称为变量。让我们通过一个例子来看看它们。将此代码保存到你的index.php文件中:

<?php
$a = 1;
$b = 2;
$c = $a + $b;
echo $c; // 3

在前面的代码片段中,我们有三个变量:$a的值为1$b的值为2$c包含$a$b的和,因此$c等于 3。你的浏览器应该打印变量$c的值,即 3。

将值赋给变量意味着给它一个值,这就像上一个例子中所示的那样,使用等号。如果你没有给变量赋值,当 PHP 检查其内容时,我们会收到一个 PHP 的通知。通知只是一个消息,告诉我们某些事情并不完全正确,但这只是一个小问题,你可以继续执行。未分配变量的值将是 null,即没有内容。

PHP 变量以$符号开头,后跟变量名。一个有效的变量名以字母或下划线开头,后跟任何组合的字母、数字和/或下划线。它是区分大小写的。让我们看看一些例子:

<?php
$_some_value = 'abc'; // valid
$1number = 12.3; // not valid!
$some$signs% = '&^%'; // not valid!
$go_2_home = "ok"; // valid
$go_2_Home = 'no'; // this is a different variable
$isThisCamelCase = true; // camel case

记住,//之后的所有内容都是注释,因此会被 PHP 忽略。

在这段代码中,我们可以看到像$_some_value$go_2_home这样的变量名是有效的。$1number$some$signs%不是有效的,因为它们以数字开头,或者它们包含无效的符号。由于名称是区分大小写的,$go_2_home$go_2_Home是两个不同的变量。最后,我们展示了驼峰命名法,这是大多数开发者首选的选项。

数据类型

我们可以将不仅仅是数字赋值给变量。PHP 有八个原始类型,但到目前为止,我们将关注它的四种标量类型:

  • 布尔值:它们只取 true 或 false 值

  • 整数:这些是没有小数点的数值,例如,2 或 5

  • 浮点数或浮点数:这些是带有小数点的数字,例如,2.3

  • 字符串:这些是由单引号或双引号包围的字符的连接,例如 'this' 或 "that"

尽管 PHP 定义了这些类型,但它允许用户将不同类型的数据分配给同一个变量。查看以下代码以了解它是如何工作的:

<?php
$number = 123;
var_dump($number);
$number = 'abc';
var_dump($number);

如果你在浏览器上查看结果,你会看到以下内容:

int(123) string(3) "abc"

代码首先将值123分配给变量$number。由于123是整数,变量的类型将是整数int。这就是我们在使用var_dump打印变量内容时看到的内容。之后,我们将另一个值分配给同一个变量,这次是一个字符串。在打印新内容时,我们看到变量的类型从整数变为字符串,但 PHP 在任何时候都没有抱怨。这被称为类型转换

让我们检查另一段代码:

<?php
$a = "1";
$b = 2;
var_dump($a + $b); // 3
var_dump($a . $b); // 12

您已经知道+运算符返回两个数值的和。您稍后将会看到.运算符连接两个字符串。因此,前面的代码将一个字符串和一个整数分配给两个变量,然后尝试将它们相加和连接。

当尝试将它们相加时,PHP 知道它需要两个数值,因此它会尝试将字符串转换为整数。在这种情况下,由于字符串代表一个有效的数字,所以很容易。这就是为什么我们看到第一个结果是一个整数 3(1 + 2)。

在最后一行,我们正在进行字符串连接。在 $b 中有一个整数,所以 PHP 会首先尝试将其转换为字符串——即 "2",然后将其与另一个字符串 "1" 连接。结果是字符串 "12"。

注意

类型转换

PHP 仅在存在需要不同变量类型的上下文时才会尝试转换变量的数据类型。但 PHP 不会改变变量本身的价值和类型。相反,它会取值并尝试转换,同时保持变量不变。

运算符

使用变量很方便,但如果我们不能使它们相互交互,我们就无法做太多。运算符是接受一些表达式(操作数)并对其执行操作以获得结果的元素。最常见的运算符例子是算术运算符,您之前已经看到了。

表达式几乎可以是任何具有值的元素。变量、数字或文本是表达式的例子,但你会看到它们可以变得非常复杂。运算符期望特定类型的表达式,例如,算术运算符期望整数或浮点数。但如您所知,PHP 会在可能的情况下处理给定表达式的类型转换。

让我们看看最重要的运算符组。

算术运算符

算术运算符非常直观,正如你所知。加法(+)、减法(-)、乘法(*)和除法(/)都按照它们的名称执行。取模运算符(%)给出两个操作数除法的余数。指数运算符(**)将第一个操作数提升为第二个操作数的幂。最后,取反运算符(-)对操作数取反。这是唯一只需要一个操作数的算术运算符。

让我们看看一些例子:

<?php
$a = 10;
$b = 3;
var_dump($a + $b); // 13
var_dump($a - $b); // 7
var_dump($a * $b); // 30
var_dump($a / $b); // 3.333333...
var_dump($a % $b); // 1
var_dump($a ** $b); // 1000
var_dump(-$a); // -10

如你所见,它们很容易理解!

赋值运算符

你也熟悉这个,因为我们已经在我们的例子中使用过它了。赋值运算符将表达式的结果赋给一个变量。现在你知道一个表达式可以像数字一样简单,或者,例如,一系列算术运算的结果。以下示例将表达式的结果赋给一个变量:

<?php
$a = 3 + 4 + 5 - 2;
var_dump($a); // 10

存在一系列的赋值运算符,它们作为快捷方式工作。你可以通过组合一个算术运算符和一个赋值运算符来构建它们。让我们看看一些例子:

$a = 13;
$a += 14; // same as $a = $a + 14;
var_dump($a);
$a -= 2; // same as $a = $a - 2;
var_dump($a);
$a *= 4; // same as $a = $a * 4;
var_dump($a);

比较运算符

比较运算符是使用最频繁的运算符组之一。它们接受两个操作数并比较它们,通常将比较的结果作为布尔值返回,即truefalse

有四种非常直观的比较运算符:<(小于)、<=(小于等于)、>(大于)和>=(大于等于)。还有一个特殊的运算符<=>(飞船),它比较两个操作数并返回一个整数而不是布尔值。当比较ab时,如果a小于b,结果将小于 0;如果a等于b,结果为 0;如果a大于b,结果大于 0。让我们看看一些例子:

<?php
var_dump(2 < 3); // true
var_dump(3 < 3); // false
var_dump(3 <= 3); // true
var_dump(4 <= 3); // false
var_dump(2 > 3); // false
var_dump(3 >= 3); // true
var_dump(3 > 3); // false
var_dump(1 <=> 2); // int less than 0
var_dump(1 <=> 1); // 0
var_dump(3 <=> 2); // int greater than 0

存在比较运算符来评估两个表达式是否相等,但你需要小心类型转换。==(等于)运算符在类型转换后评估两个表达式,也就是说,它会尝试将两个表达式转换为相同的类型,然后再进行比较。相反,===(严格等于)运算符在无类型转换的情况下评估两个表达式,所以即使它们看起来相同,如果它们的类型不同,比较结果将返回false。同样的规则适用于!=<>(不等于)和!==(严格不等于):

<?php
$a = 3;
$b = '3';
$c = 5;
var_dump($a == $b); // true
var_dump($a === $b); // false
var_dump($a != $b); // false
var_dump($a !== $b); // true
var_dump($a == $c); // false
var_dump($a <> $c); // true

你可以看到,当询问一个字符串和一个表示相同数字的整数是否相等时,它回答肯定;PHP 首先将它们都转换为相同的类型。另一方面,当询问它们是否相同类型时,它回答它们不是,因为它们的类型不同。

逻辑运算符

逻辑操作符对其操作数应用逻辑运算(也称为二元运算),返回布尔响应。最常用的有!(非),&&(与)和||(或)。&&只有在两个操作数都评估为true时才会返回true||如果任何或两个操作数都是true,则返回true!将返回操作数的否定值,即如果操作数是false则返回true,如果操作数是true则返回false。让我们看一些例子:

<?php
var_dump(true && true); // true
var_dump(true && false); // false
var_dump(true || false); // true
var_dump(false || false); // false
var_dump(!false); // true

增量和减量操作符

增量和减量操作符也是像+=-=这样的快捷方式,并且它们只作用于变量。这里有四个,需要特别注意。我们已经看到了前两个:

  • ++:这个操作符在变量的左侧会将变量增加 1,然后返回结果。在右侧,它会返回变量的内容,然后增加 1。

  • --:这个操作符的作用与++相同,但它是减少值而不是增加值。

让我们看看一个例子:

<?php
$a = 3;
$b = $a++; // $b is 3, $a is 4
var_dump($a, $b);
$b = ++$a; // $a and $b are 5
var_dump($a, $b);

在前面的代码中,在第一次赋值给$b时,我们使用了$a++。右侧的操作符首先会返回$a的值,即3,然后将它赋值给$b,然后才将$a增加 1。在第二次赋值中,左侧的操作符首先将$a增加 1,将$a的值变为5,然后将这个值赋给$b

操作符优先级

你可以在一个表达式中添加多个操作符,使其长度满足需要,但你需要小心,因为一些操作符的优先级高于其他操作符,因此执行顺序可能不是你所期望的。以下表格显示了我们至今为止所学的操作符的优先级顺序:

操作符 类型
** 算术
++, -- 增量/减少
! 逻辑
*, /, % 算术
+, - 算术
<, <=, >, >= 比较
==, !=, ===, !== 比较
&& 逻辑
&#124;&#124; 逻辑
=, +=, -=, *=, /=, %=, **= 赋值

前面的表格显示,表达式3+2*3将首先计算乘积2*3,然后是求和,所以结果是 9 而不是 15。如果你想以不同于自然优先级顺序的特定顺序执行操作,可以通过将操作放在括号内来强制执行。因此,(3+2)*3将首先执行求和,然后是乘法,这次结果是 15。

让我们通过一些例子来澄清这个相当棘手的问题:

<?php
$a = 1;
$b = 3;
$c = true;
$d = false;
$e = $a + $b > 5 || $c; // true
var_dump($e);
$f = $e == true && !$d; // true
var_dump($f);
$g = ($a + $b) * 2 + 3 * 4; // 20
var_dump($g);

这个先前的例子可能是无穷无尽的,而且仍然不能涵盖你所能想象的所有场景,所以让我们保持简单。在第一行高亮的代码中,我们有算术、比较和逻辑操作符的组合,以及赋值操作符。因为没有括号,所以顺序是前面表格中详细说明的。优先级最高的操作符是加法,所以我们首先执行它:$a + $b 等于 4。下一个是比较操作符,所以 4 > 5,结果是 false。最后,逻辑操作符,false || $c ($ctrue) 结果为 true

第二个例子可能需要更多的解释。表中我们看到的第一种操作符是取反,所以我们解决它。!$d!false,所以它是 true。现在表达式是 $e == true && true。首先我们需要解决比较 $e == true。知道 $etrue,比较结果为 true。最后的操作是逻辑结束,结果为 true

尝试自己解决最后一个例子以获得一些练习。如果你认为我们没有充分覆盖操作符,不要害怕。在接下来的几节中,我们将看到很多例子。

处理字符串

在现实生活中处理字符串真的很简单。像 检查这个字符串是否包含这个告诉我这个字符出现多少次 这样的动作很容易执行。但在编程时,字符串是字符的连接,你在搜索时不能一次看到所有的内容。相反,你必须一个一个地查看并跟踪内容。在这种情况下,那些很容易的动作不再那么容易了。

幸运的是,PHP 带有一整套预定义的函数,可以帮助你与字符串交互。你可以在 php.net/manual/en/ref.strings.php 找到函数的完整列表,但我们只会介绍最常用的那些。让我们看看一些例子:

<?php

$text = '   How can a clam cram in a clean cream can? ';

echo strlen($text); // 45
$text = trim($text);
echo $text; // How can a clam cram in a clean cream can?
echo strtoupper($text); // HOW CAN A CLAM CRAM IN A CLEAN CREAM CAN?
echo strtolower($text); // how can a clam cram in a clean cream can?
$text = str_replace('can', 'could', $text);
echo $text; // How could a clam cram in a clean cream could?
echo substr($text, 2, 6); // w coul
var_dump(strpos($text, 'can')); // false
var_dump(strpos($text, 'could')); // 4

在前面长段代码中,我们正在使用不同的函数玩字符串:

  • strlen: 这个函数返回字符串包含的字符数。

  • trim: 这个函数返回字符串,移除所有左边的空白和右边的空白。

  • strtoupperstrtolower: 这些函数分别返回所有字符都为大写或小写的字符串。

  • str_replace: 这个函数将给定的字符串的所有出现替换为替换字符串。

  • substr: 这个函数提取由参数指定的位置之间的字符串,第一个字符位于位置 0。

  • strpos: 这个函数显示给定字符串第一次出现的位置。如果字符串找不到,则返回 false

此外,还有一个用于字符串的运算符(.),它可以连接两个字符串(或者当可能时将两个变量转换为字符串)。使用它非常简单:在下面的例子中,最后的语句将连接所有字符串和变量,形成句子I am Hiro Nakamura!

<?php
$firstname = 'Hiro';
$surname = 'Nakamura';
echo 'I am ' . $firstname . ' ' . $surname . '!';

关于字符串,还有一点需要注意,那就是它们的表示方式。到目前为止,我们一直用单引号括起来字符串,但你也可以用双引号括起来。区别在于,在单引号内,字符串的表示方式就是它本身,但在双引号内,在显示最终结果之前会应用一些规则。双引号与单引号处理不同的两个元素是:转义字符和变量扩展。

  • 转义字符:这些是无法轻易表示的特殊字符。转义字符的例子包括换行符或制表符。为了表示它们,我们使用转义序列,它是反斜杠(\)后跟其他字符的连接。例如,\n代表换行符,\t代表制表符。

  • 变量扩展:这允许你在字符串中包含变量引用,PHP 会用它们的当前值来替换它们。你还需要包括$符号。

看看下面的例子:

<?php
$firstname = 'Hiro';
$surname = 'Nakamura';
echo "My name is $firstname $surname.\nI am a master of time and space. \"Yatta!\"";

上述代码将在浏览器中打印以下内容:

My name is Hiro Nakamura.
I am a master of time and space. "Yatta!"

在这里,\n插入了一个新行。\"添加了双引号(你也需要转义它们,因为 PHP 会理解你想要结束字符串),变量$firstname$surname被它们的值所替换。

数组

如果你有一些其他编程语言或数据结构的一般经验,你可能已经知道两种非常常见且有用的数据结构:列表映射。列表是有序元素集合,而映射是带有键的元素集合。让我们看看一个例子:

List: ["Harry", "Ron", "Hermione"]

Map: {
  "name": "James Potter",
  "status": "dead"
}

第一个元素是一个包含三个值的名字列表:HarryRonHermione。第二个是一个映射,它定义了两个值:James Potterdead。这两个值中的每一个都通过一个键来标识:namestatus

在 PHP 中,我们没有列表和映射;我们有数组。数组是一种数据结构,它实现了列表和映射。

初始化数组

初始化数组有多种选择。你可以初始化一个空数组,或者初始化一个包含数据的数组。数组中用相同的方式写相同的数据也有不同的方法。让我们看看一些例子:

<?php
$empty1 = [];
$empty2 = array();
$names1 = ['Harry', 'Ron', 'Hermione'];
$names2 = array('Harry', 'Ron', 'Hermione');
$status1 = [
    'name' => 'James Potter',
    'status' => 'dead'
];
$status2 = array(
    'name' => 'James Potter',
    'status' => 'dead'
);

在前面的例子中,我们定义了上一节中的列表和映射。$names1$names2是完全相同的数组,只是使用了不同的表示法。同样,$status1$status2也是这样。最后,$empty1$empty2是创建空数组的两种方式。

以后你会看到列表被像映射一样处理。内部,数组$names1是一个映射,其键是有序的数字。在这种情况下,对$names1的另一种初始化,可以导致相同的数组,如下所示:

$names1 = [
    0 => 'Harry',
    1 => 'Ron',
    2 => 'Hermione'
];

数组的键可以是任何字母数字值,如字符串或数字。数组的值可以是任何东西:字符串、数字、布尔值、其他数组等等。你可能会有以下这样的东西:

<?php
$books = [
    '1984' => [
        'author' => 'George Orwell',
        'finished' => true,
        'rate' => 9.5
    ],
    'Romeo and Juliet' => [
        'author' => 'William Shakespeare',
        'finished' => false
    ]
];

这个数组是一个包含两个数组——映射的列表。每个映射包含不同的值,如字符串、双精度浮点数和布尔值。

填充数组

数组不是不可变的,也就是说,初始化后它们可以改变。你可以通过将其视为映射或列表来更改数组的内容。将其视为映射意味着你指定要覆盖的键,而将其视为列表意味着将另一个元素追加到数组的末尾:

<?php
$names = ['Harry', 'Ron', 'Hermione'];
$status = [
    'name' => 'James Potter',
    'status' => 'dead'
];
$names[] = 'Neville';
$status['age'] = 32;
print_r($names, $status);

在前面的示例中,第一行高亮显示的行将名称Neville追加到名称列表中,因此列表将看起来像['Harry', 'Ron', 'Hermione', 'Neville']。第二个更改实际上向数组添加了一个新的键值对。你可以通过使用函数print_r来检查结果。它做的是类似var_dump的事情,只是没有每个值的类型和大小。

注意

浏览器中的 print_r 和 var_dump

当打印数组的内容时,看到每个键值一行很有用,但如果你检查浏览器,你会看到它在一行中显示整个数组。这是因为浏览器试图显示的是 HTML,它忽略了换行符或空白。为了检查数组的内容,就像 PHP 希望你看的那样,请检查页面的源代码——你可以在页面上右键单击来看到选项。

如果你需要从数组中删除一个元素,而不是添加或更新一个,你可以使用unset函数:

<?php
$status = [
    'name' => 'James Potter',
    'status' => 'dead'
];
unset($status['status']);
print_r ($status);

新的$status数组只包含键名。

访问数组

访问数组就像指定键时更新它一样简单。为此,你需要了解列表是如何工作的。你已经知道列表在内部被视为一个具有顺序数字键的映射。第一个键总是 0;因此,具有n个元素的数组将具有从 0 到n-1的键。

你可以向给定的数组添加任何键,即使它之前只包含数字条目。问题出现在添加数字键时,后来你尝试向数组追加一个元素。你认为会发生什么?

<?php
$names = ['Harry', 'Ron', 'Hermione'];
$names['badguy'] = 'Voldemort';
$names[8] = 'Snape';
$names[] = 'McGonagall';
print_r($names);

最后一小段代码的结果如下:

Array
(
    [0] => Harry
    [1] => Ron
    [2] => Hermione
    [badguy] => Voldemort
    [8] => Snape
    [9] => McGonagall
)

当尝试追加一个值时,PHP 将其插入到最后一个数字键之后,在这种情况下是8

你可能已经自己想出来了,但你总是可以通过指定其键来打印数组的任何部分:

<?php
$names = ['Harry', 'Ron', 'Hermione'];
print_r($names[1]); // prints 'Ron'

最后,尝试访问数组中不存在的键将返回 null 并抛出一个警告,因为 PHP 识别出你在代码中做了错误的事情。

<?php
$names = ['Harry', 'Ron', 'Hermione'];
var_dump($names[4]); // null and a PHP notice

空和 isset 函数

有两个有用的函数可以用来查询数组的内容。如果你想检查数组是否包含任何元素,你可以使用 empty 函数来检查它是否为空。实际上,这个函数也可以用于字符串,一个空字符串就是一个没有字符的字符串(' ')。isset 函数接受一个数组位置,并返回 truefalse,取决于该位置是否存在:

<?php
$string = '';
$array = [];
$names = ['Harry', 'Ron', 'Hermione'];
var_dump(empty($string)); // true
var_dump(empty($array)); // true
var_dump(empty($names)); // false
var_dump(isset($names[2])); // true
var_dump(isset($names[3])); // false

在前面的例子中,我们可以看到,一个没有元素的数组或一个没有字符的字符串,当被询问是否为空时,会返回 true,否则返回 false。当我们使用 isset($names[2]) 来检查数组位置 2 是否存在时,我们得到 true,因为该键有一个值:Hermione。最后,isset($names[3]) 评估为 false,因为该数组中不存在键 3。

在数组中搜索元素

可能,与数组一起使用最频繁的函数之一是 in_array。这个函数接受两个值,你想要搜索的值和数组。如果值在数组中,函数返回 true,否则返回 false。这非常有用,因为很多时候你从列表或映射中想要知道的是它是否包含一个元素,而不是知道它是否存在或其位置。

有时,array_search 函数更加有用。这个函数的工作方式与之前相同,只不过它返回的是一个布尔值,而不是找到的值对应的键,如果没有找到则返回 false。让我们看看这两个函数:

<?php
$names = ['Harry', 'Ron', 'Hermione'];
$containsHermione = in_array('Hermione', $names);
var_dump($containsHermione); // true
$containsSnape = in_array('Snape', $names);
var_dump($containsSnape); // false
$wheresRon = array_search('Ron', $names);
var_dump($wheresRon); // 1
$wheresVoldemort = array_search('Voldemort', $names);
var_dump($wheresVoldemort); // false

排序数组

数组可以以不同的方式排序,所以很可能你需要的是与当前顺序不同的顺序。默认情况下,数组是按照元素被添加到数组的顺序排序的,但你也可以按键或值对其进行排序,包括升序和降序。此外,当按值排序数组时,你可以选择保留它们的键或者生成一个新的列表。

这些函数的完整列表可以在官方文档网站上找到,网址为 php.net/manual/en/array.sorting.php,但在这里我们将展示其中最重要的几个:

名称 排序方式 维护键关联 排序顺序
sort 从低到高
rsort 从高到低
asort 从低到高
arsort 从高到低
ksort 从低到高
krsort 从高到低

这些函数始终接受一个参数,即数组,并且它们不返回任何内容。相反,它们直接对传递给它们的数组进行排序。让我们看看其中的一些函数:

<?php
$properties = [
    'firstname' => 'Tom',
    'surname' => 'Riddle',
    'house' => 'Slytherin'
];
$properties1 = $properties2 = $properties3 = $properties;
sort($properties1);
var_dump($properties1);
asort($properties3);
var_dump($properties3);
ksort($properties2);
var_dump($properties2);

好吧,最后一个示例中有很多内容。首先,我们使用一些键值初始化一个数组,并将其分配给$properties。然后我们创建三个变量,它们是原始数组的副本——语法应该是直观的。我们为什么要这样做?因为我们如果对原始数组进行排序,我们就不会再有原始内容了。在这个特定的例子中,我们不想这样做,因为我们想看到不同的排序函数如何影响同一个数组。最后,我们执行了三种不同的排序,并打印出每个结果。浏览器应该显示如下:

array(3) {
  [0]=>
  string(6) "Riddle"
  [1]=>
  string(9) "Slytherin"
  [2]=>
  string(3) "Tom"
}
array(3) {
  ["surname"]=>
  string(6) "Riddle"
  ["house"]=>
  string(9) "Slytherin"
  ["firstname"]=>
  string(3) "Tom"
}
array(3) {
  ["firstname"]=>
  string(3) "Tom"
  ["house"]=>
  string(9) "Slytherin"
  ["surname"]=>
  string(6) "Riddle"
}

第一个函数sort按字母顺序排序值。此外,如果您检查键,现在它们是列表中的数字,而不是原始键。相反,asort以相同的方式排序值,但保持键值关联。最后,ksort按键的字母顺序排序元素。

提示

如何记住这么多函数名

PHP 有很多函数助手,可以帮助您避免自己编写自定义函数,例如,它提供了多达 13 种不同的排序函数。您始终可以依赖官方文档。但当然,您可能希望编写不返回文档的代码。因此,这里有一些提示来记住每个排序函数的作用:

  • 名称中的a表示关联的,因此将保留键值关联。

  • 名称中的r表示反向的,所以顺序将是从高到低。

  • k表示,因此排序将基于键而不是值。

其他数组函数

大约有 80 个与数组相关的不同函数。正如您所想象的那样,您甚至可能从未听说过其中的一些,因为它们具有非常特定的用途。完整的列表可以在php.net/manual/en/book.array.php找到。

我们可以使用array_keys获取数组的键列表,以及使用array_values获取其值列表:

<?php
$properties = [
    'firstname' => 'Tom',
    'surname' => 'Riddle',
    'house' => 'Slytherin'
];
$keys = array_keys($properties);
var_dump($keys);
$values = array_values($properties);
var_dump($values);

我们可以使用count函数来获取数组中的元素数量:

<?php
$names = ['Harry', 'Ron', 'Hermione'];
$size = count($names);
var_dump($size); // 3

我们可以使用array_merge将两个或多个数组合并为一个:

<?php
$good = ['Harry', 'Ron', 'Hermione'];
$bad = ['Dudley', 'Vernon', 'Petunia'];
$all = array_merge($good, $bad);
var_dump($all);

最后一个示例将打印以下数组:

array(6) {
  [0]=>
  string(5) "Harry"
  [1]=>
  string(3) "Ron"
  [2]=>
  string(8) "Hermione"
  [3]=>
  string(6) "Dudley"
  [4]=>
  string(6) "Vernon"
  [5]=>
  string(7) "Petunia"
}

如您所见,第二个数组的键现在不同了,因为最初,这两个数组都有相同的数字键,而一个数组不能有两个相同的键的值。

PHP 在 Web 应用程序中

尽管本章的主要目的是向您展示 PHP 的基础知识,但以参考手册的方式来做并不足够有趣,如果我们只是复制粘贴官方文档中的内容,您还不如自己去那里阅读。考虑到本书的主要目的和您的目标是用 PHP 编写网络应用程序,让我们尽快向您展示如何应用您所学的所有内容,以免您感到过于无聊。

为了做到这一点,我们现在将开始一段旅程,目标是构建一个在线书店。在最开始的时候,你可能看不到它的实用性,但这仅仅是因为我们还没有展示 PHP 能做的一切。

从用户获取信息

让我们先从构建一个主页开始。在这个页面上,我们要确定用户是在找书还是在路过。我们如何找到这个信息呢?目前最简单的方法是检查用户用来访问我们应用程序的 URL,并从中提取一些信息。

将此内容保存为你的index.php

<?php
$looking = isset($_GET['title']) || isset($_GET['author']);
?>
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Bookstore</title>
</head>
<body>
 <p>You lookin'? <?php echo (int) $looking; ?></p>
    <p>The book you are looking for is</p>
    <ul>
        <li><b>Title</b>: <?php echo $_GET['title']; ?></li>
        <li><b>Author</b>: <?php echo $_GET['author']; ?></li>
    </ul>
</body>
</html>

现在访问链接,http://localhost:8000/?author=HarperLee&title=To Kill a Mockingbird。你会看到页面打印了你传递给 URL 的一些信息。

对于每个请求,PHP 会将来自查询字符串的所有参数存储在一个名为$_GET的数组中。数组的每个键都是参数的名称,其关联的值是参数的值。因此$_GET包含两个条目:$_GET['author']包含Harper Lee,而$_GET['title']的值是To Kill a Mockingbird

在第一行高亮显示的代码中,我们给变量$looking赋了一个布尔值。如果$_GET['title']$_GET['author']存在,该变量将为true,否则为false。紧接着,我们关闭了 PHP 标签,然后打印了一些 HTML,但正如你所看到的,我们实际上是在混合 HTML 和一些 PHP 代码。

这里还有另一条有趣的代码行,即第二行高亮显示的代码。在打印$looking的内容之前,我们进行了类型转换。类型转换意味着强制 PHP 将一种类型的值转换为另一种类型。将布尔值转换为整数意味着如果布尔值为true,则结果值为1,如果布尔值为false,则结果值为0。由于$_GET包含有效的键,$lookingtrue,因此页面显示了一个1

如果我们尝试在不发送任何信息的情况下访问相同的页面,如http://localhost:8000,浏览器将显示你在找一本书吗?0。根据你的 PHP 配置设置,你会看到两条通知消息,抱怨你正在尝试访问不存在的数组键。

注意

类型转换与类型转换

我们已经知道,当 PHP 需要特定类型的变量时,它会尝试将其转换,这被称为类型转换。但 PHP 非常灵活,所以有时你必须指定你需要的类型。当使用echo打印内容时,PHP 会尝试将得到的所有内容转换为字符串。由于布尔值false的字符串形式是一个空字符串,这对我们的应用程序来说可能没有用。首先将布尔值转换为整数可以确保我们能看到一个值,即使它只是一个 0。

HTML 表单

HTML 表单是收集用户信息最受欢迎的方式之一。它们由一系列字段组成——在 HTML 世界中称为 input 字段——以及一个最终的 submit 按钮。在 HTML 中,form 标签包含两个属性:action 指定了表单将被提交的位置,而 method 指定了表单将使用的 HTTP 方法(GET 或 POST)。让我们看看它是如何工作的。将以下内容保存为 login.html 并访问 http://localhost:8000/login.html

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Bookstore - Login</title>
</head>
<body>
    <p>Enter your details to login:</p>
 <form action="authenticate.php" method="post">
        <label>Username</label>
 <input type="text" name="username" />
        <label>Password</label>
 <input type="password" name="password" />
 <input type="submit" value="Login"/>
    </form>
</body>
</html>

前面代码中定义的表单包含两个字段,一个用于用户名,一个用于密码。你可以看到它们通过属性 name 来标识。如果你尝试提交这个表单,浏览器将显示一个 页面未找到 消息,因为它正在尝试访问 http://localhost:8000/authenticate.php,而 web 服务器找不到它。那么让我们创建它:

<?php
$submitted = !empty($_POST);
?>
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Bookstore</title>
</head>
<body>
    <p>Form submitted? <?php echo (int) $submitted; ?></p>
    <p>Your login info is</p>
    <ul>
        <li><b>username</b>: <?php echo $_POST['username']; ?></li>
        <li><b>password</b>: <?php echo $_POST['password']; ?></li>
    </ul>
</body>
</html>

$_GET 类似,$_POST 是一个包含通过 POST 接收的参数的数组。在这段代码的前一部分,我们首先检查该数组是否为空——注意 ! 操作符。之后,我们只需显示接收到的信息,就像在 index.php 中一样。请注意,$_POST 数组的键是每个输入字段的参数名称的值。

使用 cookies 持久化数据

当我们希望浏览器记住一些数据,比如在你的 web 应用程序中你是否已登录,你的基本信息等等,我们使用 cookies。Cookies 存储在客户端,并在请求时作为头部信息发送到服务器。由于 PHP 面向 web 应用程序,它允许你以非常简单的方式管理 cookies。

关于 cookies 和 PHP,你需要了解一些事情。你可以使用 setcookie 函数来写入 cookies,该函数接受多个参数:

  • 一个有效的 cookie 名称作为字符串。

  • cookie 的值——只能是字符串或可以转换为字符串的值。此参数是可选的,如果没有设置,PHP 实际上会删除 cookie。

  • 过期时间作为时间戳。如果没有设置,cookie 将在浏览器关闭时被删除。

注意

时间戳

计算机使用不同的方式来描述日期和时间,其中最常见的一种,尤其是在 Unix 系统上,就是使用时间戳。它们表示自 1970 年 1 月 1 日以来经过的秒数。例如,表示 2015 年 10 月 4 日下午 6:30 的时间戳将是 1,443,954,637,这是自该日期以来的秒数。

你可以使用 PHP 的 time 函数获取当前的时间戳。

与安全相关的其他参数也存在,但它们超出了本节的范围。此外,请注意,你只能在应用程序没有之前的输出之前设置 cookies,也就是说,在 HTML、echo 调用以及任何其他发送输出的类似函数之前。

要读取客户端发送给我们的 cookies,我们只需访问数组 $_COOKIE。它像其他两个数组一样工作,因此数组的键将是 cookies 的名称,数组的值将是它们的值。

非常常见的 cookies 用法是验证用户身份。根据您应用程序所需的 安全级别,有几种不同的方法可以实现。让我们尝试实现一个非常简单——尽管不安全的例子(不要用于实际 Web 应用程序)。保持 HTML 不变,更新你的authenticate.php文件的 PHP 部分,内容如下:

<?php
setcookie('username', $_POST['username']);
$submitted = !empty($_POST);
?>

同样,对index.php中的body标签做同样的处理:

<body>
 <p>You are <?php echo $_COOKIE['username']; ?></p>
    <p>Are you looking for a book? <?php echo (int) $lookingForBook; ?></p>
    <p>The book you are looking for is</p>
    <ul>
        <li><b>Title</b>: <?php echo $_GET['title']; ?></li>
        <li><b>Author</b>: <?php echo $_GET['author']; ?></li>
    </ul>
</body>

如果你再次访问http://localhost:8000/login.html,尝试登录,打开一个新的标签页(在同一浏览器中),并转到主页http://localhost:8000,你会看到浏览器仍然记得你的用户名。

其他超全局变量

$_GET$_POST$_COOKIE是称为超全局变量的特殊变量。还有其他超全局变量,如$_SERVER$_ENV,它们会给你提供额外的信息。第一个显示了关于标题、访问的路径和其他与请求相关的信息。第二个包含了运行应用程序的机器的环境变量。你可以在php.net/manual/es/language.variables.superglobals.php上看到这些数组的完整列表及其元素。

通常,使用超全局变量是有用的,因为它允许你从用户、浏览器、请求等获取信息。这对于编写需要与用户交互的 Web 应用程序来说具有无法估量的价值。但是,权力越大,责任越大,使用这些数组时你应该非常小心。这些值中的大多数都来自用户本身,这可能导致安全问题。

控制结构

到目前为止,我们的文件是逐行执行的。正因为如此,我们在某些场景下收到了通知,例如当数组不包含我们正在寻找的内容时。如果我们能选择执行哪些行会不是很好?控制结构来拯救我们!

控制结构就像一个交通分流标志。它根据一些预定义的条件来指导执行流程。有不同种类的控制结构,但我们可以将它们分为条件循环。条件允许我们选择是否执行一个语句。循环会根据需要多次执行一个语句。让我们来看看每一个。

条件

条件评估一个布尔表达式,即返回值的某种东西。如果表达式为true,它将执行其代码块内的所有内容。代码块是一组由{}包围的语句。让我们看看它是如何工作的:

<?php
echo "Before the conditional.";
if (4 > 3) {
 echo "Inside the conditional.";
}
if (3 > 4) {
 echo "This will not be printed.";
}
echo "After the conditional.";

在前面的代码片段中,我们使用了两个条件。条件是通过关键字if后跟括号中的布尔表达式和代码块来定义的。如果表达式为true,它将执行该块,否则将跳过它。

你可以通过添加关键字else来增强条件语句的威力。这告诉 PHP,如果前面的条件没有得到满足,则执行一些代码块。让我们看看一个例子:

if (2 > 3) {
    echo "Inside the conditional.";
} else {
 echo "Inside the else.";
}

上述示例将在if的条件没有得到满足时执行else中的代码。

最后,你还可以添加一个elseif关键字,后面跟着另一个条件和一段代码,以继续向 PHP 请求更多条件。你可以在if之后添加任意数量的elseif。如果你添加了else,它必须是条件链中的最后一个。同时,请注意,一旦 PHP 找到一个解析为true的条件,它将停止评估剩余的条件。

<?php
if (4 > 5) {
    echo "Not printed";
} elseif (4 > 4) {
    echo "Not printed";
} elseif (4 == 4) {
 echo "Printed.";
} elseif (4 > 2) {
    echo "Not evaluated.";
} else {
    echo "Not evaluated.";
}
if (4 == 4) {
    echo "Printed";
}

在最后一个例子中,第一个评估为true的条件是突出显示的那个。在那之后,PHP 不会评估更多的条件,直到一个新的if开始。

带着这些知识,让我们尝试清理我们的应用程序,只在需要时执行语句。将此代码复制到你的index.php文件中:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Bookstore</title>
</head>
<body>
    <p>
<?php
if (isset($_COOKIE[username'])) {
 echo "You are " . $_COOKIE['username'];
} else {
 echo "You are not authenticated.";
}
?>
    </p>
<?php
if (isset($_GET['title']) && isset($_GET['author'])) {
?>
 <p>The book you are looking for is</p>
 <ul>
 <li><b>Title</b>: <?php echo $_GET['title']; ?></li>
 <li><b>Author</b>: <?php echo $_GET['author']; ?></li>
 </ul>
<?php
} else {
?>
 <p>You are not looking for a book?</p>
<?php
}
?>
</body>
</html>

在这段新代码中,我们以两种不同的方式混合了条件和 HTML 代码。第一种是打开一个 PHP 标签,并添加一个if…else子句,该子句将使用echo打印我们是否已认证。条件内没有合并 HTML,这使得它很清晰。

第二种选项——第二个突出显示的块——展示了一个更丑陋但有时必要的解决方案。当你需要打印大量的 HTML 代码时,echo并不那么方便,最好是关闭 PHP 标签,打印所有需要的 HTML,然后再打开标签。你甚至可以在if子句的代码块内这样做,就像你可以在代码中看到的那样。

注意

混合 PHP 和 HTML

如果你觉得我们最后编辑的文件看起来相当丑陋,你是对的。混合 PHP 和 HTML 会让人困惑,你应该避免这样做。在第六章,适应 MVC中,我们将看到如何正确地做事。

让我们再编辑一下authenticate.php文件,因为它正在尝试访问可能不存在的$_POST条目。文件的新内容如下:

<?php
$submitted = isset($_POST['username']) && isset($_POST['password']);
if ($submitted) {
    setcookie('username', $_POST['username']);
}
?>
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Bookstore</title>
</head>
<body>
<?php if ($submitted): ?>
    <p>Your login info is</p>
    <ul>
        <li><b>username</b>: <?php echo $_POST['username']; ?></li>
        <li><b>password</b>: <?php echo $_POST['password']; ?></li>
    </ul>
<?php else: ?>
    <p>You did not submit anything.</p>
<?php endif; ?>
</body>
</html>

这段代码也包含条件语句,这是我们已知的。我们正在设置一个变量来知道是否提交了登录,如果是的话,则设置 cookies。但突出显示的行展示了使用 HTML 包含条件语句的新方法。这使得在处理 HTML 代码时代码更易读,避免了使用{},而是使用:endif。两种语法都是正确的,你应该在每个情况下使用你认为更易读的一种。

Switch…case

if…else类似的另一个控制结构是switch…case。这个结构只评估一个表达式,并根据其值执行相应的代码块。让我们看看一个例子:

<?php
switch ($title) {
    case 'Harry Potter':
        echo "Nice story, a bit too long.";
        break;
    case 'Lord of the Rings':
        echo "A classic!";
        break;
    default:
        echo "Dunno that one.";
        break;
}

switch子句接受一个表达式,在这个例子中是一个变量,然后定义了一系列的情况。当某个情况与表达式的当前值匹配时,PHP 会执行其内部的代码。一旦 PHP 找到一个break语句,它就会退出switch…case。如果没有适合表达式的任何情况,PHP 会执行默认情况(如果有的话),但这不是必须的。

你还需要知道,如果你想退出switch…case,break 是强制性的。如果你没有指定任何,PHP 会继续执行语句,即使它遇到了新的情况。让我们看一个类似的例子,但没有 break 语句:

<?php
$title = 'Twilight';
switch ($title) {
    case 'Harry Potter':
        echo "Nice story, a bit too long.";
    case 'Twilight':
        echo 'Uh...';
    case 'Lord of the Rings':
        echo "A classic!";
    default:
        echo "Dunno that one.";
}

如果你在这个浏览器中测试这段代码,你会看到它打印出嗯...经典!不知道这个。PHP 发现第二个情况是有效的,因此执行了其内容。但是因为没有 break 语句,它会一直执行到末尾。这可能在某些情况下是期望的行为,但通常不是,所以使用时要小心!

循环

循环是允许你多次执行某些语句的控制结构,你可以根据需要多次执行。你可能会在多种不同的场景中使用它们,但最常见的一个是在与数组交互时。例如,想象你有一个包含元素的数组,但你不知道里面有什么。你想要打印出所有的元素,所以你会遍历它们。

有四种类型的循环。每种类型都有自己的使用场景,但一般来说,你可以将一种类型的循环转换为另一种类型。让我们仔细看看它们。

While

while循环是最简单的循环。它会在要评估的表达式返回false之前执行代码块。让我们看一个例子:

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

在前面的例子中,我们定义了一个值为1的变量。然后我们有一个while子句,其中要评估的表达式是$i < 4。这个循环会执行代码块的内容,直到该表达式为false。正如你所看到的,在循环内部,我们每次都会将$i的值增加 1,所以循环会在 4 次迭代后结束。检查那个脚本的输出,你会看到"0 1 2 3"。最后打印的值是 3,所以在那时$i的值是 3。之后,我们将其值增加到 4,所以当while子句评估$i < 4时,结果是false

注意

循环和无限循环

while循环最常见的一个问题就是创建无限循环。如果你没有在while循环内添加任何代码来更新while表达式中考虑的任何变量,使得它在某个时刻可以返回false,PHP 将永远不会退出循环!

Do…while

do…while循环在某种程度上与while循环非常相似,因为它每次都会评估一个表达式,并且会执行代码块直到该表达式为false。唯一的区别在于,当这个表达式被评估时,while子句会在执行代码之前评估这个表达式,所以有时候,如果表达式第一次评估就为false,我们甚至可能不会进入循环。另一方面,do…while在执行其代码块之后评估这个表达式,所以即使表达式一开始就是false,循环至少也会执行一次。

<?php
echo "with while: ";
$i = 1;
while ($i < 0) {
    echo $i . " ";
    $i++;
}
echo "with do-while: ";
$i = 1;
do {
 echo $i . " ";
 $i++;
} while ($i < 0);

上述代码定义了两个具有相同表达式和代码块的循环,但如果你执行它们,你会看到只有do…while中的代码被执行。在这两种情况下,表达式从一开始就是false,所以while甚至没有进入循环,而do…while进入循环一次。

For

for循环是四种循环中最复杂的。它定义了一个初始化表达式、一个退出条件和迭代结束表达式。当 PHP 第一次遇到循环时,它会执行初始化表达式所定义的内容。然后,它会评估退出条件,如果它解析为true,它会进入循环。在执行循环内的所有内容之后,它会执行迭代结束表达式。一旦完成,它会再次评估结束条件,通过循环代码和迭代结束表达式,直到它评估为false。就像往常一样,一个例子会澄清它:

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

初始化表达式是$i = 1,并且只执行第一次。退出条件是$i < 10,它在每次迭代的开始时被评估。迭代结束表达式是$i++,它在每次迭代的结束时执行。这个例子会打印从 1 到 9 的数字。for循环的另一种更常见的用法是与数组一起使用:

<?php
$names = ['Harry', 'Ron', 'Hermione'];
for ($i = 0; $i < count($names); $i++) {
    echo $names[$i] . " ";
}

在这个例子中,我们有一个名字数组。由于它被定义为列表,它的键将是 0、1 和 2。循环将变量$i初始化为 0,并且它迭代直到$i的值不小于数组中的元素数量,即 3。在第一次迭代中,$i是 0,在第二次迭代中是 1,在第三次迭代中是 2。当$i是 3 时,它将不会进入循环,因为退出条件评估为false

在每次迭代中,我们打印数组中位置$i的内容,因此这段代码的结果将是数组中的所有三个名字。

Tip

注意退出条件

很常见的是设置一个不是我们真正需要的退出条件,尤其是在处理数组时。记住,如果数组是一个列表,它从 0 开始,所以一个有三个元素的数组将有条目 0、1 和 2。将退出条件定义为$i <= count($array)会在你的代码中引起错误,因为当$i是 3 时,它也满足退出条件,并尝试访问不存在的键 3。

Foreach

最后,但同样重要的是,循环类型是 foreach。这个循环仅适用于数组,它允许您完全遍历一个数组,即使您不知道它的键。语法有两种选择,如以下示例所示:

<?php
$names = ['Harry', 'Ron', 'Hermione'];
foreach ($names as $name) {
    echo $name . " ";
}
foreach ($names as $key => $name) {
    echo $key . " -> " . $name . " ";
}

foreach 循环接受一个数组——在这个例子中是 $names——并指定一个变量,该变量将包含数组的条目值。您可以看到我们不需要指定任何结束条件,因为 PHP 会知道何时遍历完数组。可选地,您可以指定一个变量,它包含每个迭代的键,就像第二个循环中那样。

foreach 循环与映射也很有用,其中键不一定是数字。PHP 遍历数组的顺序将与您在数组中插入内容的顺序相同。

让我们在我们的应用程序中使用一些循环。我们想在主页上显示可用的书籍。我们有一个书籍列表在数组中,所以我们将不得不使用 foreach 循环遍历它们,并从每本书中打印一些信息。将以下代码添加到 index.php 中的 body 标签:

<?php endif;
    $books = [
        [
            'title' => 'To Kill A Mockingbird',
            'author' => 'Harper Lee',
            'available' => true,
            'pages' => 336,
            'isbn' => 9780061120084
        ],
        [
            'title' => '1984',
            'author' => 'George Orwell',
            'available' => true,
            'pages' => 267,
            'isbn' => 9780547249643
        ],
        [
            'title' => 'One Hundred Years Of Solitude',
            'author' => 'Gabriel Garcia Marquez',
            'available' => false,
            'pages' => 457,
            'isbn' => 9785267006323
        ],
    ];
?>
<ul>
<?php foreach ($books as $book): ?>
 <li>
 <i><?php echo $book['title']; ?></i>
 - <?php echo $book['author']; ?>
<?php if (!$book['available']): ?>
 <b>Not available</b>
<?php endif; ?>
 </li>
<?php endforeach; ?>
    </ul>

突出的代码显示了使用 : 符号的 foreach 循环,当与 HTML 混合使用时更好。它遍历所有的 $books 数组,并为每本书打印一些作为 HTML 列表的信息。请注意,我们还在循环内部有一个条件语句,这是完全正常的。当然,这个条件语句将为数组中的每个条目执行,所以你应该尽可能使循环的代码块保持简单。

函数

函数是一段可重用的代码块,给定输入,执行一些操作,并可选地返回一些结果。您已经知道几个预定义的函数,如 emptyin_arrayvar_dump。这些函数随 PHP 一起提供,因此您不必重新发明轮子,但您可以非常容易地创建自己的函数。当您确定应用程序中需要多次执行的部分或只是要封装一些功能时,您可以定义函数。

函数声明

声明一个函数意味着将其写下来以便以后使用。一个函数有一个名称,接受一些参数,并包含一段代码块。可选地,它可以定义要返回的值的类型。函数的名称必须遵循与变量名称相同的规则,即它必须以字母或下划线开头,并且可以包含任何字母、数字或下划线。它不能是保留字。

让我们看看一个简单的例子:

function addNumbers($a, $b) {
    $sum = $a + $b;
    return $sum;
}
$result = addNumbers(2, 3);

前一个函数的名称是 addNumbers,它接受两个参数:$a$b。代码块定义了一个新变量 $sum,它是两个参数的和,然后使用 return 返回其内容。为了使用此函数,您只需按其名称调用它,同时发送所有必需的参数,如突出显示的行所示。

PHP 不支持重载函数。重载指的是声明两个或更多具有相同名称但参数不同的函数的能力。正如你所见,你可以声明参数而不知道它们的类型,所以 PHP 无法决定使用哪个函数。

另一个需要注意的重要事项是变量作用域。我们在代码块内部声明了一个变量$sum,所以一旦函数结束,该变量将不再可访问。这意味着在函数内部声明的变量的作用域只是函数本身。此外,如果你在函数外部声明了一个变量$sum,它将完全不受影响,因为函数无法访问那个变量,除非我们将其作为参数传递。

函数参数

函数通过参数从外部获取信息。你可以定义任意数量的参数——包括 0(没有)。这些参数至少需要一个名称,以便在函数内部使用;不能有两个具有相同名称的参数。在调用函数时,你需要按照声明的顺序发送参数。

一个函数可能包含可选参数,也就是说,你不必为这些参数提供值。在声明函数时,你需要为这些参数提供一个默认值。因此,如果用户没有提供值,函数将使用默认值。

function addNumbers($a, $b, $printResult = false) {
    $sum = $a + $b;
    if ($printResult) {
        echo 'The result is ' . $sum;
    }
    return $sum;
}

$sum1 = addNumbers(1, 2);
$sum1 = addNumbers(3, 4, false);
$sum1 = addNumbers(5, 6, true); // it will print the result

最后一个示例中的这个新函数接受两个必需参数和一个可选参数。可选参数的默认值是false,然后在函数内部正常使用。如果用户将true作为第三个参数提供,函数将打印求和的结果,这只有在函数被调用的第三次时才会发生。对于前两次,$printResult被设置为false

函数接收到的参数只是用户提供的值的副本。这意味着如果你在函数内部修改这些参数,它将不会影响原始值。这个特性被称为按值传递参数。让我们看一个例子:

function modify($a) {
    $a = 3;
}

$a = 2;
modify($a);
var_dump($a); // prints 2

我们声明一个变量$a,其值为2,然后调用modify方法,传递那个$amodify方法修改了参数$a,将其值设置为3,但这不会影响$a的原始值,正如你可以从var_dump中看到的那样,它仍然是2

如果你想要实际更改在调用中使用的原始变量的值,你需要按引用传递参数。要做到这一点,你需要在声明函数时在参数前添加一个和号(&):

function modify(&$a) {
    $a = 3;
}

现在,在调用modify函数时,$a将始终是3

注意

按值传递参数与按引用传递参数

PHP 允许你这样做,实际上,一些 PHP 的原生函数使用引用传递参数。记得数组排序函数吗?它们没有返回排序后的数组,而是对提供的数组进行了排序。但使用引用传递参数是一种让开发者困惑的方式。通常,当某人使用一个函数时,他们期望得到一个结果,并且他们不希望提供的参数被修改。所以尽量避免这样做;人们会感激的!

返回语句

你可以在你的函数内部有任意多的 return 语句,但 PHP 会在找到第一个 return 语句时退出函数。这意味着如果你有两个连续的 return 语句,第二个将永远不会被执行。尽管如此,如果它们在条件语句内部,多个 return 语句仍然可能是有用的。将此函数添加到你的 functions.php 文件中:

function loginMessage() {
    if (isset($_COOKIE['username'])) {
        return "You are " . $_COOKIE['username'];
    } else {
        return "You are not authenticated.";
    }
}

让我们在你的 index.php 文件中使用最后一个示例,通过替换高亮内容(注意,为了节省一些纸张,我将大部分完全没有更改的代码替换为 //…):

//...
<body>
 <p><?php echo loginMessage(); ?></p>
<?php if (isset($_GET['title']) && isset($_GET['author'])): ?>
//...

此外,如果你不希望函数返回任何内容,你可以省略 return 语句。在这种情况下,函数将在到达代码块末尾时结束。

类型提示和返回类型

随着 PHP 7 的发布,该语言允许开发者更具体地说明函数获取和返回的内容。你可以——始终可选地——指定函数需要的参数类型(类型提示),以及函数将返回的类型(返回类型)。让我们先看一个例子:

<?php

declare(strict_types=1);

function addNumbers(int $a, int $b, bool $printSum): int {
    $sum = $a + $b;
    if ($printSum) {
        echo 'The sum is ' . $sum;
    }
    return $sum;
}

addNumbers(1, 2, true);
addNumbers(1, '2', true); // it fails when strict_types is 1
addNumbers(1, 'something', true); // it always fails

此前函数声明了参数需要是整数、整数和布尔值,并且结果将是整数。现在,你知道 PHP 有类型转换,所以它通常可以将一个类型的值转换为另一个类型的等效值,例如,字符串 "2" 可以用作整数 2。为了阻止 PHP 在函数的参数和结果上使用类型转换,你可以声明指令 strict_types,如第一行高亮所示。此指令必须在每个你想强制执行此行为的文件顶部声明。

三个调用工作如下:

  • 第一次调用发送两个整数和一个布尔值,这正是函数所期望的,所以无论 strict_types 的值如何,它总是会工作。

  • 第二次调用发送一个整数、一个字符串和一个布尔值。该字符串有一个有效的整数值,所以如果 PHP 被允许使用类型转换,调用将正常解决。但在本例中,它将因为文件顶部的声明而失败。

  • 第三个调用总是会失败,因为字符串 "something" 无法转换为有效的整数。

让我们在我们的项目中尝试使用一个函数。在我们的index.php中,有一个foreach循环,它遍历书籍并打印它们。循环中的代码有点难以理解,因为它混合了 HTML 和 PHP,还有一个条件语句。让我们尝试将循环中的逻辑抽象成一个函数。首先,创建一个新的functions.php文件,内容如下:

<?php
function printableTitle(array $book): string {
    $result = '<i>' . $book['title'] . '</i> - ' . $book['author'];
    if (!$book['available']) {
        $result .= ' <b>Not available</b>';
    }
    return $result;
}

这个文件将包含我们的函数。第一个函数printableTitle接受一个表示书籍的数组,并构建一个包含书籍在 HTML 中良好表示的字符串。代码与之前相同,只是封装在一个函数中。

现在index.php将需要包含functions.php文件,然后在循环中使用该函数。让我们看看如何:

<?php require_once 'functions.php' ?>
<!DOCTYPE html>
<html lang="en">

//...

?>
    <ul>
<?php foreach ($books as $book): ?>
 <li><?php echo printableTitle($book); ?> </li>
<?php endforeach; ?>
    </ul>

//...

好吧,现在我们的循环看起来干净多了,对吧?此外,如果我们需要在其他地方打印书籍的标题,我们可以重用该函数而不是复制代码!

文件系统

如你所见,PHP 自带了许多原生函数,这些函数可以帮助你以比其他语言更简单的方式管理数组和字符串。文件系统是 PHP 试图使其尽可能简单化的另一个领域。函数列表扩展到超过 80 个不同的函数,所以我们在这里只介绍你更有可能使用的那些。

读取文件

在我们的代码中,我们定义了一个书籍列表。到目前为止,我们只有三本书,但你可以猜到,如果我们想使这个应用程序有用,列表将增长得多。在代码中存储信息根本不实用,所以我们必须开始考虑外部化它。

如果我们考虑将代码与数据分离,就没有必要继续使用 PHP 数组来定义书籍。使用一个不那么语言限制的系统将允许不知道 PHP 的人编辑文件的内容。为此有很多解决方案,比如 CSV 或 XML 文件,但如今,在 Web 应用程序中表示数据最常用的系统之一是 JSON。PHP 允许你使用几个函数将数组转换为 JSON,反之亦然:json_encodejson_decode。简单,对吧?

将以下内容保存到books.json

[
    {
        "title": "To Kill A Mockingbird",
        "author": "Harper Lee",
        "available": true,
        "pages": 336,
        "isbn": 9780061120084
    },
    {
        "title": "1984",
        "author": "George Orwell",
        "available": true,
        "pages": 267,
        "isbn": 9780547249643
    },
    {
        "title": "One Hundred Years Of Solitude",
        "author": "Gabriel Garcia Marquez",
        "available": false,
        "pages": 457,
        "isbn": 9785267006323
    }
]
file_get_contents, and transform it to a PHP array with json_decode. Replace the array with these two lines:
$booksJson = file_get_contents('books.json');
$books = json_decode($booksJson, true);

只用一个函数,我们就能将 JSON 文件中的所有内容存储在一个变量中作为字符串。使用该函数,我们将这个 JSON 字符串转换为一个数组。json_decode的第二个参数告诉 PHP 将其转换为数组,否则它将使用对象,我们还没有介绍过这些对象。

当在 PHP 函数中引用文件时,你需要知道是使用绝对路径还是相对路径。当使用相对路径时,PHP 会尝试在 PHP 脚本所在的同一目录中查找文件。如果找不到,PHP 会尝试在include_path指令中定义的其他目录中查找,但这是你想要避免的。相反,你可以使用绝对路径,这是一种确保引用不会被误解的方法。让我们看看两个例子:

$booksJson = file_get_contents('/home/user/bookstore/books.json');
$booksJson = file_get_contents(__DIR__, '/books.json');

常量__DIR__包含当前 PHP 文件的目录名,如果我们将其添加到文件名前缀,我们将得到一个绝对路径。实际上,尽管你可能认为亲自写下整个路径更好,但使用__DIR__允许你将应用程序移动到任何其他位置,而无需在代码中做任何更改,因为其内容将始终与脚本的目录相匹配,而第一个示例中的硬编码路径将不再有效。

写文件

让我们在应用程序中添加一些功能。想象一下,我们想要允许用户取走他们正在寻找的书籍,但前提是它必须是可用的。如果你记得,我们通过查询字符串来识别书籍。这并不太实用,所以让我们通过在书籍列表中添加链接来帮助用户,当你点击链接时,查询字符串将包含该书籍的信息。

<?php require_once 'functions.php' ?>
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Bookstore</title>
</head>
<body>
    <p><?php echo loginMessage(); ?></p>
<?php
$booksJson = file_get_contents('books.json');
$books = json_decode($booksJson, true);
if (isset($_GET['title'])) {
 echo '<p>Looking for <b>' . $_GET['title'] . '</b></p>';
} else {
 echo '<p>You are not looking for a book?</p>';
}
?>
    <ul>
<?php foreach ($books as $book): ?>
 <li>
 <a href="?title=<?php echo $book['title']; ?>">
 <?php echo printableTitle($book); ?>
 </a>
 </li>
<?php endforeach; ?>
    </ul>
</body>
</html>

如果你尝试在浏览器中运行前面的代码,你会看到列表中包含链接,点击它们后,页面会刷新,查询字符串中包含新的标题。现在让我们检查书籍是否可用,如果可用,我们将更新其可用字段为false。在functions.php中添加以下函数:

function bookingBook(array &$books, string $title): bool {
    foreach ($books as $key => $book) {
        if ($book['title'] == $title) {
            if ($book['available']) {
                $books[$key]['available'] = false;
                return true;
            } else {
                return false;
            }
        }
    }
    return false;
}

我们必须注意,当代码开始变得复杂时。这个函数接受一个书籍数组和标题,并返回一个布尔值,如果可以预订则为true,否则为false。此外,书籍数组是通过引用传递的,这意味着对该数组所做的任何更改都会影响原始数组。尽管我们之前曾劝阻这样做,但在这种情况下,这是一个合理的做法。

我们遍历整个书籍数组,每次询问当前书籍的标题是否与我们要找的标题匹配。只有当它是true时,我们才会检查书籍是否可用。如果可用,我们将更新可用性为false并返回true,表示我们已预订了书籍。如果书籍不可用,我们只返回false

最后,请注意,foreach定义了$key$book。我们这样做是因为$book变量是$books数组的副本,如果我们编辑它,原始的将不会受到影响。相反,我们要求该书籍的键也一起提供,因此当编辑数组时,我们使用$books[$key]而不是$book

我们可以从index.php文件中使用此函数:

//...
    echo '<p>Looking for <b>' . $_GET['title'] . '</b></p>';
 if (bookingBook($books, $_GET['title'])) {
 echo 'Booked!';
 } else {
 echo 'The book is not available...';
 }
} else {
//...

在浏览器中试一试。通过点击可用的书籍,你会看到已预订的消息。我们几乎完成了!我们只是缺少最后一步:将此信息持久化回文件系统。为了做到这一点,我们必须构建新的 JSON 内容,并将其写回books.json文件。当然,只有当书籍可用时才这样做。

function updateBooks(array $books) {
    $booksJson = json_encode($books);
    file_put_contents(__DIR__ . '/books.json', $booksJson);
}

json_encode函数与json_decode相反:它接受一个数组或任何其他变量,并将其转换为 JSON。file_put_contents函数用于将内容写入作为第一个参数引用的文件,第二个参数是发送的内容。你知道如何使用这个函数吗?

//...
if (bookingBook($books, $_GET['title'])) {
    echo 'Booked!';
 updateBooks($books);
} else {
    echo 'The book is not available...';
}
//...

注意

文件与数据库

将信息存储在 JSON 文件中比将其直接放在代码中要好,但这还不是最佳选择。在第五章中,使用数据库,你将学习如何将应用的数据存储在数据库中,这是一个更好的解决方案。

其他文件系统函数

如果你想要使你的应用更加健壮,你可以检查books.json文件是否存在,你是否有读写权限,以及之前的内容是否是有效的 JSON。你可以使用一些 PHP 函数来完成这个任务:

  • file_exists:此函数接受文件的路径,并返回一个布尔值:当文件存在时返回true,否则返回false

  • is_writable:此函数与file_exists的工作方式相同,但它会检查文件是否可写。

你可以在uk1.php.net/manual/en/book.filesystem.php找到完整的函数列表。你可以找到用于移动、复制或删除文件、创建目录、设置权限和所有权等功能的函数。

摘要

在本章中,我们通过编写简单的示例来实践,学习了过程式 PHP 的所有基础知识。你现在知道如何使用变量和数组与控制结构和函数一起使用,如何从 HTTP 请求中获取信息,以及如何与其他事物交互,例如与文件系统交互。

在下一章,我们将学习其他最常用的范式:面向对象编程(OOP)。这使我们的应用编写更加整洁和结构良好又近了一步。

第四章:使用 OOP 创建整洁的代码

当应用程序开始增长,表示更复杂的数据结构变得必要。当你想要将特定行为与数据关联时,原始类型如整数、字符串或数组就不够了。半个多世纪以前,计算机科学家开始使用对象的概念来指代在现实生活中表示属性和功能封装的概念。

现在,面向对象编程(OOP)是使用最广泛的编程范式之一,你可能会很高兴地知道 PHP 支持它。了解 OOP 不仅仅是了解语言的语法,而是了解何时以及如何使用它。但不要担心,在本章和一些实践之后,你将成为一个自信的 OOP 开发者。

在本章中,你将学习以下内容:

  • 类和对象

  • 可见性、静态属性和方法

  • 命名空间

  • 自动加载类

  • 继承、接口和特性

  • 处理异常

  • 设计模式

  • 匿名函数

类和对象

对象是现实生活元素的表示。每个对象都有一些属性,这些属性将其与其他相同类的其他对象区分开来,并且能够执行一系列操作。是定义对象外观和功能的定义,就像对象的模式一样。

让我们以我们的书店为例,并思考它包含的真实的对象类型。我们存储书籍,并允许人们在它们可用时取走它们。我们可以考虑两种类型的对象:书籍和客户。我们可以定义这两个类如下:

<?php

class Book {
}

class Customer {
}

类是通过关键字class定义的,后跟一个有效的类名——它遵循任何其他 PHP 标签(如变量名)的相同规则,以及一段代码块。但如果我们想要一个特定的书籍,即对象Book——或者Book类的实例——我们必须实例化它。要实例化一个对象,我们使用关键字new后跟类名。我们将实例分配给一个变量,就像它是一个原始类型一样:

$book = new Book();
$customer = new Customer();

你可以创建你需要的任何数量的实例,只要将它们分配给不同的变量:

$book1 = new Book();
$book2 = new Book();

类属性

首先,让我们思考一下书籍的特性:它们有一个标题、一个作者和一个 ISBN。它们也可以是可用或不可用的。请在Book.php文件内编写以下代码:

<?php

class Book {
 public $isbn;
 public $title;
 public $author;
 public $available;
}
public; we will explain what it means when talking about visibility in the next section. For now, just think of properties as variables inside the class. We can use these variables in objects. Try adding this code at the end of the Book.php file:
$book = new Book();
$book->title = "1984";
$book->author = "George Orwell";
$book->available = true;
var_dump($book);

打印对象会显示其每个属性的值,方式类似于数组打印其键的方式。你可以看到属性在打印时有一个类型,但我们没有明确定义这个类型;相反,变量取了分配的值的类型。这正好与正常变量一样工作。

当创建多个对象实例并为其属性赋值时,每个对象将有自己的值,因此你不会覆盖它们。下面的代码片段展示了这是如何工作的:

$book1 = new Book();
$book1->title = "1984";
$book2 = new Book();
$book2->title = "To Kill a Mockingbird";
var_dump($book1, $book2);

类方法

方法是在类内部定义的函数。像函数一样,方法接收一些参数并执行一些操作,可选地返回一个值。方法的优势在于它们可以使用调用它们的对象的属性。因此,在两个不同的对象中调用相同的方法可能会有两个不同的结果。

尽管通常将 HTML 与 PHP 混合使用不是一个好主意,但为了学习的目的,让我们在我们的Book类中添加一个方法,该方法返回书籍,就像我们已存在的printableTitle函数一样:

<?php

class Book {
    public $isbn;
    public $title;
    public $author;
    public $available;

 public function getPrintableTitle(): string {
 $result = '<i>' . $this->title
 . '</i> - ' . $this->author;
 if (!$this->available) {
 $result .= ' <b>Not available</b>';
 }
 return $result;
 }
}

与属性一样,我们在函数的开始处添加关键字public,但除此之外,其余部分看起来就像一个普通函数。另一个特殊之处在于使用$this:它代表对象本身,允许你访问该对象的属性和方法。注意我们是如何引用标题、作者和可用的属性的。

您还可以通过其函数之一更新当前对象的价值。让我们使用可用的属性作为一个整数,显示可用的单位数量,而不是仅仅是一个布尔值。有了这个,我们可以允许多个客户借阅同一本书的不同副本。让我们添加一个方法,给客户一本书的副本,更新可用的单位数量:

public function getCopy(): bool {
    if ($this->available < 1) {
        return false;
    } else {
        $this->available--;
        return true;
    }
}

在这个先前的方法中,我们首先检查我们是否至少有一个可用的单位。如果没有,我们返回false以让他们知道操作未成功。如果我们有客户的一个单位,我们减少可用的单位数量,然后返回true,让他们知道操作成功。让我们看看如何使用这个类:

<?php
$book = new Book();
$book->title = "1984";
$book->author = "George Orwell";
$book->isbn = 9785267006323;
$book->available = 12;

if ($book->getCopy()) {
    echo 'Here, your copy.';
} else {
    echo 'I am afraid that book is not available.';
}

这段代码会打印什么?正是,这里,你的副本。但是属性available的值会是什么?它将是 11,这是getCopy调用的结果。

类构造函数

你可能已经注意到,实例化Book类并设置所有值似乎很麻烦。如果我们类的属性有 30 个而不是 4 个呢?好吧,希望你永远不会这样做,因为这非常不好。尽管如此,有一种方法可以减轻这种痛苦:构造函数

构造函数是在创建类的新实例时调用的函数。它们看起来像普通方法,但它们的名称总是__construct,并且它们没有return语句,因为它们总是必须返回新实例。让我们看看一个例子:

public function __construct(int $isbn, string $title, string $author, int $available) {
    $this->isbn = $isbn;
    $this->title = $title;
    $this->author = $author;
    $this->available = $available;
}

构造函数接收四个参数,然后将其中一个参数的值分配给实例的每个属性。要实例化Book类,我们使用以下方法:

$book = new Book("1984", "George Orwell", 9785267006323, 12);

这个对象与我们手动设置每个属性值的对象完全相同。但这个看起来更干净,对吧?这并不意味着你不能手动设置这个对象的新值,它只是帮助你构建新的对象。

由于构造函数仍然是函数,因此它可以使用默认参数。想象一下,在创建对象时,单位数量通常为 0,然后图书管理员会在有可用时添加单位。我们可以将默认值设置为构造函数的$available参数,这样如果我们创建对象时不发送单位数量,对象将以默认值实例化:

public function __construct(
    int $isbn,
    string $title,
    string $author,
    int $available = 0
) {
    $this->isbn = $isbn;
    $this->title = $title;
    $this->author = $author;
    $this->available = $available;
}

我们可以使用前面的构造函数以两种不同的方式:

$book1 = new Book("1984", "George Orwell", 9785267006323, 12);
$book2 = new Book("1984", "George Orwell", 9785267006323);

$book1将设置可用的单位数量为12,而$book2将设置为默认值 0。但不要相信我;自己试试看!

魔法方法

有一个特殊的方法组,它们的行为与普通方法不同。这些方法被称为魔法方法,它们通常是由类或对象的交互触发的,而不是由调用触发的。您已经看到了其中之一,即类的构造函数__construct。此方法不是直接调用的,而是在使用new创建新实例时使用的。您可以通过它们以__开头轻松识别魔法方法。以下是一些最常用的魔法方法:

  • __toString: 当我们尝试将对象转换为字符串时,将调用此方法。它不接受任何参数,并期望返回一个字符串。

  • __call: 这是当您尝试在不存在的方法的类上调用方法时,PHP 调用的方法。它通过参数获取方法名称作为字符串和调用中使用的参数列表作为数组。

  • __get: 这是__call属性版本。它通过参数获取用户试图通过属性访问的名称,并且可以返回任何内容。

您可以使用__toString方法替换我们Book类中的当前getPrintableTitle方法。为此,只需按以下方式更改方法名称:

public function __toString() {
    $result = '<i>' . $this->title . '</i> - ' . $this->author;
    if (!$this->available) {
        $result .= ' <b>Not available</b>';
    }
    return $result;
}
book and then casts it to a string, invoking the __toString method:
$book = new Book(1234, 'title', 'author');
$string = (string) $book; // title - author Not available

如其名所示,这些是魔法方法,所以它们的功能大多数时候看起来像魔法。出于明显的原因,我们个人鼓励开发者使用构造函数以及可能__toString,但请注意何时使用其余部分,因为您可能会使您的代码对不熟悉它的人来说非常不可预测。

属性和方法可见性

到目前为止,我们在Book类中定义的所有属性和方法都被标记为public。这意味着它们对任何人或更确切地说,从任何地方都是可访问的。这被称为属性或方法的可见性,并且有三种类型的可见性。从更严格到更宽松的顺序如下:

  • private: 此类型允许只有同一类的成员可以访问。如果 A 和 B 是类 C 的实例,A 可以访问 B 的属性和方法。

  • protected: 此类型允许同一类的成员和从该类继承的类的实例访问。您将在下一节中看到继承。

  • public:此类指代一个可以从任何地方访问的属性或方法。任何来自类外部的类或一般代码都可以访问它。

为了展示一些示例,让我们首先在我们的应用程序中创建第二个类。将其保存到Customer.php文件中:

<?php

class Customer {
    private $id;
    private $firstname;
    private $surname;
    private $email;

    public function __construct(
        int $id,
        string $firstname,
        string $surname,
        string $email
    ) {
        $this->id = $id;
        $this->firstname = $firstname;
        $this->surname = $surname;
        $this->email = $email;
    }
}

这个类代表一个客户,其属性包括书店通常了解的客户的一般信息。但出于安全原因,我们不能让每个人都了解我们客户的个人信息,所以我们把每个属性都设置为private

到目前为止,我们一直在同一个Book.php文件中添加创建对象的代码,但既然现在有两个类,似乎很自然地让这些类留在各自的文件中,并在一个单独的文件中创建和操作对象。让我们把这个第三个文件命名为init.php。为了实例化给定类的对象,PHP 需要知道类在哪里。为此,只需使用require_once包含该文件即可。

<?php

require_once __DIR__ . '/Book.php';
require_once __DIR__ . '/Customer.php';

$book1 = new Book("1984", "George Orwell", 9785267006323, 12);
$book2 = new Book("To Kill a Mockingbird", "Harper Lee", 9780061120084, 2);

$customer1 = new Customer(1, 'John', 'Doe', 'johndoe@mail.com');
$customer2 = new Customer(2, 'Mary', 'Poppins', 'mp@mail.com');

你不需要每次都包含文件。一旦你包含了它们,PHP 就会知道在哪里找到类,即使你的代码在不同的文件中。

注意

类的约定

当与类一起工作时,你应该知道有一些约定,每个人都试图遵循以确保代码整洁且易于维护。其中最重要的如下:

  • 每个类都应该在一个与类名相同的文件中,并带有.php扩展名

  • 类名应该使用驼峰式命名法,即每个单词的首字母大写,其余部分小写

  • 一个文件应该只包含一个类的代码

  • 在类内部,你应该首先放置属性,然后是构造函数,最后是其他方法

为了展示可见性是如何工作的,让我们尝试以下代码:

$book1->available = 2; // OK
$customer1->id = 3; // Error!

我们已经知道Book类对象的属性是公开的,因此可以从外部编辑。但当我们尝试从Customer中更改值时,PHP 会抱怨,因为它的属性是私有的。

封装

当与对象一起工作时,你必须知道并应用的一个重要概念是封装。封装试图将对象的数据与其方法组合在一起,以尝试隐藏对象的内部结构。简单来说,如果你说一个对象的属性是私有的,并且唯一更新它们的方式是通过公共方法,那么你就是在使用封装。

使用封装的原因是为了让开发者更容易修改类的内部结构,而不会直接影响到使用该类的外部代码。例如,想象一下我们的Customer类,现在有两个属性来定义其姓名——firstnamesurname——需要更改。从现在起,我们只有一个包含两者的属性名。如果我们直接访问其属性,我们应该更改所有这些访问!

相反,如果我们把属性设置为私有,并启用两个公共方法,getFirstnamegetSurname,即使我们必须更改类的内部结构,我们也可以只更改这两个方法的实现——这只有一个地方——而使用我们类的其余代码将不会受到影响。这个概念也被称为信息隐藏

实现这个想法的最简单方法是将类的所有属性都设置为私有,并为每个属性启用两个方法:一个将获取当前值(也称为获取器),另一个将允许你设置新值(称为设置器)。这至少是最常见和简单的方法来封装数据。

但让我们更进一步:在定义一个类时,考虑你想让用户能够更改和检索的数据,并且只为它们添加设置器和获取器。例如,客户可能会更改他们的电子邮件地址,但一旦创建,他们的名字、姓氏和 ID 就保持不变。类的新的定义如下所示:

<?php

class Customer {
    private $id;
    private $name;
    private $surname;
    private $email;

    public function __construct(
        int $id,
        string $firstname,
        string $surname,
        string $email
    ) {
        $this->id = $id;
        $this->firstname = $firstname;
        $this->surname = $surname;
        $this->email = $email;
    }

 public function getId(): id {
 return $this->id;
 }
 public function getFirstname(): string {
 return $this->firstname;
 }
 public function getSurname(): string {
 return $this->surname;
 }
 public function getEmail(): string {
 return $this->email;
 }
 public function setEmail(string $email) {
 $this->email = $email;
 }
}

另一方面,我们的书籍几乎保持不变。唯一可能的变化是可用的单元数量。但我们通常一次拿或添加一本书,而不是设置具体的单元数量,因此这里的设置器并不是很有用。我们已经有了一个getCopy方法,当可能时取一个副本;让我们添加一个addCopy方法,以及其余的获取器:

<?php

class Book {
    private $isbn;
    private $title;
    private $author;
    private $available;

    public function __construct(
        int $isbn,
        string $title,
        string $author,
        int $available = 0
    ) {
        $this->isbn = $isbn;
        $this->title = $title;
        $this->author = $author;
        $this->available = $available;
    }
 public function getIsbn(): int {
 return $this->isbn;
 }
 public function getTitle(): string {
 return $this->title;
 }
 public function getAuthor(): string {
 return $this->author;
 }
 public function isAvailable(): bool {
 return $this->available;
 }

    public function getPrintableTitle(): string {
        $result = '<i>' . $this->title . '</i> - ' . $this->author;
        if (!$this->available) {
            $result .= ' <b>Not available</b>';
        }
        return $result;
    }

    public function getCopy(): bool {
        if ($this->available < 1) {
            return false;
        } else {
            $this->available--;
            return true;
        }
    }

 public function addCopy() {
 $this->available++;
 }
}

当你的应用程序中的类数量以及随之而来的类之间的关系数量增加时,将这些类以图表的形式表示出来是有帮助的。我们可以称这个图表为类的 UML 图,或者简单地称为层次树。我们两个类的层次树看起来如下所示:

封装

我们只显示公共方法,因为受保护的或私有的方法不能从类外部调用,因此对于只想外部使用这些类的开发者来说,它们并不有用。

静态属性和方法

到目前为止,所有的属性和方法都与特定的实例相关联;因此,两个不同的实例可以为相同的属性有不同的值。PHP 允许你将属性和方法与类本身相关联,而不是与对象相关联。这些属性和方法使用关键字static定义。

private static $lastId = 0;

将前面的属性添加到Customer类中。这个属性显示了分配给用户的最后一个 ID,这对于知道应该分配给新用户的 ID 是有用的。让我们将我们类的构造函数修改如下:

public function __construct(
    int $id,
    string $name,
    string $surname,
    string $email
) {
 if ($id == null) {
 $this->id = ++self::$lastId;
 } else {
 $this->id = $id;
 if ($id > self::$lastId) {
 self::$lastId = $id;
 }
 }
    $this->name = $name;
    $this->surname = $surname;
    $this->email = $email;
}

注意,当引用静态属性时,我们不使用变量 $this。相反,我们使用 self::,它不是绑定到任何实例,而是绑定到类本身。在这个最后的构造函数中,我们有两种选择。我们要么提供一个非空的 ID 值,要么用 null 代替。当接收到的 ID 为 null 时,我们使用静态属性 $lastId 来知道最后一个使用的 ID,将其增加 1,并将其分配给属性 $id。如果我们插入的最后一个 ID 是 5,这将更新静态属性为 6,然后将其分配给实例属性。下次我们创建一个新的客户时,$lastId 静态属性将是 6。相反,如果我们作为参数的一部分得到一个有效的 ID,我们将其分配,并检查分配的 $id 是否大于静态的 $lastId。如果是,我们更新它。让我们看看我们如何使用它:

$customer1 = new Customer(3, 'John', 'Doe', 'johndoe@mail.com');
$customer2 = new Customer(null, 'Mary', 'Poppins', 'mp@mail.com');
$customer3 = new Customer(7, 'James', 'Bond', '007@mail.com');

在前面的例子中,$customer1 指定他的 ID 是 3,可能是因为他是一位现有客户,并希望保持相同的 ID。这设置了他的 ID 和最后一个静态 ID 都是 3。当创建第二个客户时,我们没有指定 ID,因此构造函数将取最后一个 ID,增加 1,并将其分配给客户。所以 $customer2 将有 ID 4,最新的 ID 也将是 4。最后,我们的秘密特工知道他想要什么,所以他强迫系统将 ID 设置为 7。最新的 ID 也将更新为 7。

静态属性和方法的好处之一是,我们不需要对象就可以使用它们。你可以通过指定类的名称,然后是 ::,以及属性/方法的名称来引用静态属性或方法。当然,如果可见性规则允许你这样做,这在当前情况下是不允许的,因为属性是私有的。让我们添加一个公共静态方法来检索最后一个 ID:

public static function getLastId(): int {
    return self::$lastId;
}

你可以从代码的任何地方使用类名或现有的实例来引用它:

Customer::getLastId();
$customer1::getLastId();

命名空间

你知道你不能有两个具有相同名称的类,因为 PHP 在创建新对象时不知道应该引用哪一个。为了解决这个问题,PHP 允许使用 命名空间,它们在文件系统中充当路径。这样,你可以拥有你需要的任何数量的具有相同名称的类,只要它们都定义在不同的命名空间中。值得注意的是,尽管命名空间和文件路径通常会是相同的,但这是由开发者强制执行的,而不是由语言强制执行的;你实际上可以使用与文件系统无关的任何命名空间。

指定命名空间必须在文件中做的第一件事。为了做到这一点,使用 namespace 关键字后跟命名空间。命名空间的每个部分都由 \ 分隔,就像它是不同的目录一样。如果你没有指定命名空间,则类将属于基础命名空间,或根。在两个文件的开头——Book.phpCustomer.php——添加以下内容:

<?php

namespace Bookstore\Domain;

上一行代码将我们类的命名空间设置为 Bookstore\Domain。我们类的完整名称因此是 Bookstore\Domain\BookBookstore\Domain\Customer。如果你尝试从浏览器访问 init.php 文件,你会看到一个错误,说找不到 BookCustomer 类。但我们不是已经包含了这些文件吗?这是因为 PHP 认为你正在尝试从根目录访问 \Book\Customer。不要担心,有几种方法可以修正这个问题。

一种方法是在引用类时指定类的完整名称,即使用 $customer = new Bookstore\Domain\Book(); 而不是 $book = new Book();。但这听起来并不实用,对吧?

另一种方法是将 init.php 文件归属到 BookStore\Domain 命名空间。这意味着 init.php 内部所有类的引用都将带有 BookStore\Domain 前缀,你将能够使用 BookCustomer。这种解决方案的缺点是,你不能轻易地引用其他命名空间中的类,因为任何类的引用都将带有该命名空间的前缀。

最佳解决方案是使用关键字 use。这个关键字允许你在文件的开头指定一个完整的类名,然后在文件的其余部分使用类的简单名称。让我们看看一个例子:

<?php

use Bookstore\Domain\Book;
use Bookstore\Domain\Customer;

require_once __DIR__ . '/Book.php';
require_once __DIR__ . '/Customer.php';
//...

在前面的文件中,每次我们引用 BookCustomer 时,PHP 会知道我们实际上想要使用完整的类名,即在其前面加上 Bookstore\Domain\ 前缀。这种解决方案允许你在引用这些类时拥有干净的代码,同时,如果需要的话,也能引用其他命名空间中的类。

但如果你想在同一个文件中包含两个具有相同名称的不同类呢?如果你设置了两个 use 语句,PHP 将不知道选择哪一个,所以我们仍然面临之前的问题!为了解决这个问题,你可以每次引用任何类时都使用完整的类名——带有命名空间——或者你可以使用别名。

假设我们有两个 Book 类,第一个在 Bookstore\Domain 命名空间中,第二个在 Library\Domain 命名空间中。为了解决冲突,你可以这样做:

use Bookstore\Domain\Book;
use Library\Domain\Book as LibraryBook;

关键字 as 为该类设置了一个别名。在那个文件中,每次你引用 LibraryBook 类时,实际上你引用的是 Library\Domain\Book 类。而当你引用 Book 时,PHP 将只使用来自 Bookstore 的那个。问题解决了!

类的自动加载

如你所知,为了使用一个类,你需要包含定义它的文件。到目前为止,我们一直是手动包含这些文件的,因为我们只有几个类,并且在一个文件中使用它们。但当我们使用多个文件中的多个类时会发生什么呢?肯定有更聪明的方法,对吧?确实有。自动加载来拯救我们!

自动加载是 PHP 的一个特性,它允许你的程序根据一组预定义的规则自动搜索和加载文件。每次当你引用 PHP 不认识的类时,它都会询问 自动加载器。如果自动加载器能够确定该类所在的文件,它将加载该文件,并且程序的执行将继续正常进行。如果它不能,PHP 将停止执行。

那么,自动加载器是什么?它不过是一个接收类名作为参数的 PHP 函数,并且它被期望加载一个文件。实现自动加载有两种方式:要么使用 __autoload 函数,要么使用 spl_autoload_register

使用 __autoload 函数

定义一个名为 __autoload 的函数告诉 PHP 该函数是它必须使用的自动加载器。你可以实现一个简单的解决方案:

function __autoload($classname) {
    $lastSlash = strpos($classname, '\\') + 1;
    $classname = substr($classname, $lastSlash);
    $directory = str_replace('\\', '/', $classname);
    $filename = __DIR__ . '/' . $directory . '.php';
    require_once($filename);
}

我们的意图是将所有 PHP 文件都放在 src 目录中,即源目录。在这个目录内部,目录树将模拟类的命名空间树,除了第一个部分 BookStore,它作为一个命名空间是有用的,但作为一个目录则不是必要的。这意味着我们的 Book 类,全称 BookStore\Domain\Book,将位于 src/Domain/Book.php

为了实现这一点,我们的 __autoload 函数尝试使用 strpos 找到反斜杠 \ 的第一个出现位置,然后使用 substr 从该位置提取到文件末尾。实际上,这仅仅移除了命名空间的第一部分,BookStore。之后,我们将所有的 \ 替换为 /,这样文件系统就能理解路径了。最后,我们将当前目录、类名作为目录以及 .php 扩展名连接起来。

在尝试之前,请记住创建 src/Domain 目录并将两个类移动到其中。同时,为了确保我们在测试自动加载器,请将以下内容保存为你的 init.php 文件,并访问 http://localhost:8000/init.php

<?php

use Bookstore\Domain\Book;
use Bookstore\Domain\Customer;

function __autoload($classname) {
    $lastSlash = strpos($classname, '\\') + 1;
    $classname = substr($classname, $lastSlash);
    $directory = str_replace('\\', '/', $classname);
    $filename = __DIR__ . '/src/' . $directory . '.php'
    require_once($filename);
}

$book1 = new Book("1984", "George Orwell", 9785267006323, 12);
$customer1 = new Customer(5, 'John', 'Doe', 'johndoe@mail.com');

浏览器现在不再抱怨,也没有显式的 require_once。同时记住,__autoload 函数只需要定义一次,而不是在每个文件中。所以从现在开始,当你想要使用你的类时,只要类位于一个遵循约定的命名空间和文件中,你只需要定义 use 语句。比以前干净多了,对吧?

使用 spl_autoload_register 函数

__autoload 解决方案看起来相当不错,但有一个小问题:如果我们的代码非常复杂,我们不仅仅只有一个约定,我们需要多个 __autoload 函数的实现?因为我们不能定义两个同名函数,我们需要一种方法告诉 PHP 保留自动加载器的可能实现列表,这样它就可以尝试所有这些实现,直到找到一个有效的。

这就是 spl_autoload_register 的作用。你定义你的自动加载函数并使用一个有效的名称,然后调用 spl_autoload_register 函数,将你的自动加载函数名称作为参数传递。你可以根据你代码中的不同自动加载器多次调用这个函数。实际上,即使你只有一个自动加载器,使用这个系统也比使用 __autoload 更好,因为它使其他人将来添加新自动加载器更容易:

function autoloader($classname) {
    $lastSlash = strpos($classname, '\\') + 1;
    $classname = substr($classname, $lastSlash);
    $directory = str_replace('\\', '/', $classname);
    $filename = __DIR__ . '/' . $directory . '.php';
    require_once($filename);
}
spl_autoload_register('autoloader');

继承

我们将面向对象范式视为复杂数据结构的万能药,尽管我们已经展示了我们可以定义具有属性和方法的对象,看起来很漂亮,也很高级,但这并不是我们不能用数组解决的问题。封装是使对象比数组更有用的一项特性,但它们的真正力量在于继承。

介绍继承

面向对象编程中的继承是指将类的实现从父类传递给子类的功能。是的,类可以有父类,而技术上将这一特性称为一个类 继承 自另一个类。当我们扩展一个类时,我们会获得所有未定义为私有的属性和方法,子类可以像使用自己的属性和方法一样使用它们。限制是,一个类只能从一个父类继承。

为了举例说明,让我们考虑我们的 Customer 类。它包含属性 firstnamesurnameemailid。客户实际上是一种特定类型的人,他/她在我们的系统中注册,因此可以借阅书籍。但我们的系统中可能有其他类型的人,如图书管理员或访客。他们都会有一些对所有人的共同属性,即 firstnamesurname。因此,如果我们创建一个 Person 类,并使 Customer 类从它继承,那就很有意义了。层次结构树将如下所示:

介绍继承

注意 Customer 如何与 Person 相连。Person 中的方法在 Customer 中没有定义,因为它们是从扩展中隐含的。现在按照我们的约定,将新类保存在 src/Domain/Person.php 中:

<?php

namespace Bookstore\Domain;

class Person {
    protected $firstname;
    protected $surname;

    public function __construct(string $firstname, string $surname) {
        $this->firstname = $firstname;
        $this->surname = $surname;
    }

    public function getFirstname(): string {
        return $this->firstname;
    }

    public function getSurname(): string {
        return $this->surname;
    }
}
Customer class by removing the duplicate properties and its getters:
<?php

namespace Bookstore\Domain;

class Customer extends Person {
    private static $lastId = 0;
    private $id;
    private $email;

    public function __construct(
        int $id,
        string $name,
        string $surname,
        string $email
    ) {
        if (empty($id)) {
            $this->id = ++self::$lastId;
        } else {
            $this->id = $id;
            if ($id > self::$lastId) {
                self::$lastId = $id;
            }
        }
        $this->name = $name;
        $this->surname = $surname;
        $this->email = $email;
    }

    public static function getLastId(): int {
        return self::$lastId;
    }

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

    public function getEmail(): string {
        return $this->email;
    }

    public function setEmail($email): string {
        $this->email = $email;
    }
}

注意新关键字 extends;它告诉 PHP 这个类是 Person 类的子类。由于 PersonCustomer 都在同一个命名空间中,你不需要添加任何 use 语句,但如果它们不在同一个命名空间中,你应该让它知道如何找到父类。这段代码运行正常,但我们可以看出,这里有一些代码重复。Customer 类的构造函数正在做与 Person 类构造函数相同的工作!我们将会尽快修复这个问题。

为了从子类中引用父类的方法或属性,你可以使用$this,就像属性或方法在同一个类中一样。实际上,你可以说它实际上就是。但 PHP 允许你在子类中重新定义已经在父类中存在的方法。如果你想引用父类的实现,你不能使用$this,因为 PHP 将调用子类中的那个。要强制 PHP 使用父类的方法,请使用关键字parent::而不是$this。按照以下方式更新Customer类的构造函数:

public function __construct(
    int $id,
    string $firstname,
    string $surname,
    string $email
) {
 parent::__construct($firstname, $surname);
    if (empty($id)) {
        $this->id = ++self::$lastId;
    } else {
        $this->id = $id;
        if ($id > self::$lastId) {
            self::$lastId = $id;
        }
    }
    $this->email = $email;
}

这个新的构造函数并没有复制代码。相反,它调用父类Person的构造函数,传递$firstname$surname,并让父类做它已经知道如何做的事情。我们避免了代码重复,并且在此基础上,我们还使得对Person构造函数的任何未来更改都变得更加容易。如果我们需要更改Person构造函数的实现,我们只需在一个地方更改,而不是在所有子类中更改。

覆盖方法

如前所述,当我们从一个类扩展时,我们得到父类的所有方法。这是隐式的,所以它们实际上并没有在子类的类中写下来。如果你实现了一个具有相同签名和/或名称的另一个方法会发生什么?你将覆盖方法

由于我们不需要这个功能在我们的类中,我们只需在我们的init.php文件中添加一些代码来展示这种行为,然后你可以简单地删除它。让我们定义一个类Pops,一个从父类扩展的类Child,以及它们两个中的sayHi方法:

class Pops {
    public function sayHi() {
        echo "Hi, I am pops.";
    }
}

class Child extends Pops{
 public function sayHi() {
 echo "Hi, I am a child.";
 }
}

$pops = new Pops();
$child = new Child();
echo $pops->sayHi(); // Hi, I am pops.
echo $child->sayHi(); // Hi, I am Child.

突出的代码显示该方法已被覆盖,因此当我们从子类的角度调用它时,我们将使用它而不是从其父类继承来的那个。但如果我们还想引用继承来的那个呢?你可以始终使用关键字parent来引用它。让我们看看它是如何工作的:

class Child extends Pops{
    public function sayHi() {
        echo "Hi, I am a child.";
 parent::sayHi();
    }
}

$child = new Child();
echo $child->sayHi(); // Hi, I am Child. Hi I am pops.

现在子类在对自己和他父亲都说hi。这似乎非常简单和方便,对吧?但是有一个限制。想象一下,就像现实生活中,这个孩子非常害羞,他不会对每个人都打招呼。我们可以尝试将方法的可见性设置为受保护的,但看看会发生什么:

class Child extends Pops{
 protected function sayHi() {
        echo "Hi, I am a child.";
    }
}

尝试这段代码时,即使没有尝试实例化它,你也会得到一个关于该方法访问级别的致命错误。原因是当覆盖时,方法必须至少与继承的方法有相同的可见性。这意味着如果我们继承了一个受保护的,我们可以用另一个受保护的或公共的来覆盖它,但不能用私有的来覆盖。

抽象类

记住,每次只能从一个父类扩展。这意味着Customer只能从Person扩展。但如果我们想使这个层次树更复杂,我们可以创建从Customer扩展的子类,这些类也将隐式地从Person扩展。让我们创建两种类型的客户:基本和高级。这两种客户将具有来自CustomerPerson的相同属性和方法,以及我们在每个中实现的新方法。

将以下代码保存为src/Domain/Customer/Basic.php

<?php

namespace Bookstore\Domain\Customer;

use Bookstore\Domain\Customer;

class Basic extends Customer {
    public function getMonthlyFee(): float {
        return 5.0;
    }

    public function getAmountToBorrow(): int {
        return 3;
    }

    public function getType(): string {
        return 'Basic';
    }
}

以下代码作为src/Domain/Customer/Premium.php

<?php

namespace Bookstore\Domain\Customer;

use Bookstore\Domain\Customer;

class Premium extends Customer {
    public function getMonthlyFee(): float {
        return 10.0;
    }

    public function getAmountToBorrow(): int {
        return 10;
    }

    public function getType(): string {
        return 'Premium';
    }
}

在前两个代码中需要注意的事项是,我们从两个不同的类中扩展了Customer,这是完全合法的——我们可以从不同命名空间中的类扩展。有了这个添加,Person的层次树将如下所示:

抽象类

我们在这两个类中定义了相同的方法,但它们的实现是不同的。这种方法的目的是在不了解每次是哪一个的情况下,无差别地使用这两种类型的客户。例如,我们可以在init.php中暂时有以下代码。记住,如果你没有导入Customer类,请添加use语句。

function checkIfValid(Customer $customer, array $books): bool {
    return $customer->getAmountToBorrow() >= count($books);
}

前面的函数会告诉我们一个给定的客户是否可以借阅数组中的所有书籍。注意,该方法类型提示为Customer,但没有指定具体是哪一个。这将接受任何是Customer实例或从Customer扩展的类的对象,即BasicPremium。看起来很合理,对吧?那么让我们试试看:

$customer1 = new Basic(5, 'John', 'Doe', 'johndoe@mail.com');
var_dump(checkIfValid($customer1, [$book1])); // ok
$customer2 = new Customer(7, 'James', 'Bond', 'james@bond.com');
var_dump(checkIfValid($customer2, [$book1])); // fails

第一次调用按预期工作,但第二次调用失败,即使我们发送了一个Customer对象。问题在于父类不知道任何getAmountToBorrow方法!而且,我们依赖于子类始终实现该方法看起来也很危险。解决方案在于使用抽象类。

抽象类是一个不能实例化的类。它的唯一目的是确保其子类被正确实现。将类声明为抽象是通过使用关键字abstract,然后是正常类的定义来完成的。我们还可以指定子类必须实现的方法,而不在父类中实现它们。这些方法被称为抽象方法,并且使用关键字abstract在开头定义。当然,其余的正常方法也可以保留在那里,并将由其子类继承:

<?php
abstract class Customer extends Person {
//...
 abstract public function getMonthlyFee();
 abstract public function getAmountToBorrow();
 abstract public function getType();
//...
}

上述抽象解决了两个问题。首先,我们将无法发送 Customer 类的任何实例,因为我们不能实例化它。这意味着 checkIfValid 方法将要接受的所有对象都只能是 Customer 的子类。另一方面,声明抽象方法迫使所有扩展该类的子类实现它们。通过这种方式,我们确保所有对象都将实现 getAmountToBorrow,并且我们的代码是安全的。

新的层次结构树将在 Customer 中定义三个抽象方法,并为其子类省略它们。虽然我们在子类中实现了它们,但它们由 Customer 强制执行,并且由于抽象的存在,我们确信所有从它扩展的类都必须实现它们,这样做是安全的。让我们看看这是如何完成的:

抽象类

在最后的新添加中,你的 init.php 文件应该会失败。原因是它试图实例化 Customer 类,但现在它是抽象的,所以你不能。实例化一个具体的类,即一个非抽象的类,来解决这个问题。

接口

接口 是一个 OOP 元素,它将一组函数声明分组,但不实现它们,即它指定了名称、返回类型和参数,但没有代码块。接口与抽象类不同,因为它们根本不能包含任何实现,而抽象类可以混合方法定义和实现。接口的目的是声明一个类可以做什么,但不是如何做。

从我们的代码中,我们可以识别出接口的潜在用途。客户有一个预期的行为,但它的实现取决于客户类型。因此,Customer 可以是一个接口而不是一个抽象类。但是,由于接口不能实现任何函数,也不能包含属性,我们必须将具体的代码从 Customer 类移动到其他地方。目前,让我们将其移动到 Person 类。按照以下方式编辑 Person 类:

<?php

namespace Bookstore\Domain;

class Person {

 private static $lastId = 0;
 protected $id;
    protected $firstname;
    protected $surname;
 protected $email;

 public function __construct(
 int $id,
 string $firstname,
 string $surname,
 string $email
 ) {
 $this->firstname = $firstname;
 $this->surname = $surname;
 $this->email = $email;

 if (empty($id)) {
 $this->id = ++self::$lastId;
 } else {
 $this->id = $id;
 if ($id > self::$lastId) {
 self::$lastId = $id;
 }
 }
 }

    public function getFirstname(): string {
        return $this->firstname;
    }
    public function getSurname(): string {
        return $this->surname;
    }
 public static function getLastId(): int {
 return self::$lastId;
 }
 public function getId(): int {
 return $this->id;
 }
 public function getEmail(): string {
 return $this->email;
 }
}

注意

使事情比必要的更复杂

接口非常有用,但万事万物都有其合适的时间和地点。由于我们的应用程序由于其教学性质而非常简单,实际上没有它们的真正位置。前一个部分中已经定义的抽象类是我们场景的最佳方法。但只是为了展示接口是如何工作的,我们将调整我们的代码以适应它们。

不要担心,因为我们将要引入的大多数代码,一旦我们在第五章“使用数据库”(Chapter 5)和第六章“适应 MVC”(Chapter 6)中引入数据库和 MVC 模式后,将被更好的实践所取代。

当编写自己的应用程序时,不要试图使事情比必要的更复杂。看到开发者试图在一个非常简单的场景中展示他们所有技能而编写非常复杂的代码是一种常见的模式。只使用必要的工具来留下干净、易于维护的代码,当然,这样的代码应该按预期工作。

使用以下内容更改Customer.php的内容:

<?php

namespace Bookstore\Domain;

interface Customer {
    public function getMonthlyFee(): float;
    public function getAmountToBorrow(): int;
    public function getType(): string;
}

注意,接口与抽象类非常相似。区别在于它是用关键字interface定义的,并且它的方法没有abstract这个词。接口不能被实例化,因为它们的实现方式与抽象类不同。你可以用它们做的唯一一件事就是创建一个类来实现它们。

实现一个接口意味着实现其中定义的所有方法,就像我们扩展抽象类时一样。它具有扩展抽象类的所有好处,例如属于那个类型——在类型提示时很有用。从开发者的角度来看,使用实现接口的类就像编写一个合同:你确保你的类将始终拥有接口中声明的那些方法,无论实现如何。正因为如此,接口只关心公共方法,这是其他开发者可以使用的那些方法。你需要在代码中做的唯一改变是将关键字extends替换为implements

class Basic implements Customer {

那么,为什么有人会使用接口,如果我们总能使用一个不仅强制实现方法,还允许继承代码的抽象类呢?原因在于你只能从一个类扩展,但你可以在同一时间实现多个实例。想象一下,如果你还有一个定义付款人的接口。这可以识别出有支付能力的人,无论是什么。将以下代码保存到src/Domain/Payer.php

<?php

namespace Bookstore\Domain;

interface Payer {
    public function pay(float $amount);
    public function isExtentOfTaxes(): bool;
}

现在基本客户和高级客户都可以实现这两个接口。基本客户将看起来如下:

//...
use Bookstore\Domain\Customer; 
use Bookstore\Domain\Person;

class Basic extends Person implements Customer {
    public function getMonthlyFee(): float {
//...

高级客户也会以同样的方式改变:

//...
use Bookstore\Domain\Customer; 
use Bookstore\Domain\Person;

class Premium extends Person implements Customer {
    public function getMonthlyFee(): float {
//...

你应该看到这段代码将不再工作。原因是尽管我们实现了第二个接口,但方法并没有实现。将这两个方法添加到基本客户类中:

public function pay(float $amount) {
    echo "Paying $amount.";
}

public function isExtentOfTaxes(): bool {
    return false;
}

将这两个方法添加到高级客户类中:

public function pay(float $amount) {
    echo "Paying $amount.";
}

public function isExtentOfTaxes(): bool {
    return true;
}

如果你知道所有客户都必须是付款人,你甚至可以让Customer接口继承自Payer接口:

interface Customer extends Payer {

这个改变根本不影响我们类的使用。其他开发者会看到我们的基本客户和高级客户继承自PayerCustomer,因此它们包含所有必要的方法。这些接口是独立的,或者它们相互扩展,这不会影响太多。

接口只能从其他接口扩展,类只能从其他类扩展。唯一混合它们的方式是一个类实现一个接口,但类不扩展接口,接口也不扩展类。但从类型提示的角度来看,它们可以互换使用。

为了总结本节内容并使事情清晰,让我们展示在所有新添加之后层次树的样子。与抽象类一样,接口中声明的方法显示在接口中,而不是在实现它的每个类中。

接口

多态

多态是面向对象编程的一个特性,它允许我们与实现相同接口的不同类一起工作。它是面向对象编程的美丽之处之一。它允许开发者创建一个复杂的类和层次树系统,但提供了简单的工作方式。

假设我们有一个函数,给定一个付款人,检查它是否免税,并使其支付一定金额的钱。这段代码并不关心付款人是客户、图书管理员还是与书店无关的人。它唯一关心的是付款人是否有支付的能力。这个函数可以是这样的:

function processPayment(Payer $payer, float $amount) {
    if ($payer->isExtentOfTaxes()) {
        echo "What a lucky one...";
    } else {
        $amount *= 1.16;
    }
    $payer->pay($amount);
}

您可以将基本客户或高级客户发送到这个功能,行为会有所不同。但是,由于两者都实现了Payer接口,提供的两个对象都是有效的类型,并且两者都能够执行所需的操作。

checkIfValid函数接受一个客户和一个书籍列表。我们已经看到发送任何类型的客户都会使函数按预期工作。但是,如果我们发送一个从Payer扩展的Librarian类的对象会发生什么?由于Payer不了解Customer(情况正好相反),函数会抱怨,因为类型提示没有完成。

PHP 附带的一个有用特性是检查一个对象是否是特定类或接口的实例。使用它的方法是指定变量后跟关键字instanceof和类或接口的名称。它返回一个布尔值,如果对象来自扩展或实现指定类的类,则为true,否则为false。让我们看看一些例子:

$basic = new Basic(1, "name", "surname", "email");
$premium = new Premium(2, "name", "surname", "email");
var_dump($basic instanceof Basic); // true
var_dump($basic instanceof Premium); // false
var_dump($premium instanceof Basic); // false
var_dump($premium instanceof Premium); // true
var_dump($basic instanceof Customer); // true
var_dump($basic instanceof Person); // true
var_dump($basic instanceof Payer); // true

记得为每个类或接口添加所有的use语句,否则 PHP 会理解指定的类名在文件的作用域内。

特型

到目前为止,您已经了解到从类扩展可以使您继承代码(属性和方法实现),但它有一个限制,即每次只能从一个类扩展。另一方面,您可以使用接口从同一个类实现多个行为,但您不能以这种方式继承代码。为了填补这个差距,即能够从多个地方继承代码,您有特型。

特质是允许你同时从多个来源重用代码的机制,或者说“继承”,或者说复制粘贴代码。特质,作为抽象类或接口,不能被实例化;它们只是包含可以由其他类使用的功能的功能容器。

如果你记得,我们在Person类中有些代码用于管理 ID 的分配。这段代码实际上并不属于个人,而是属于一个 ID 系统,该系统可以被其他需要用 ID 进行标识的实体使用。从Person类中提取这种功能的一种方法——我们并不是说这是最好的方法,但为了看到特质的实际应用,我们选择了这种方法——是将它移动到一个特质中。

要定义一个特质,就像定义一个类一样做,只是用关键字trait代替class。定义其命名空间,添加所需的use语句,声明其属性并实现其方法,并将所有内容放在遵循相同约定的文件中。将以下代码添加到src/Utils/Unique.php文件中:

<?php

namespace Bookstore\Utils;

trait Unique {
    private static $lastId = 0;
    protected $id;

 public function setId(int $id) {
        if (empty($id)) {
            $this->id = ++self::$lastId;
        } else {
            $this->id = $id;
            if ($id > self::$lastId) {
                self::$lastId = $id;
            }
        }
    }

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

注意,命名空间与通常不同,因为我们把这段代码存储在不同的文件中。这是一个关于约定的问题,但你完全可以根据每个案例考虑更好的文件结构。在这种情况下,我们认为这个特质并不代表像客户和书籍那样的“业务逻辑”,而是一个用于管理 ID 分配的实用工具。

我们包含了与 ID 相关的所有代码,包括属性、获取器和构造函数中的代码。由于特质不能被实例化,我们不能添加构造函数。相反,我们添加了一个setId方法,其中包含代码。当构造使用此特质的新实例时,我们可以调用此setId方法来根据用户提供的参数设置 ID。

Person类也需要做出改变。我们必须删除所有与 ID 相关的引用,并且必须以某种方式定义该类正在使用特质。为此,我们使用关键字use,就像在命名空间中一样,但要在类内部。让我们看看它将是什么样子:

<?php

namespace Bookstore\Domain;

use Bookstore\Utils\Unique;

class Person {
 use Unique;

    protected $firstname;
    protected $surname;
    protected $email;

    public function __construct(
        int $id,
        string $firstname,
        string $surname,
        string $email
    ) {
        $this->firstname = $firstname;
        $this->surname = $surname;
        $this->email = $email;
 $this->setId($id);
    }

    public function getFirstname(): string {
        return $this->firstname;
    }
    public function getSurname(): string {
        return $this->surname;
    }
    public function getEmail(): string {
        return $this->email;
    }
    public function setEmail(string $email) {
        $this->email = $email;
    }
}

我们添加了use Unique;语句,让类知道它正在使用特质。我们删除了与 ID 相关的一切,即使在构造函数中也是如此。我们仍然将 ID 作为构造函数的第一个参数,但我们要求特质的setId方法为我们做所有事情。注意,我们用$this来引用该方法,就像该方法在类内部一样。更新后的层次结构树将如下所示(注意,我们没有添加所有不涉及最近更改的类或接口的方法,以使图表尽可能小和可读):

特质

让我们看看它是如何工作的,即使它以你可能预期的这种方式进行。将以下代码添加到你的init.php文件中,包含必要的use语句,并在浏览器中执行:

$basic1 = new Basic(1, "name", "surname", "email");
$basic2 = new Basic(null, "name", "surname", "email");
var_dump($basic1->getId()); // 1
var_dump($basic2->getId()); // 2

上述代码创建了两个客户实例。第一个客户有一个特定的 ID,而第二个客户让系统为它选择一个 ID。结果是第二个基本客户的 ID 为 2。这是预期的,因为两个客户都是基本的。但如果客户类型不同会发生什么呢?

$basic = new Basic(1, "name", "surname", "email");
$premium = new Premium(null, "name", "surname", "email");
var_dump($basic->getId()); // 1
var_dump($premium->getId()); // 2

ID 仍然相同。这是预期的,因为特性包含在 Person 类中,所以静态属性 $lastId 将在 Person 类的所有实例之间共享,包括 BasicPremium 客户。如果你使用 BasicPremium 客户端的特性而不是 Person(但你不应这样做),你会得到以下结果:

var_dump($basic->getId()); // 1
var_dump($premium->getId()); // 1

每个类都将有自己的静态属性。所有 Basic 实例将共享相同的 $lastId,与 Premium 实例的 $lastId 不同。这应该清楚地表明,特性中的静态成员与使用它们的类相关联,而不是特性本身。这也可以从以下代码的测试中反映出来,该代码使用我们原始的场景,其中特性从 Person 使用:

$basic = new Basic(1, "name", "surname", "email");
$premium = new Premium(null, "name", "surname", "email");
var_dump(Person::getLastId()); // 2
var_dump(Unique::getLastId()); // 0
var_dump(Basic::getLastId()); // 2
var_dump(Premium::getLastId()); // 2

如果你善于发现问题,你可能会开始思考关于特性使用的一些潜在问题。如果我们使用包含相同方法的两个特性会发生什么?或者如果你使用一个包含已在类中实现的方法的特性会发生什么?

理想情况下,你应该避免遇到这类情况;它们是可能存在不良设计的警告信号。但既然总会有些特殊情况,让我们看看一些孤立示例,看看它们会如何表现。

当特性和类实现相同的方法时,这种情况很简单。在类中显式实现的方法具有更高的优先级,其次是特性中实现的方法,最后是从父类继承的方法。让我们看看它是如何工作的。以以下特性和类定义为例:

<?php

trait Contract {
    public function sign() {
        echo "Signing the contract.";
    }
}

class Manager {
    use Contract;

    public function sign() {
        echo "Signing a new player.";
    }
}

两个都实现了 sign 方法,这意味着我们必须应用之前定义的优先级规则。类中定义的方法比特性中的方法具有更高的优先级,所以在这种情况下,执行的方法将是类中的方法:

$manager = new Manager();
$manager->sign(); // Signing a new player.

最复杂的情况是,一个类使用两个具有相同方法的特点。没有规则可以自动解决冲突,所以你必须显式地解决它。查看以下代码:

<?php

trait Contract {
    public function sign() {
        echo "Signing the contract.";
    }
}

trait Communicator {
    public function sign() {
        echo "Signing to the waitress.";
    }
}

class Manager {
 use Contract, Communicator;
}

$manager = new Manager();
$manager->sign();

上述代码抛出一个致命错误,因为两个特性实现了相同的方法。要选择你想要使用的方法,你必须使用操作符 insteadof。要使用它,声明你想要使用的特性和方法,然后是 insteadof 和你想要拒绝使用的特性。可选地,使用关键字 as 添加一个别名,就像我们使用命名空间一样,这样你就可以使用两个方法:

class Manager {
 use Contract, Communicator {
 Contract::sign insteadof Communicator;
 Communicator::sign as makeASign;
 }
}

$manager = new Manager();
$manager->sign(); // Signing the contract.
$manager->makeASign(); // Signing to the waitress.

你可以看到我们决定使用Contract方法而不是Communicator方法,但添加了别名,以便两种方法都可以使用。希望你能看到,即使冲突也可以解决,并且存在一些特定的情况,除了处理它们之外没有其他事情可做;一般来说,它们看起来像是一个坏信号——不是字面意义上的。

处理异常

无论你的应用程序设计得多简单直观,用户都会有不恰当的使用或仅仅是随机的连接错误,你的代码必须准备好处理这些场景,以便用户体验尽可能好。我们把这些场景称为异常:语言中的一个元素,用于标识一个不符合预期的案例。

try…catch 块

当你认为必要时,你的代码可以手动抛出异常。例如,从Unique特质中获取setId方法。多亏了类型提示,我们强制 ID 必须是数字,但这只是第一步。如果有人尝试设置一个负数的 ID 会发生什么?目前的代码允许它通过,但根据你的偏好,你可能希望避免这种情况。那将是一个异常发生的好地方。让我们看看我们如何添加这个检查和随后的异常:

public function setId($id) {
    if ($id < 0) {
 throw new \Exception('Id cannot be negative.');
    }
    if (empty($id)) {
        $this->id = ++self::$lastId;
    } else {
        $this->id = $id;
        if ($id > self::$lastId) {
            self::$lastId = $id;
        }
    }
}

如你所见,异常是exception类的对象。记住,除非你想要在文件顶部使用use Exception;包含它,否则需要在类名前添加反斜杠。Exception类的构造函数接受一些可选参数,其中第一个是异常的消息。Exception类的实例本身并不做任何事情;它们必须被抛出才能被程序注意到。

让我们尝试强制我们的程序抛出这个异常。为了做到这一点,让我们尝试创建一个 ID 为负数的客户。在你的init.php文件中,添加以下内容:

$basic = new Basic(-1, "name", "surname", "email");

如果你现在在浏览器中尝试,PHP 将抛出一个致命错误,表示存在未捕获的异常,这是预期的行为。对于 PHP 来说,异常是它无法恢复的东西,所以它会停止执行。这远非理想,因为你希望只是向用户显示一个错误消息,并让他们再次尝试。

你可以——并且应该——使用try…catch块来捕获异常。你将可能抛出异常的代码放入try块中,如果发生异常,PHP 将跳转到catch块。让我们看看它是如何工作的:

public function setId(int $id) {
 try {
        if ($id < 0) {
            throw new Exception('Id cannot be negative.');
        }
        if (empty($id)) {
            $this->id = ++self::$lastId;
        } else {
            $this->id = $id;
            if ($id > self::$lastId) {
                self::$lastId = $id;
            }
        }
 } catch (Exception $e) {
 echo $e->getMessage();
 }
}
catch block. Calling the getMessage method on an exception instance will give us the message—the first argument when creating the object. But remember that the argument of the constructor is optional; so, do not rely on the message of the exception too much if you are not sure how it is generated, as it might be empty.

注意,在抛出异常之后,try块内的其他代码将不会执行;PHP 会直接跳转到catch块。此外,该块会接收到一个参数,即抛出的异常。在这里,类型提示是强制性的——你很快就会明白原因。将参数命名为$e是一个广泛使用的约定,尽管使用不具描述性的变量名并不是一个好的实践。

有一点批评性的看法,到目前为止,在这个例子中使用异常并没有看到任何真正的优势。一个简单的if…else块就能完成同样的工作,对吧?但异常的真正力量在于它们能够在方法之间传播。也就是说,如果在setId方法上抛出的异常没有被捕获,它将会传播到方法被调用的任何地方,这样我们就可以在那里捕获它。这非常有用,因为代码的不同地方可能需要以不同的方式处理异常。为了看到这是如何完成的,让我们从setId中移除插入的try…catch,并将以下代码片段放入你的init.php文件中,代替它:

try {
    $basic = new Basic(-1, "name", "surname", "email");
} catch (Exception $e) {
    echo 'Something happened when creating the basic customer: '
        . $e->getMessage();
}

前面的例子展示了捕获传播的异常是多么有用:我们可以更具体地了解发生了什么,因为我们知道当异常抛出时用户正在尝试做什么。在这种情况下,我们知道我们正在尝试创建客户,但这个异常可能是在尝试更新现有客户的 ID 时抛出的,这将需要一个不同的错误消息。

finally

在处理异常时,你可以使用第三个块:finally块。这个块是在try…catch块之后添加的,它是可选的。事实上,catch块也是可选的;限制是try之后必须至少跟有一个。所以你可以有这三种情况:

// scenario 1: the whole try-catch-finally
try {
    // code that might throw an exception
} catch (Exception $e) {
    // code that deals with the exception
} finally {
    // finally block
}

// scenario 2: try-finally without catch
try {
    // code that might throw an exception
} finally {
    // finally block
}

// scenario 3: try-catch without finally
try {
    // code that might throw an exception
} catch (Exception $e) {
    // code that deals with the exception
}
init.php file:
function createBasicCustomer($id)
{
    try {
        echo "\nTrying to create a new customer.\n";
        return new Basic($id, "name", "surname", "email");
    } catch (Exception $e) {
        echo "Something happened when creating the basic customer: "
            . $e->getMessage() . "\n";
    } finally {
        echo "End of function.\n";
    }
}

createBasicCustomer(1);
createBasicCustomer(-1);

如果你尝试这样做,你的浏览器将显示以下输出——记得显示页面的源代码以看到它格式得很好:

The finally block

结果可能不是你所预期的。第一次调用函数时,我们能够无问题地创建对象,这意味着我们执行了return语句。在一个正常的函数中,这应该是结束的地方,但由于我们处于try…catch…finally块中,我们仍然需要执行finally代码!第二个例子看起来更直观,从try跳到catch,然后到finally块。

finally块在处理像数据库连接这样的昂贵资源时非常有用。在第五章“使用数据库”中,你将看到如何使用它们。根据连接的类型,你将需要在使用后关闭它,以便其他用户可以连接。finally块用于关闭这些连接,无论函数是否抛出异常。

捕获不同类型的异常

异常已经被证明是有用的,但还有一个重要的特性要展示:捕获不同类型的异常。正如你所知道的那样,异常是 Exception 类的实例,并且像任何其他类一样,它们可以被扩展。从该类扩展的主要目标是创建不同类型的异常,但我们不会在其中添加任何逻辑——尽管你当然可以。让我们创建一个从 Exception 扩展的类,并标识与无效 ID 相关的异常。将此代码放入 src/Exceptions/InvalidIdException.php 文件中:

<?php

namespace Bookstore\Exceptions;

use Exception;

class InvalidIdException extends Exception {
    public function __construct($message = null) {
        $message = $message ?: 'Invalid id provided.';
        parent::__construct($message);
    }
}

InvalidIdException 类从 Exception 类扩展,因此它可以被抛出。该类的构造函数接受一个可选参数 $message。它内部的以下两行代码很有趣:

  • ?: 运算符是条件运算符的简短版本,其工作方式如下:如果左边的表达式不评估为 false,则返回该表达式,否则返回右边的表达式。我们在这里想要使用用户给出的消息,或者在没有提供任何消息的情况下使用默认消息。有关更多信息和使用方法,你可以访问 PHP 文档 php.net/manual/en/language.operators.comparison.php

  • parent::__construct 将调用父类的构造函数,即 Exception 类的构造函数。正如你所知道的那样,这个构造函数将异常的消息作为第一个参数。你可以争论,因为我们是从 Exception 类扩展的,所以我们实际上不需要调用任何函数,因为我们可以直接编辑类的属性。避免这样做的原因是让父类管理自己的属性。想象一下,由于某种原因,在 PHP 的未来版本中,Exception 改变了消息属性的名字。如果你直接修改它,你将不得不在代码中做相应的更改,但如果你使用构造函数,你就不必担心。内部实现比外部接口更可能发生变化。

我们可以使用这个新的异常代替通用的异常。在你的 Unique 特性中按照以下方式替换它:

throw new InvalidIdException('Id cannot be a negative number.');

你可以看到我们仍在发送消息:这是因为我们想要更加具体。但即使没有消息,异常也能正常工作。再次尝试你的代码,你会看到没有任何变化。

现在想象一下,我们有一个非常小的数据库,我们不允许超过 50 个用户。我们可以创建一个新的异常来标识这种情况,比如说,作为 src/Exceptions/ExceededMaxAllowedException.php

<?php

namespace Bookstore\Exceptions;

use Exception;

class ExceededMaxAllowedException extends Exception {
    public function __construct($message = null) {
        $message = $message ?: 'Exceeded max allowed.';
        parent::__construct($message);
    }
}

让我们修改我们的特性,以便检查这种情况。当设置 ID 时,如果这个 ID 大于 50,我们可以假设我们已经达到了用户数量的最大值:

public function setId(int $id) {
        if ($id < 0) {
            throw new InvalidIdException(
                'Id cannot be a negative number.'
            );
        }
        if (empty($id)) {
            $this->id = ++self::$lastId;
        } else {
            $this->id = $id;
            if ($id > self::$lastId) {
                self::$lastId = $id;
            }
        }
 if ($this->id > 50) {
 throw new ExceededMaxAllowedException(
 'Max number of users is 50.'
 );
 }
    }

现在先前的函数抛出了两种不同的异常:InvalidIdExceptionExceededMaxAllowedException。在捕获它们时,你可能希望根据捕获到的异常类型以不同的方式行事。还记得你必须在catch块中声明一个参数吗?嗯,你可以根据需要添加尽可能多的catch块,在每个块中指定不同的异常类。代码可能看起来像这样:

function createBasicCustomer(int $id)
{
    try {
        echo "\nTrying to create a new customer with id $id.\n";
        return new Basic($id, "name", "surname", "email");
 } catch (InvalidIdException $e) {
        echo "You cannot provide a negative id.\n";
 } catch (ExceededMaxAllowedException $e) {
        echo "No more customers are allowed.\n";
    } catch (Exception $e) {
        echo "Unknown exception: " . $e->getMessage();
    }
}

createBasicCustomer(1);
createBasicCustomer(-1);
createBasicCustomer(55);

如果你尝试这段代码,你应该看到以下输出:

捕获不同类型的异常

注意,这里我们捕获了三种异常:我们定义的两个新异常和通用的一个。这样做的原因是,可能存在其他代码块抛出与我们定义的类型不同的异常,我们需要定义一个带有通用Exception类的catch块来捕获它,因为所有异常都将从这个类扩展出来。当然,这完全是可选的,如果你不这样做,异常将只是被传播。

请记住catch块的顺序。PHP 会尝试按照你定义的顺序使用catch块。所以,如果你的第一个catch是针对Exception的,那么其余的块将永远不会被执行,因为所有异常都从这个类扩展出来。尝试以下代码:

try {
    echo "\nTrying to create a new customer with id $id.\n";
    return new Basic($id, "name", "surname", "email");
} catch (Exception $e) {
    echo 'Unknown exception: ' . $e->getMessage() . "\n";
} catch (InvalidIdException $e) {
    echo "You cannot provide a negative id.\n";
} catch (ExceededMaxAllowedException $e) {
    echo "No more customers are allowed.\n";
}

浏览器返回的结果始终来自第一个catch

捕获不同类型的异常

设计模式

开发者早在互联网出现之前就已经开始编写代码了,他们一直在多个不同的领域工作,而不仅仅是网页应用。正因为如此,很多人已经不得不面对类似的场景,带着之前尝试修复同样问题的经验。简而言之,这意味着几乎可以肯定,有人已经设计了一种很好的解决你现在面临问题的方法。

已经有很多书籍被写出来,试图将常见问题的解决方案分组,也称为设计模式。设计模式不是你可以复制粘贴到程序中的算法,展示如何一步一步地修复某个问题,而是一系列食谱,以启发式的方式向你展示如何寻找答案。

如果你想要成为一名专业开发者,研究它们是必不可少的,这不仅是为了解决问题,也是为了与其他开发者沟通。在讨论你的程序设计时,得到一个像“这里你可以使用工厂”这样的答案是非常常见的。知道工厂是什么,而不是每次有人提到它时都解释模式,可以节省很多时间。

正如我们所说,有整本书都在讨论设计模式,我们强烈建议您看看其中的一些。本节的目标是向您展示什么是设计模式以及如何使用它。此外,我们还将向您展示一些在编写 Web 应用程序时使用 PHP 的常见设计模式,不包括 MVC 模式,我们将在第六章,适应 MVC中学习。

除了书籍之外,您还可以访问开源项目DesignPatternsPHP,网址为designpatternsphp.readthedocs.org/en/latest/README.html。那里有很好的集合,并且它们是用 PHP 实现的,因此您更容易适应。

工厂

工厂是创建型设计模式之一,这意味着它允许您创建对象。您可能会想,我们不需要这样的东西,因为创建一个对象就像使用new关键字、类及其参数一样简单。但是让用户这样做是有危险的,原因有很多。除了使用new进行单元测试时增加的难度(您将在第七章中学习单元测试),测试 Web 应用程序),我们的代码中还会增加很多耦合。

当我们讨论封装时,您了解到隐藏类的内部实现是更好的做法,您可以将构造函数视为其中的一部分。原因是用户需要始终知道如何创建对象,包括构造函数的参数是什么。如果我们想改变构造函数以接受不同的参数怎么办?我们需要逐个检查所有创建对象的地方并更新它们。

使用工厂的另一个原因是管理继承自超类或实现相同接口的不同类。正如您所知,多态性使得您可以使用一个对象,而无需知道它实例化的具体类,只要您知道正在实现的接口。可能发生的情况是,您的代码需要实例化一个实现接口的对象并使用它,但该对象的实际类可能根本不重要。

想想我们的书店例子。我们有两种类型的客户:基本和高级。但在大多数代码中,我们并不真正关心特定实例是哪种类型的客户。事实上,我们应该编写我们的代码以使用实现Customer接口的对象,而无需了解具体的类型。所以,如果我们决定将来添加一个新类型,只要它实现了正确的接口,我们的代码将无问题地工作。但是,如果是这种情况,当我们需要创建一个新的客户时,我们无法实例化一个接口,所以让我们使用工厂模式。将以下代码添加到src/Domain/Customer/CustomerFactory.php中:

<?php

namespace Bookstore\Domain\Customer;

use Bookstore\Domain\Customer;

class CustomerFactory {
    public static function factory(
        string $type,
        int $id,
        string $firstname,
        string $surname,
        string $email
    ): Customer {
        switch ($type) {
            case 'basic':
                return new Basic($id, $firstname, $surname, $email);
            case 'premium':
                return new Premium($id, $firstname, $surname, $email);
        }
    }
}

之前代码中的工厂由于不同的原因并不理想。首先,我们使用了一个switch语句,并为所有现有的客户类型添加了一个情况。两种类型并不算多,但如果我们有 19 种呢?让我们尝试使这个工厂方法更加动态。

public static function factory(
        string $type,
        int $id,
        string $firstname,
        string $surname,
        string $email
    ): Customer {
 $classname = __NAMESPACE__ . '\\' . ucfirst($type);
 if (!class_exists($classname)) {
 throw new \InvalidArgumentException('Wrong type.');
 }
 return new $classname($id, $firstname, $surname, $email);
}

是的,你可以在 PHP 中做我们之前代码中所做的那样。动态实例化类,即使用变量的内容作为类的名称,是 PHP 如此灵活……以及危险的原因之一。使用不当,它会使你的代码难以阅读和维护,所以对此要小心。还要注意常量__NAMESPACE__,它包含当前文件的作用域。

现在这个工厂看起来更简洁,而且它也非常灵活。你可以添加更多客户类型,只要它们在正确的命名空间内并实现了接口,那么在工厂的这一侧以及工厂的使用上就无需做任何更改。

为了使用它,让我们更改我们的init.php文件。你可以移除所有的测试,只留下自动加载的代码。然后,添加以下内容:

CustomerFactory::factory('basic', 2, 'mary', 'poppins', 'mary@poppins.com');
CustomerFactory::factory('premium', null, 'james', 'bond', 'james@bond.com');

工厂设计模式可以像你需要的那样复杂。它有不同的变体,每一种都有其合适的时间和地点,但总体思想始终相同。

单例

如果有人对设计模式或一般意义上的 Web 开发有一些经验,他们看到这个章节的标题时,可能会开始拔自己的头发并声称单例是设计模式中最糟糕的例子。但请耐心听我说。

当解释接口时,我添加了一条关于开发者倾向于使他们的代码过于复杂,仅仅是为了能够使用他们所知道的所有工具的注释。使用设计模式就是这种情况之一。它们已经非常著名,人们声称对它们的良好使用与优秀开发者直接相关,因此每一个学习它们的人都会试图在绝对任何地方使用它们。

单例模式可能是 PHP 在 Web 开发中使用的设计模式中最臭名昭著的一个。这个模式有一个非常具体的目的,当这种情况发生时,这个模式证明是非常有用的。但这个模式实现起来如此简单,以至于开发者不断地试图在各个地方添加单例,使他们的代码变得难以维护。正因为如此,人们称其为反模式,一种应该避免而不是使用的模式。

我确实同意这个观点,但我仍然认为你应该非常熟悉这个设计模式。即使你应该避免过度使用它,人们仍然到处都在使用它,他们无数次地提到它,所以你应该处于一个可以同意他们或者有足够的理由来阻止他们使用的位置。话虽如此,让我们看看单例模式的目的是什么。

这个想法很简单:当你想让一个类始终只有一个唯一实例时,就使用单例。每次,无论在哪里使用这个类,它都必须是通过同一个实例来使用。原因是避免有太多重资源的实例,或者保持始终相同的状态——全局。例如,数据库连接或配置处理器。

想象一下,为了运行我们的应用程序,我们需要一些配置,比如数据库的凭证、特殊端点的 URL、查找库或重要文件的目录路径等等。当你收到一个请求时,你首先做的事情是从文件系统中加载这个配置,然后将其存储为数组或其他数据结构。将以下代码保存为你的src/Utils/Config.php文件:

<?php

namespace Bookstore\Utils;

use Bookstore\Exceptions\NotFoundException;

class Config {
    private $data;

    public function __construct() {
    $json = file_get_contents(__DIR__ . '/../../config/app.json');
        $this->data = json_decode($json, true);
    }

    public function get($key) {
        if (!isset($this->data[$key])) {
            throw new NotFoundException("Key $key not in config.");
        }
        return $this->data[$key];
    }
}

正如你所见,这个类使用了一个新的异常。在src/Utils/NotFoundException.php下创建它:

<?php

namespace Bookstore\Exceptions;

use Exception;

class NotFoundException extends Exception {
}

此外,这个类读取一个文件,config/app.json。你可以在其中添加以下 JSON 映射:

{
  "db": {
    "user": "Luke",
    "password": "Skywalker"
  }
}

为了使用这个配置,让我们将以下代码添加到你的init.php文件中。

$config = new Config();
$dbConfig = $config->get('db');
var_dump($dbConfig);

这看起来是一个读取配置的好方法,但请注意高亮的那一行。我们实例化了Config对象,因此,我们读取一个文件,将其内容从 JSON 转换为数组,并存储它。如果文件包含的行数是六行而不是数百行,你应该注意到实例化这个类是非常昂贵的。

你不希望在每次从配置中请求一些数据时都读取文件并将它们转换成数组。这太昂贵了!但当然,你肯定需要在代码的很多地方使用配置数组,你不能把这个数组带到任何地方。如果你理解了静态属性和方法,你可以争论说在对象内部实现一个静态数组应该可以解决这个问题。你只需实例化一次,然后只需调用一个静态方法,该方法将访问一个已经填充的静态属性。理论上,我们跳过了实例化,对吧?

<?php

namespace Bookstore\Utils;

use Bookstore\Exceptions\NotFoundException;

class Config {
 private static $data;

    public function __construct() {
        $json = file_get_contents(__DIR__ . '/../config/app.json');
 self::$data = json_decode($json, true);
    }

 public static function get($key) {
 if (!isset(self::$data[$key])) {
            throw new NotFoundException("Key $key not in config.");
        }
 return self::$data[$key];
    }
}

这看起来是个好主意,但非常危险。你怎么能绝对确定数组已经被填充了呢?而且你怎么能确保,即使使用静态上下文,用户也不会一次又一次地实例化这个类呢?这就是单例模式派上用场的地方。

实现单例意味着以下要点:

  1. 将类的构造函数设为私有,这样绝对没有人可以从类外部实例化该类。

  2. 创建一个名为$instance的静态属性,它将包含一个自身的实例——也就是说,在我们的Config类中,$instance属性将包含Config类的实例。

  3. 创建一个名为getInstance的静态方法,它将检查$instance是否为 null,如果是,它将使用私有构造函数创建一个新的实例。无论如何,它都会返回$instance属性。

让我们看看单例类会是什么样子:

<?php

namespace Bookstore\Utils;

use Bookstore\Exceptions\NotFoundException;

class Config {
    private $data;
 private static $instance;

 private function __construct() {
        $json = file_get_contents(__DIR__ . '/../config/app.json');
        $this->data = json_decode($json, true);
    }

 public static function getInstance(){
 if (self::$instance == null) {
 self::$instance = new Config();
 }
 return self::$instance;
 }

    public function get($key) {
        if (!isset($this->data[$key])) {
            throw new NotFoundException("Key $key not in config.");
        }
        return $this->data[$key];
    }
}

如果你现在运行这段代码,它会抛出一个错误,因为这个类的构造函数是私有的。第一个成就解锁!让我们正确地使用这个类:

$config = Config::getInstance();
$dbConfig = $config->get('db');
var_dump($dbConfig);

这能让你信服吗?这确实证明是非常方便的。但是,我必须强调这一点:当你使用这种设计模式时,要非常小心,因为它有非常、非常具体的用例。避免陷入在所有地方都实现它的陷阱!

匿名函数

匿名函数,或称为lambda 函数,是没有名称的函数。由于它们没有名称,为了能够调用它们,我们需要将它们存储为变量。一开始可能会觉得有些奇怪,但这个想法其实很简单。在这个阶段,我们实际上并不需要任何匿名函数,所以让我们直接将代码添加到init.php中,然后再将其删除:

$addTaxes = function (array &$book, $index, $percentage) {
    $book['price'] += round($percentage * $book['price'], 2);
};

前面的匿名函数被分配给变量$addTaxes。它期望三个参数:$book(一个作为引用的数组)、$index(未使用)和$percentage。该函数将税费添加到书籍的价格键上,四舍五入到两位小数(round是 PHP 的本地函数)。不要在意参数$index,在这个函数中它没有被使用,但被迫按照我们将要使用它的方式使用,正如你将看到的。

你可以将一系列书籍作为一个数组实例化,遍历它们,然后每次调用这个函数。一个例子可能是以下这样:

$books = [
    ['title' => '1984', 'price' => 8.15],
    ['title' => 'Don Quijote', 'price' => 12.00],
    ['title' => 'Odyssey', 'price' => 3.55]
];
foreach ($books as $index => $book) {
 $addTaxes($book, $index, 0.16);
}
var_dump($books);

为了使用该函数,你只需像调用$addTaxes包含要调用的函数名称一样调用它。函数的其他部分就像是一个普通函数一样工作:它接收参数,它可以返回一个值,并且它有一个作用域。以这种方式定义它的好处是什么?一个可能的应用是将其用作可调用对象。可调用对象是一种变量类型,用于标识 PHP 可以调用的函数。你将这个可调用变量作为参数发送,接收它的函数可以调用它。以 PHP 的内置函数array_walk为例。它接收一个数组、一个可调用对象和一些额外的参数。PHP 将迭代数组,并对每个元素调用可调用函数(就像foreach循环一样)。因此,你可以用以下方式替换整个循环:

array_walk($books, $addTaxes, 0.16);

array_walk接收的可调用对象需要至少接受两个参数:数组的当前元素的值和索引,因此我们之前被迫实现的$index参数。它还可以可选地接受额外的参数,这些参数将是发送给array_walk的额外参数——在这种情况下,是 0.16 作为$percentage

实际上,在 PHP 中,可调用的不仅仅是匿名函数。你可以发送普通函数甚至类方法。让我们看看如何做到:

function addTaxes(array &$book, $index, $percentage) {
    if (isset($book['price'])) {
        $book['price'] += round($percentage * $book['price'], 2);
    }
}

class Taxes {
    public static function add(array &$book, $index, $percentage)
    {
        if (isset($book['price'])) {
            $book['price'] += round($percentage * $book['price'], 2);
        }
    }
    public function addTaxes(array &$book, $index, $percentage)
    {
        if (isset($book['price'])) {
            $book['price'] += round($percentage * $book['price'], 2);
        }
    }
}

// using normal function
array_walk($books, 'addTaxes', 0.16);
var_dump($books);

// using static class method
array_walk($books, ['Taxes', 'add'], 0.16);
var_dump($books);

// using class method
array_walk($books, [new Taxes(), 'addTaxes'], 0.16);
var_dump($books);

在前面的例子中,你可以看到我们如何将每种情况用作可调用对象。对于普通方法,只需发送方法名称作为字符串。对于类的静态方法,发送一个包含类名称的数组,以 PHP 能理解的方式(要么是包括命名空间的全名,要么是在之前添加use关键字),以及方法名称,两者都作为字符串。要使用类的普通方法,你需要发送一个包含该类实例和方法名称作为字符串的数组。

好的,所以匿名函数可以用作可调用对象,就像任何其他函数或方法一样。那么它们有什么特别之处呢?其中之一是匿名函数是变量,因此它们具有变量所具有的所有优点——或者缺点。这包括作用域——也就是说,函数是在作用域内定义的,一旦这个作用域结束,函数将不再可访问。如果你的函数非常特定于那部分代码,并且你不想在其他地方重用它,这可能会很有用。此外,由于它是无名的,你不会与任何其他现有函数发生冲突。

使用匿名函数的另一个好处是继承父作用域的变量。当你定义一个匿名函数时,你可以使用关键字use指定定义它的作用域中的某些变量,并在函数内部使用它。变量的值将是它在声明函数时的值,即使后来被更新也是如此。让我们看一个例子:

$percentage = 0.16;
$addTaxes = function (array &$book, $index) use ($percentage) {
    if (isset($book['price'])) {
        $book['price'] += round($percentage * $book['price'], 2);
    }
};
$percentage = 100000;
array_walk($books, $addTaxes);
var_dump($books);

上述例子展示了如何使用关键字use。即使我们在定义函数后更新$percentage,结果也会显示税率仅为 16%。这很有用,因为它让你不必在想要使用函数$addTaxes的任何地方都发送$percentage。如果确实有需要使用已更新变量值的场景,你可以像在普通函数的参数中那样,将它们声明为引用:

$percentage = 0.16;
$addTaxes = function (array &$book, $index) use (&$percentage) {
    if (isset($book['price'])) {
        $book['price'] += round($percentage * $book['price'], 2);
    }
};

array_walk($books, $addTaxes, 0.16);
var_dump($books);

$percentage = 100000;
array_walk($books, $addTaxes, 0.16);
var_dump($books);

在这个最后的例子中,第一个array_walk使用了原始值 0.16,因为那时变量的值仍然是这个。但在第二次调用时,$percentage已经改变,这影响了匿名函数的结果。

摘要

在本章中,你已经学习了什么是面向对象编程,以及如何将其应用于我们的 Web 应用程序以创建易于维护的清晰代码。你还知道了如何正确地管理异常,最常用的设计模式,以及何时使用匿名函数。

在下一章中,我们将解释如何使用数据库来管理你的应用程序数据,这样你就可以完全将数据与代码分离。

第五章:使用数据库

数据可能是大多数 Web 应用程序的基石。当然,你的应用程序必须很漂亮、快速、无错误,等等,但如果某些东西对用户来说是基本的,那就是你可以为他们管理的数据。从这一点我们可以得出,管理数据是你在设计应用程序时必须考虑的最重要的事情之一。

管理数据不仅意味着存储只读文件并在需要时读取它们,就像我们迄今为止所做的那样,还包括添加、检索、更新和删除单个信息片段。为此,我们需要一个工具来分类我们的数据,并使这些任务对我们来说更容易,这就是数据库发挥作用的时候。

在本章中,你将了解:

  • 模式和表

  • 操作和查询数据

  • 使用 PDO 将您的数据库与 PHP 连接

  • 索引你的数据

  • 在连接的表中构建复杂查询

介绍数据库

数据库是管理数据的工具。数据库的基本功能是插入、搜索、更新和删除数据,尽管大多数数据库系统做的不仅仅是这些。根据它们存储数据的方式,数据库分为两个不同的类别:关系型数据库和非关系型数据库。

关系型数据库以非常详细的方式组织数据,迫使用户使用定义的格式,并允许创建不同信息片段之间的连接——即关系。非关系型数据库是存储数据方式更为宽松的系统,就像没有明显的结构一样。尽管这些非常模糊的定义可能会让你认为每个人都想使用关系型数据库,但这两个系统都非常有用;这完全取决于你如何使用它们。

在这本书中,我们将专注于关系型数据库,因为它们在小型 Web 应用程序中广泛使用,在这些应用程序中,数据量不是很大。原因是通常应用程序包含相互关联的数据;例如,我们的应用程序可以存储销售数据,这些销售数据由客户和书籍组成。

MySQL

MySQL 长期以来一直是 PHP 开发者的首选选择。它是一个使用 SQL 作为与系统通信语言的数据库系统。SQL 在许多其他系统中也被使用,这使得在需要切换数据库或只是需要理解一个使用不同于你习惯的数据库的应用程序时,事情变得更容易。本章的其余部分将专注于 MySQL,但即使你选择不同的 SQL 系统,它也会对你有所帮助。

为了使用 MySQL,你需要安装两个应用程序:服务器和客户端。你可能还记得从第二章,使用 PHP 的 Web 应用程序中提到的服务器-客户端应用程序。MySQL 服务器是一个程序,它监听来自客户端的指令或查询,执行它们,并返回结果。你需要启动服务器才能访问数据库;请参阅第一章,设置环境,了解如何进行此操作。客户端是一个应用程序,允许你构建指令并将它们发送到服务器,你将使用它。

注意

GUI 与命令行

图形用户界面GUI)在数据库使用中非常常见。它可以帮助你构建指令,甚至仅使用可视表格就可以管理数据。另一方面,命令行客户端强迫你手动编写所有命令,但它们比 GUI 轻便,启动速度快,并迫使你记住如何编写 SQL,这在用 PHP 编写应用程序时是必需的。此外,一般来说,几乎任何带有数据库的机器都会有一个 MySQL 客户端,但可能没有图形应用程序。

你可以选择你更舒适的一个,因为你通常会在自己的机器上工作。然而,请记住,基本的命令行知识会在许多场合救你于水火之中。

为了将客户端与服务器连接起来,你需要提供一些关于连接位置和用户凭证的信息。如果你没有自定义你的 MySQL 安装,至少你应该有一个没有密码的 root 用户,这是我们将会使用的。你可能认为这似乎是一个可怕的安全漏洞,可能确实如此,但如果你不是从服务器所在的同一台机器连接,你不应该能够使用此用户连接。在启动客户端时,你可以使用的最常见参数是:

  • -u <用户名>:这指定了用户——在我们的情况下,root

  • -p<密码>:没有空格,这指定了密码。由于我们没有为我们的用户设置密码,因此我们不需要提供此信息。

  • -h <主机>:这指定了连接的位置。默认情况下,客户端连接到同一台机器。由于我们的情况是这样,因此不需要指定任何内容。如果你需要,你可以指定一个 IP 地址或主机名。

  • <模式名称>:这指定了要使用的模式名称。我们将在稍后解释这意味着什么。

根据这些规则,你应该能够使用mysql -u root命令连接到你的数据库。你应该得到一个非常类似于以下输出的结果:

$ mysql -u root
Welcome to the MySQL monitor.  Commands end with ; or \g.
Your MySQL connection id is 2
Server version: 5.1.73 Source distribution

Copyright (c) 2000, 2013, Oracle and/or its affiliates. All rights reserved.

Oracle is a registered trademark of Oracle Corporation and/or its
affiliates. Other names may be trademarks of their respective
owners.

Type 'help;' or '\h' for help. Type '\c' to clear the current input statement.

mysql>

终端将显示服务器的版本以及有关如何使用客户端的一些有用信息。从现在开始,命令行将以 mysql> 开头,而不是您的正常提示符,这表明您正在使用 MySQL 客户端。要执行查询,只需输入查询,以分号结束,然后按 Enter。客户端会将查询发送到服务器,并显示其结果。要退出客户端,您可以输入 \q 并按 Enter,或者按 Ctrl + D,尽管最后一个选项将取决于您的操作系统。

模式和表

关系型数据库系统通常具有相同的结构。它们在不同的数据库或 模式 中存储数据,这些模式将不同应用程序的数据分开。这些模式只是 的集合。表是特定数据结构的定义,由 字段 组成。字段是基本数据类型,它定义了信息的最小组成部分,就像它们是数据的基本粒子一样。因此,模式是由字段组成的表的集合。让我们看看这些元素中的每一个。

理解模式

如前所述,模式或数据库——在 MySQL 中,它们是同义词——是具有共同上下文的表的集合,通常属于同一应用程序。实际上,在这方面没有限制,如果需要,您可以为同一应用程序拥有多个模式。然而,对于小型网络应用程序,正如我们的情况一样,我们只有一个模式。

您的服务器可能已经有一些模式。它们通常包含 MySQL 运作所需的元数据,我们强烈建议您不要修改它们。相反,让我们只创建我们自己的模式。模式是非常简单的元素,它们只有一个必需的名称和一个可选的字符集。名称标识模式,字符集定义字符串应遵循的类型编码或“字母表”。由于默认字符集是 latin1,如果您不需要更改它,则不需要指定它。

使用 CREATE SCHEMA 后跟模式名称,以便创建我们将用于书店的模式。名称必须具有代表性,所以让我们称它为 bookstore。请记住,在行尾加上分号。看看下面的例子:

mysql> CREATE SCHEMA bookstore;
Query OK, 1 row affected (0.00 sec)

如果您需要记住模式是如何创建的,可以使用 SHOW CREATE SCHEMA 来查看其描述,如下所示:

mysql> SHOW CREATE SCHEMA bookstore \G
*************************** 1\. row ***************************
 Database: bookstore
Create Database: CREATE DATABASE `bookstore` /*!40100 DEFAULT CHARACTER SET latin1 */
1 row in set (0.00 sec)

如您所见,我们以 \G 结束查询而不是分号。这告诉客户端以与分号不同的方式格式化响应。当使用 SHOW CREATE 类型的命令时,我们建议您以 \G 结束,以获得更好的理解。

小贴士

您应该使用大写还是小写?

当编写查询时,你可能注意到我们使用了大写字母作为关键字,小写字母作为标识符,例如模式的名称。这只是广泛使用的一种约定,以便清楚地表明什么是 SQL 的一部分,什么是你的数据。然而,MySQL 的关键字是不区分大小写的,所以你可以无差别地使用任何大小写。

所有数据都必须属于一个模式。数据不能在所有模式之外漂浮。这样,除非你指定你想要使用的模式,否则你不能做任何事情。为了做到这一点,在你的客户端启动后,使用USE关键字后跟模式名称。可选地,你可以在连接时告诉客户端使用哪个模式,如下所示:

mysql> USE bookstore;
Database changed

如果你忘记了你的模式名称或者想要检查你的服务器中还有哪些其他模式,你可以运行SHOW SCHEMAS;命令来获取它们的列表,如下所示:

mysql> SHOW SCHEMAS;
+--------------------+
| Database           |
+--------------------+
| information_schema |
| bookstore          |
| mysql              |
| test               |
+--------------------+
4 rows in set (0.00 sec)

数据库数据类型

与 PHP 一样,MySQL 也有数据类型。它们用于定义一个字段可以包含哪种类型的数据。与 PHP 一样,MySQL 在数据类型方面相当灵活,如果需要,可以将它们从一种类型转换为另一种类型。它们有很多种,但我们将解释最重要的几种。我们强烈建议如果你想要使用更复杂的数据结构来构建应用程序,请访问有关数据类型的官方文档dev.mysql.com/doc/refman/5.7/en/data-types.html

数值数据类型

数值数据可以分为整数或小数。对于整数,MySQL 使用INT数据类型,尽管有版本可以存储更小的数字,例如TINYINTSMALLINTMEDIUMINT,或者更大的数字,例如BIGINT。下表显示了不同数值类型的大小,这样你可以根据你的情况选择使用哪一个:

类型 大小/精度
TINYINT -128 到 127
SMALLINT -32,768 到 32,767
MEDIUMINT -8,388,608 到 8,388,607
INT -2,147,483,648 到 2,147,483,647
BIGINT -9,223,372,036,854,775,808 到 9,223,372,036,854,775,807

数值类型可以被定义为默认有符号或无符号;也就是说,你可以允许或不允许它们包含负值。如果一个数值类型被定义为UNSIGNED,那么它可以接受的数字范围会加倍,因为它不需要为负数预留空间。

对于小数,我们有两种类型:近似值,处理速度快但有时并不精确,以及精确值,可以给出小数值的精确精度。对于近似值或浮点类型,我们有FLOATDOUBLE。对于精确值或定点类型,我们有DECIMAL

MySQL 允许你指定数字可以包含的位数和小数位数。例如,为了指定一个可以包含五个数字且最多有两个小数的数字,我们将使用 FLOAT(5,2) 语法。当我们在创建价格表时,你会注意到这作为一个约束是有用的。

字符串数据类型

尽管有几种数据类型允许你存储从单个字符到大量文本或二进制代码,但这超出了本章的范围。在本节中,我们将向你介绍三种类型:CHARVARCHARTEXT

CHAR 是一种数据类型,允许你存储一个确切数量的字符。一旦你定义了字段,你需要指定字符串的长度,从这一点开始,这个字段的所有值都必须是这个长度。在我们的应用中,一个可能的用途是当存储书籍的 ISBN 时,因为我们知道它总是 13 个字符长。

VARCHAR 或可变字符是一种数据类型,允许你存储长达 65,535 个字符的字符串。你不需要指定它们的长度,并且可以插入不同长度的字符串而不会出现问题。当然,这种类型的动态性使得它的处理速度比前一种类型慢,但经过几次之后,你就会知道字符串的长度总是多少。你可以告诉 MySQL,即使你想插入不同长度的字符串,最大长度也将是一个确定的数字。这将有助于其性能。例如,名字的长度不同,但你可以安全地假设没有名字的长度会超过 64 个字符,因此你的字段可以定义为 VARCHAR(64)

最后,TEXT 是一种用于存储非常长字符串的数据类型。如果你想存储用户的长期评论、文章等,可以使用它。与 INT 类似,这个数据类型有不同的版本:TINYTEXTTEXTMEDIUMTEXTLONGTEXT。尽管它们在几乎任何具有用户交互的 Web 应用程序中都非常重要,但我们将不会在我们的应用中使用它们。

值列表

在 MySQL 中,你可以强制一个字段只能包含一组有效的值。它们有两种类型:ENUM,它允许正好包含一个预定义的可能值,和SET,它允许包含任意数量的预定义值。

例如,在我们的应用中,我们有两种类型的客户:基本和高级。如果我们想在数据库中存储我们的客户,那么有一个字段将是客户类型。由于客户必须是基本或高级之一,一个很好的解决方案是将该字段定义为枚举类型 ENUM("basic", "premium")。这样,我们将确保所有存储在我们数据库中的客户都将具有正确的类型。

尽管枚举(enum)的使用相当普遍,但集合(set)的使用则不太广泛。通常,使用一个额外的表来定义列表的值是一个更好的主意,正如我们在本章讨论外键时将会注意到的。

日期和时间数据类型

日期和时间类型是 MySQL 中最复杂的数据类型。尽管这个想法很简单,但围绕这些类型有多个函数和边缘情况。我们无法一一介绍,所以我们只解释最常见的用法,这是我们应用程序所需要的。

DATE存储日期——即日、月和年的组合。TIME存储时间——即小时、分钟和秒的组合。DATETIME是日期和时间的数据类型。对于这些数据类型中的任何一种,你都可以提供一个字符串来指定值,但你需要注意所使用的格式。尽管你可以始终指定输入数据的格式,但你也可以使用默认格式输入日期或时间——例如,日期为 2014-12-31,时间为 14:34:50,日期和时间为 2014-12-31 14:34:50。

第四种类型是TIMESTAMP。这种类型存储一个整数,表示从 1970 年 1 月 1 日以来的秒数,也称为 Unix 时间戳。在 PHP 中,使用now()函数获取当前的 Unix 时间戳非常容易,并且这种数据类型的格式始终相同,因此与它一起工作更安全。缺点是它所能表示的日期范围与其他类型相比有限。

有一些函数可以帮助你管理这些类型。这些函数提取整个值的特定部分,以不同的格式返回值,添加或减去日期,等等。让我们看看它们的简要列表:

函数名 描述
DAY(), MONTH(), 和 YEAR() 从提供的DATEDATETIME值中提取日、月或年的特定值。
HOUR(), MINUTE(), 和 SECOND() 从提供的TIMEDATETIME值中提取小时、分钟或秒的特定值。
CURRENT_DATE()CURRENT_TIME() 返回当前的日期或当前的时间。
NOW() 返回当前的日期和时间。
DATE_FORMAT() 返回具有指定格式的DATETIMEDATETIME值。
DATE_ADD() 将指定的间隔时间添加到给定的日期或时间类型。

如果你对如何使用这些函数感到困惑,请不要担心;我们将在本书的其余部分作为我们应用程序的一部分来使用它们。此外,所有类型的详细列表可以在dev.mysql.com/doc/refman/5.7/en/date-and-time-functions.html找到。

管理表

现在你已经了解了字段可以采取的不同数据类型,是时候介绍表了。如模式与表部分所定义,表是一组字段,它定义了一种信息类型。你可以将其与面向对象编程(OOP)进行比较,将表视为类,字段是它们的属性。类的每个实例都会成为表中的一行。

在定义一个表时,你必须声明该表包含的字段列表。对于每个字段,你需要指定其名称、其类型以及根据字段类型的一些额外信息。最常见的包括:

  • NOT NULL:如果字段不能为 null——也就是说,如果它需要为每一行提供一个具体的有效值,则使用此选项。默认情况下,字段可以是 null。

  • UNSIGNED:如前所述,这用于禁止在此字段中使用负数。默认情况下,数值字段接受负数。

  • DEFAULT <value>:这定义了一个默认值,以防用户没有提供任何值。通常,如果没有指定此子句,默认值是 null。

表定义也需要一个名称,就像模式一样,以及一些可选属性。你可以定义表的字符集或其引擎。引擎可以是一个相当大的主题,但就本章的范围而言,让我们只注意,如果我们需要在表之间建立强大的关系,我们应该使用 InnoDB 引擎。对于更高级的读者,你可以在dev.mysql.com/doc/refman/5.0/en/storage-engines.html上了解更多关于 MySQL 引擎的信息。

了解这一点后,让我们尝试创建一个将保存我们的书籍的表。表的名称应该是book,因为每一行将定义一本书。字段可以具有与Book类相同的属性。让我们看看构建表的查询将是什么样子:

mysql> CREATE TABLE book(
 -> isbn CHAR(13) NOT NULL,
 -> title VARCHAR(255) NOT NULL,
 -> author VARCHAR(255) NOT NULL,
 -> stock SMALLINT UNSIGNED NOT NULL DEFAULT 0,
 -> price FLOAT UNSIGNED
 -> ) ENGINE=InnoDb;
Query OK, 0 rows affected (0.01 sec)

正如你所注意到的,我们可以添加更多的新行,直到我们用分号结束查询。这样,我们可以以更易读的方式格式化查询。MySQL 会让我们知道我们仍在编写同一个查询,显示->提示符。由于这个表包含五个字段,我们很可能需要不时地刷新我们的记忆,因为我们可能会忘记它们。为了显示表的结构,你可以使用DESC命令,如下所示:

mysql> DESC book;
+--------+----------------------+------+-----+---------+-------+
| Field  | Type                 | Null | Key | Default | Extra |
+--------+----------------------+------+-----+---------+-------+
| isbn   | char(13)             | NO   |     | NULL    |       |
| title  | varchar(255)         | NO   |     | NULL    |       |
| author | varchar(255)         | NO   |     | NULL    |       |
| stock  | smallint(5) unsigned | NO   |     | 0       |       |
| price  | float unsigned       | YES  |     | NULL    |       |
+--------+----------------------+------+-----+---------+-------+
5 rows in set (0.00 sec)

我们为stock字段使用了SMALLINT,因为它非常不可能有超过几千本相同的书的副本。正如我们所知,ISBN 是 13 个字符长,我们在定义字段时强制执行了这一点。最后,stockprice都是无符号的,因为负值没有意义。现在,让我们通过以下脚本创建我们的customer表:

mysql> CREATE TABLE customer(
 -> id INT UNSIGNED NOT NULL,
 -> firstname VARCHAR(255) NOT NULL,
 -> surname VARCHAR(255) NOT NULL,
 -> email VARCHAR(255) NOT NULL,
 -> type ENUM('basic', 'premium')
 -> ) ENGINE=InnoDb;
Query OK, 0 rows affected (0.00 sec)

我们已经预见到使用枚举作为字段类型,因为在设计类时,我们可以绘制一个图来标识我们的数据库内容。在这张图上,我们可以显示表及其字段。让我们看看到目前为止表格图会是什么样子:

管理表格

注意,即使我们创建了与我们的类相似的表格,我们也不会为Person创建一个表格。原因是数据库存储数据,而这个类没有可以存储的数据,因为customer表已经包含了我们所需的所有信息。此外,有时我们可能会创建在代码中没有作为类的表格,因此类与表之间的关系是非常灵活的。

键和约束

现在我们已经定义了主表,让我们尝试思考表内的数据看起来会是什么样子。表内的每一行将描述一个对象,这个对象可能是一本书或一个客户。如果我们的应用程序有一个错误,允许我们创建具有相同数据的书籍或客户会发生什么?数据库将如何区分它们?在理论上,我们将为客户分配 ID 以避免这些场景,但我们如何强制 ID 不重复?

MySQL 有一个机制可以让你对你的数据强制执行某些限制。除了你已经看到的NOT NULLUNSIGNED等属性之外,你可以告诉 MySQL 某些字段比其他字段更特殊,并指示它为它们添加一些行为。这些机制被称为,有四种类型:主键、唯一键、外键和索引。让我们更详细地看看它们。

主键

主键是标识表中唯一行的字段。同一表中不能有两个相同的值,它们也不能为空。将主键添加到定义对象的表中几乎是必须的,因为它将确保你将始终能够通过此字段区分两行。

使主键如此吸引人的另一个部分是它们可以将主键设置为自增的数值;也就是说,你不需要为 ID 分配值,MySQL 会自动获取最新插入的 ID 并将其增加 1,就像我们使用Unique特性时做的那样。当然,为了实现这一点,你的字段必须是整数数据类型。实际上,我们强烈建议你始终将主键定义为整数,即使现实生活中的对象根本不具有这个 ID。原因是你应该通过这个唯一的数值 ID 来搜索行,MySQL 将为设置字段为键提供一些性能改进。

然后,让我们给我们的book表添加一个 ID。为了添加一个新字段,我们需要修改我们的表。有一个命令可以让你做到这一点:ALTER TABLE。使用这个命令,你可以修改任何现有字段的定义,添加新的字段,或者删除现有的字段。由于我们将添加的字段将成为我们的主键并且是自增的,我们可以将这些修饰符添加到字段定义中。执行以下代码:

mysql> ALTER TABLE book
 -> ADD id INT UNSIGNED NOT NULL AUTO_INCREMENT 
 -> PRIMARY KEY FIRST;
Query OK, 0 rows affected (0.02 sec)
Records: 0  Duplicates: 0  Warnings: 0

注意命令末尾的FIRST。当添加新字段时,如果你想它们出现在表末尾之外的位置,你需要指定位置。可以是FIRSTAFTER <other field>。为了方便起见,表的主键是其字段中的第一个。

由于客户表已经有一个 ID 字段,我们不需要再次添加它,而是修改它。为了做到这一点,我们将使用带有MODIFY选项的ALTER TABLE命令,指定已存在字段的新的定义,如下所示:

mysql> ALTER TABLE customer
 -> MODIFY id INT UNSIGNED NOT NULL
 -> AUTO_INCREMENT PRIMARY KEY;
Query OK, 0 rows affected (0.00 sec)
Records: 0  Duplicates: 0  Warnings: 0

外键

让我们想象一下,我们需要跟踪借阅的书籍。该表应包含借阅的书籍、谁借阅了它以及何时借阅。那么,您会用什么数据来识别书籍或客户?您会使用标题还是名字?嗯,我们应该使用一些可以唯一标识这些表中某一行的东西,而这个“东西”就是主键。通过这个操作,我们将消除使用可能同时指向两个或更多行的引用的风险。

然后,我们可以创建一个包含book_idcustomer_id作为数值字段的表,包含引用这两个表的 ID。作为第一种方法,这是有意义的,但我们也可以找到一些弱点。例如,如果我们插入错误的 ID,并且它们在bookcustomer中不存在会发生什么?我们可以在我们的 PHP 端编写一些代码,确保在从borrowed_books获取信息时,我们只显示正确的信息。我们甚至可以有一个定期检查错误行并删除它们的例行程序,解决错误数据占用磁盘空间的问题。然而,就像在 MySQL 中使用Unique特性与添加主键一样,通常最好让数据库系统来管理这些事情,因为性能通常会更好,而且您不需要编写额外的代码。

MySQL 允许您创建强制引用其他表的关键字。这些被称为外键,这是我们被迫使用 InnoDB 表引擎而不是其他引擎的主要原因。外键定义并强制执行了该字段与另一个表中不同行的引用。如果为具有外键的字段提供的 ID 在引用表中不存在,查询将失败。此外,如果您有一个有效的指向现有书籍的borrowed_books行,然后您从书籍表中删除条目,MySQL 将对此提出抗议——尽管您很快就能自定义这种行为——因为这个操作会在系统中留下错误数据。正如您所注意到的,这比编写代码来管理这些情况要有用得多。

让我们创建一个包含书籍、客户引用和日期的borrowed_books表。请注意,我们必须在定义字段之后而不是在定义主键时定义外键,如下所示:

mysql> CREATE TABLE borrowed_books(
 -> book_id INT UNSIGNED NOT NULL,
 -> customer_id INT UNSIGNED NOT NULL,
 -> start DATETIME NOT NULL,
 -> end DATETIME DEFAULT NULL,
 -> FOREIGN KEY (book_id) REFERENCES book(id),
 -> FOREIGN KEY (customer_id) REFERENCES customer(id)
 -> ) ENGINE=InnoDb;
Query OK, 0 rows affected (0.00 sec)

SHOW CREATE SCHEMA一样,您还可以检查表的外观。此命令还会显示有关键的信息,而不是DESC命令。让我们看看它是如何工作的:

mysql> SHOW CREATE TABLE borrowed_books \G
*************************** 1\. row ***************************
 Table: borrowed_books
Create Table: CREATE TABLE `borrowed_books` (
 `book_id` int(10) unsigned NOT NULL,
 `customer_id` int(10) unsigned NOT NULL,
 `start` datetime NOT NULL,
 `end` datetime DEFAULT NULL,
 KEY `book_id` (`book_id`),
 KEY `customer_id` (`customer_id`),
 CONSTRAINT `borrowed_books_ibfk_1` FOREIGN KEY (`book_id`) REFERENCES `book` (`id`),
 CONSTRAINT `borrowed_books_ibfk_2` FOREIGN KEY (`customer_id`) REFERENCES `customer` (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=latin1
1 row in set (0.00 sec)

在这里要注意两个重要的事情。一方面,我们有两个额外的键我们没有定义。原因是当我们定义外键时,MySQL 也会将字段定义为将用于提高表性能的键;我们稍后会探讨这一点。另一个需要注意的元素是 MySQL 会自己为键定义名称。这是必要的,因为如果我们想更改或删除这个键,我们需要能够引用它们。你可以让 MySQL 为你命名键,或者在你创建它们时指定你喜欢的名称。

我们经营一家书店,即使我们允许顾客借阅书籍,我们也希望能够出售它们。销售是一个非常重要的元素,我们需要追踪它,因为顾客可能想要回顾它们,或者你可能只需要提供这些信息以供税务目的。与借阅不同,知道书籍、客户和日期就足够了,在这里,我们需要为销售设置 ID,以便向顾客识别它们。

然而,这个表的设计比其他表更困难,不仅仅是因为 ID。想想看:客户是一本书一本书地买书吗?还是他们更愿意一次性买任意数量的书?因此,我们需要允许表中包含未定义数量的书籍。在 PHP 中,这很简单,因为我们只需使用一个数组,但我们没有 MySQL 中的数组。这个问题有两个解决方案。

一种可能的解决方案是将销售的 ID 设置为普通整数字段,而不是主键。这样,我们就能向sales表中插入多行,每行对应一本借阅的书籍。然而,这个解决方案并不理想,因为我们错过了定义一个非常好的主键的机会,因为这个主键有sales ID。此外,我们重复了关于客户和日期的数据,因为它们总是相同的。

第二个解决方案,我们将要实施的解决方案,是创建一个作为“列表”的独立表。我们仍然会有我们的sales表,它将包含作为主键的销售 ID,作为外键的客户 ID 和日期。然而,我们将创建一个名为sale_book的第二张表,并在其中定义销售 ID、书籍 ID 和顾客购买的同一副本的书籍数量。这样,我们就可以同时拥有关于客户和日期的信息,并且我们可以在我们的sale_book列表表中插入所需的所有行,而不重复任何数据。让我们看看我们将如何创建这些表:

mysql> CREATE TABLE sale(
 -> id INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
 -> customer_id INT UNSIGNED NOT NULL,
 -> date DATETIME NOT NULL,
 -> FOREIGN KEY (customer_id) REFERENCES customer(id)
 -> ) ENGINE=InnoDb;
Query OK, 0 rows affected (0.00 sec)

mysql> CREATE TABLE sale_book(
 -> sale_id INT UNSIGNED NOT NULL,
 -> book_id INT UNSIGNED NOT NULL,
 -> amount SMALLINT UNSIGNED NOT NULL DEFAULT 1,
 -> FOREIGN KEY (sale_id) REFERENCES sale(id),
 -> FOREIGN KEY (book_id) REFERENCES book(id)
 -> ) ENGINE=InnoDb;
Query OK, 0 rows affected (0.00 sec)

请记住,你应该始终首先创建sales表,因为如果你首先创建带有外键的sale_book表,引用一个尚未存在的表,MySQL 会抱怨。

在本节中,我们创建了三个新的表,它们是相互关联的。现在是更新表图的好时机。注意,当定义外键时,我们将字段与表相链接。看看:

外键

唯一键

如你所知,主键非常实用,因为它们提供了几个功能。其中之一是字段必须是唯一的。然而,每个表只能定义一个主键,即使你可能有几个唯一的字段。为了克服这种限制,MySQL 引入了唯一键。它们的作用是确保字段在多行中不会重复,但它们不包含主键的其他功能,例如自动递增。此外,唯一键可以是空的。

我们的书籍客户表包含适合作为唯一键的候选字段。书籍可能具有相同的标题,而且肯定会有不止一本书是由同一作者所著。然而,它们还有一个独一无二的 ISBN;两本不同的书不应该有相同的 ISBN。同样,即使两个客户有相同的名字,他们的电子邮件地址也总是不同的。让我们使用ALTER TABLE命令添加这两个键,尽管你也可以在创建表时添加它们,就像我们处理外键时那样,如下所示:

mysql> ALTER TABLE book ADD UNIQUE KEY (isbn);
Query OK, 0 rows affected (0.01 sec)
Records: 0  Duplicates: 0  Warnings: 0

mysql> ALTER TABLE customer ADD UNIQUE KEY (email);
Query OK, 0 rows affected (0.01 sec)
Records: 0  Duplicates: 0  Warnings: 0

第六章:索引

索引,是键的同义词,是那些不需要像其他键那样有特殊行为的字段,但它们在我们的查询中非常重要。因此,我们将要求 MySQL 对这些字段做一些工作,以便在通过此字段进行查询时表现更好。你还记得在添加外键时 MySQL 添加了额外的键到表中吗?那些也是索引。

考虑应用程序将如何使用数据库。我们希望向我们的客户展示书籍目录,但我们肯定不能一次性展示所有书籍。客户将想要过滤结果,而最常见的一种过滤方式是指定他们正在寻找的书籍标题。从这个角度来看,我们可以得出结论,标题将被频繁用于过滤书籍,因此我们希望为这个字段添加索引。让我们通过以下代码添加索引:

mysql> ALTER TABLE book ADD INDEX (title);
Query OK, 0 rows affected (0.01 sec)
Records: 0  Duplicates: 0  Warnings: 0

记住,所有其他键也提供了索引。书籍、客户和销售、ISBN 和电子邮件的 ID 已经建立了索引,因此在这里添加另一个索引是没有必要的。此外,尽量不要为每个字段都添加索引,因为这样做会导致过度索引,这会使某些类型的查询比没有索引时还要慢!

插入数据

我们已经创建了完美的表来存储我们的数据,但到目前为止它们是空的。现在是时候填充它们了。我们推迟了这个时刻,因为当表中有数据时更改表比它们为空时更困难。

为了插入这些数据,我们将使用INSERT INTO命令。此命令将包含表名、你想要填充的字段以及每个字段的值。请注意,你可以选择不指定字段的值,这样做有不同的原因,如下所述:

  • 该字段有一个默认值,并且我们很高兴为这个特定的行使用它

  • 即使该字段没有显式的默认值,该字段也可以接受 null 值;因此,如果不指定该字段,MySQL 将自动在此插入 null 值

  • 该字段是主键,并且是自增的,我们希望让 MySQL 为我们获取下一个 ID

有不同的原因可能导致INSERT INTO命令失败:

  • 如果你没有指定字段的值,并且 MySQL 无法提供有效的默认值

  • 如果提供的值不是字段的数据类型,并且 MySQL 无法找到有效的转换

  • 如果你指定了要设置字段的值,但你未能提供值

  • 如果你提供了一个带有 ID 的外键,但该 ID 在引用的表中不存在

让我们看看如何添加行。让我们从我们的customer表开始,添加一个basic和一个premium,如下所示:

mysql> INSERT INTO customer (firstname, surname, email, type)
 -> VALUES ("Han", "Solo", "han@tatooine.com", "premium");
Query OK, 1 row affected (0.00 sec)

mysql> INSERT INTO customer (firstname, surname, email, type)
 -> VALUES ("James", "Kirk", "enter@prise", "basic");
Query OK, 1 row affected (0.00 sec)

注意,MySQL 会显示一些返回信息;在这种情况下,它显示影响了一行,即我们插入的那一行。我们没有提供 ID,所以 MySQL 只是添加了列表中的下一个 ID。由于这是我们第一次添加数据,MySQL 使用了 ID 1 和 2。

让我们尝试欺骗 MySQL 并添加另一个客户,重复我们在上一节中设置为唯一的电子邮件地址字段:

mysql> INSERT INTO customer (firstname, surname, email, type)
 -> VALUES ("Mr", "Spock", "enter@prise", "basic");
ERROR 1062 (23000): Duplicate entry 'enter@prise' for key 'email'

返回了一个带有错误代码和错误信息的错误,当然,行没有被插入。错误信息通常包含足够的信息,以便理解问题及其解决方法。如果不是这样,我们总是可以尝试使用错误代码在互联网上搜索,并注意官方文档或其他用户对此有何评论。

如果您需要向同一表中引入多行,并且它们包含相同的字段,则有一个简短的命令版本,您可以在其中指定字段,然后为每行提供值组。让我们看看在向我们的book表添加书籍时如何使用它,如下所示:

mysql> INSERT INTO book (isbn,title,author,stock,price) VALUES
 -> ("9780882339726","1984","George Orwell",12,7.50),
 -> ("9789724621081","1Q84","Haruki Murakami",9,9.75),
 -> ("9780736692427","Animal Farm","George Orwell",8,3.50),
 -> ("9780307350169","Dracula","Bram Stoker",30,10.15),
 -> ("9780753179246","19 minutes","Jodi Picoult",0,10);
Query OK, 5 rows affected (0.01 sec)
Records: 5  Duplicates: 0  Warnings: 0

与客户一样,我们不会指定 ID,让 MySQL 选择合适的 ID。注意,现在受影响的行数是5,因为我们插入了五行。

我们如何利用我们在表中定义的显式默认值?嗯,我们可以用与处理主键相同的方式来做这件事:不要在字段列表或值列表中指定它们,MySQL 将直接使用默认值。例如,我们为book.stock字段定义了一个默认值1,这对于book表和stock字段来说是一个有用的表示法。让我们使用这个默认值添加另一行,如下所示:

mysql> INSERT INTO book (isbn,title,author,price) VALUES
 -> ("9781416500360", "Odyssey", "Homer", 4.23);
Query OK, 1 row affected (0.00 sec)

现在我们有了书籍和客户,让我们添加一些关于客户借阅书籍的历史数据。为此,使用bookcustomer的数字 ID,如下面的代码所示:

mysql> INSERT INTO borrowed_books(book_id,customer_id,start,end)
 -> VALUES
 -> (1, 1, "2014-12-12", "2014-12-28"),
 -> (4, 1, "2015-01-10", "2015-01-13"),
 -> (4, 2, "2015-02-01", "2015-02-10"),
 -> (1, 2, "2015-03-12", NULL);
Query OK, 3 rows affected (0.00 sec)
Records: 3  Duplicates: 0  Warnings: 0

查询数据

花了相当多的时间,但我们终于到达了与数据库最激动人心——也是最有用——的部分:查询数据。查询数据是指要求 MySQL 从指定的表中返回行,并可选择通过一组规则过滤这些结果。您也可以选择获取特定的字段而不是整个行。为了查询数据,我们将使用SELECT命令,如下所示:

mysql> SELECT firstname, surname, type FROM customer;
+-----------+---------+---------+
| firstname | surname | type    |
+-----------+---------+---------+
| Han       | Solo    | premium |
| James     | Kirk    | basic   |
+-----------+---------+---------+
2 rows in set (0.00 sec)

查询数据的一种最简单的方法是在SELECT之后指定感兴趣的字段,并使用FROM关键字指定表。由于我们没有在查询中添加任何过滤器——通常称为条件——所以我们得到了所有这些行。有时,这可能是期望的行为,但最常见的事情是向查询中添加条件,以检索我们需要的行。使用WHERE关键字来实现这一点。

mysql> SELECT firstname, surname, type FROM customer
 -> WHERE id = 1;
+-----------+---------+---------+
| firstname | surname | type    |
+-----------+---------+---------+
| Han       | Solo    | premium |
+-----------+---------+---------+
1 row in set (0.00 sec)

添加条件与我们在 PHP 中创建布尔表达式时非常相似。我们将指定字段名、运算符和值,MySQL 将只检索返回此表达式的true的行。在这种情况下,我们要求具有 ID 1 的客户,MySQL 返回了一行:具有确切 ID 为 1 的那一行。

常见的查询需求是获取以某些文本开头的书籍。由于我们只想匹配字符串的一部分,因此不能使用您所知的任何比较运算符来构造这个表达式,例如 =<>。为此,MySQL 有一个 LIKE 运算符,它接受一个可以包含通配符的字符串。通配符是一个代表规则的字符,可以匹配符合规则后的任意数量的字符。例如,% 通配符代表任意数量的字符,因此使用 1% 字符串将匹配以 1 开头并跟随后续任意数量或字符的任何字符串,例如 19841Q84。让我们考虑以下示例:

mysql> SELECT title, author, price FROM book
 -> WHERE title LIKE "1%";
+------------+-----------------+-------+
| title      | author          | price |
+------------+-----------------+-------+
| 1984       | George Orwell   |   7.5 |
| 1Q84       | Haruki Murakami |  9.75 |
| 19 minutes | Jodi Picoult    |    10 |
+------------+-----------------+-------+
3 rows in set (0.00 sec)

我们请求了所有标题以 1 开头的书籍,并得到了三行。您可以想象这个运算符有多有用,尤其是在我们实现应用程序中的搜索工具时。

与 PHP 一样,MySQL 也允许您添加逻辑运算符——即接受操作数并执行逻辑运算的运算符,结果返回布尔值。最常用的逻辑运算符与 PHP 一样是 ANDORAND 在两个表达式都为 true 时返回 true,而 OR 在任一操作数为 true 时返回 true。让我们考虑以下示例:

mysql> SELECT title, author, price FROM book
 -> WHERE title LIKE "1%" AND stock > 0;
+------------+-----------------+-------+
| title      | author          | price |
+------------+-----------------+-------+
| 1984       | George Orwell   |   7.5 |
| 1Q84       | Haruki Murakami |  9.75 |
+------------+-----------------+-------+
2 rows in set (0.00 sec)

这个例子与上一个例子非常相似,但我们添加了一个额外的条件。我们请求了所有以 1 开头的标题以及是否有库存。这就是为什么一本书没有显示,因为它不满足这两个条件。您可以使用逻辑运算符添加所需数量的条件,但请注意 AND 运算符的优先级高于 OR。如果您想改变这个优先级,您总是可以用括号将表达式括起来,就像在 PHP 中一样。

到目前为止,我们在查询数据时检索了特定的字段,但我们可以请求给定表中的所有字段。为此,我们只需在 SELECT 中使用 * 通配符。让我们通过以下代码选择所有客户的字段:

mysql> SELECT * FROM customer \G
*************************** 1\. row ***************************
 id: 1
firstname: Han
 surname: Solo
 email: han@tatooine.com
 type: premium
*************************** 2\. row ***************************
 id: 2
firstname: James
 surname: Kirk
 email: enter@prise
 type: basic
2 rows in set (0.00 sec)

您可以检索的信息不仅仅是字段。例如,您可以使用 COUNT 来检索满足给定条件的行数,而不是检索所有列。这种方法比检索所有列然后再计数要快,因为您通过减少响应的大小来节省时间。让我们考虑一下它将如何看起来:

mysql> SELECT COUNT(*) FROM borrowed_books
 -> WHERE customer_id = 1 AND end IS NOT NULL;
+----------+
| COUNT(*) |
+----------+
|        1 |
+----------+
1 row in set (0.00 sec)

正如您所注意到的,响应显示 1,这意味着只有一个借阅的书籍满足条件。然而,检查一下条件;您会注意到我们使用了另一个熟悉的逻辑运算符:NOTNOT 取反表达式,就像 PHP 中的 ! 一样。请注意,我们不用等号来与空值比较。在 MySQL 中,您必须使用 IS 而不是等号来与 NULL 进行比较。因此,第二个条件会在借阅的书籍有一个非空的结束日期时得到满足。

让我们通过在查询数据时添加两个更多功能来完成这个部分。第一个功能是能够指定返回行的顺序。要做到这一点,只需使用关键字ORDER BY后跟你想排序的字段名称。你也可以指定是否想要以升序排序,这是默认的,或者以降序排序,可以通过附加DESC来实现。另一个功能是使用LIMIT和要检索的行数来限制返回的行数。现在,运行以下代码:

mysql> SELECT id, title, author, isbn FROM book
 -> ORDER BY title LIMIT 4;
+----+-------------+-----------------+---------------+
| id | title       | author          | isbn          |
+----+-------------+-----------------+---------------+
|  5 | 19 minutes  | Jodi Picoult    | 9780753179246 |
|  1 | 1984        | George Orwell   | 9780882339726 |
|  2 | 1Q84        | Haruki Murakami | 9789724621081 |
|  3 | Animal Farm | George Orwell   | 9780736692427 |
+----+-------------+-----------------+---------------+
4 rows in set (0.00 sec)

使用 PDO

到目前为止,我们已经与 MySQL 一起工作过,你对它能做什么已经有了一个很好的了解。然而,手动连接到客户端并执行查询并不是我们的目标。我们想要实现的是,我们的应用程序可以自动利用数据库。为了做到这一点,我们将使用一组 PHP 附带类,允许你从代码中连接到数据库并执行查询。

PHP 数据对象PDO)是连接到数据库并允许你与之交互的类。这是 PHP 开发者处理数据库的流行方式,尽管还有其他方式,我们在这里不会讨论。PDO 允许你与不同的数据库系统一起工作,所以你不仅限于 MySQL。在接下来的章节中,我们将考虑如何使用这个类连接到数据库、插入数据以及检索数据。

连接到数据库

为了连接到数据库,将凭证(即用户名和密码)与代码分开存储在配置文件中是一个好习惯。我们已经有这个文件,即config/app.json,因为我们之前与Config类一起工作过。让我们为我们的数据库添加正确的凭证。如果你有默认的配置,配置文件应该看起来像这样:

{
  "db": {
    "user": "root",
    "password": ""
  }
}

开发者通常会指定与连接相关的其他信息,例如主机、端口或数据库名称。这取决于你的应用程序是如何安装的,MySQL 是否运行在不同的服务器上,等等,至于你希望在代码和配置文件中保留多少信息,这取决于你。

为了连接到数据库,我们需要从PDO类实例化一个对象。这个类的构造函数期望三个参数:数据源名称DSN),它是一个表示要使用哪种数据库的字符串;用户的名称;以及密码。我们已经从Config类中获得了用户名和密码,但我们仍然需要构建 DSN。

MySQL 数据库的一种格式是<database type>:host=<host>;dbname=<schema name>。由于我们的数据库系统是 MySQL,它运行在同一个服务器上,模式名称是bookstore,DSN 将是mysql:host=127.0.0.1;dbname=bookstore。让我们看看我们将如何将所有这些放在一起:

$dbConfig = Config::getInstance()->get('db');
$db = new PDO(
 'mysql:host=127.0.0.1;dbname=bookstore',
 $dbConfig['user'],
 $dbConfig['password']
);
$db->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC);

注意,我们还将从PDO实例调用setAttribute方法。此方法允许你设置一些选项到连接;在这种情况下,它设置了从 MySQL 返回的结果的格式。此选项强制 MySQL 返回键为字段名的数组,这比默认的基于字段顺序返回数字键的方式更有用。设置此选项现在将影响使用$db实例执行的所有查询,而不是每次执行查询时设置选项。

执行查询

从你的数据库中检索数据的最简单方法是使用query方法。此方法接受查询作为字符串,并返回一个作为数组的行列表。让我们考虑一个例子:在数据库连接初始化之后写入以下内容——例如,在init.php文件中:

$rows = $db->query('SELECT * FROM book ORDER BY title');
foreach ($rows as $row) {
    var_dump($row);
}

这个查询试图获取数据库中的所有书籍,并按标题排序。这可能是getAllBooks函数的内容,该函数在我们显示目录时使用。每一行都是一个数组,包含所有字段作为键和数据作为值。

如果你将应用程序运行在你的浏览器上,你将得到以下结果:

执行查询

当我们想要检索数据时,query函数很有用,但为了执行插入行的查询,PDO 提供了exec函数。此函数也期望第一个参数为字符串,定义要执行的查询,但它返回一个布尔值,指定执行是否成功。一个很好的例子是尝试插入书籍。输入以下内容:

$query = <<<SQL
INSERT INTO book (isbn, title, author, price)
VALUES ("9788187981954", "Peter Pan", "J. M. Barrie", 2.34)
SQL;
$result = $db->exec($query);
var_dump($result); // true

这段代码还使用了一种新的字符串表示方法:heredoc。我们将字符串放在<<<SQLSQL;之间,这两者都在不同的行上,而不是用引号。这种方法的优点是能够用制表符或任何其他空白字符在多行中编写字符串,而 PHP 会尊重这一点。我们可以构建易于阅读的查询,而不是将它们写在单行上或者需要连接不同的字符串。请注意,SQL是一个表示字符串开始和结束的标记,但你也可以使用你认为是的任何文本。

第一次使用此代码运行应用程序时,查询将成功执行,因此,结果将是布尔值true。然而,如果你再次运行它,它将返回false,因为我们插入的 ISBN 相同,但我们将其限制设置为唯一。

了解查询失败是有用的,但如果我们知道原因会更好。PDO实例有一个errorInfo方法,它返回一个包含最后错误信息的数组。键2包含描述,所以我们可能更经常使用它。用以下代码更新之前的代码:

$query = <<<SQL
INSERT INTO book (isbn, title, author, price)
VALUES ("9788187981954", "Peter Pan", "J. M. Barrie", 2.34)
SQL;
$result = $db->exec($query); 
var_dump($result); // false
$error = $db->errorInfo()[2];
var_dump($error); // Duplicate entry '9788187981954' for key 'isbn'

结果是查询失败,因为 ISBN 条目重复。现在,我们可以为我们的客户或仅用于调试目的构建更有意义的错误消息。

预处理语句

前两个函数在你需要运行总是相同的快速查询时非常有用。然而,在第二个例子中,你可能注意到查询字符串并不很有用,因为它总是插入相同的书籍。虽然确实可以通过变量替换这些值,但这不是好的做法,因为这些变量通常来自用户端,可能包含恶意代码。总是先清理这些值会更好。

PDO 提供了准备语句的能力——即参数化的查询。你可以指定将改变查询的字段参数,然后为这些参数分配值。让我们首先考虑以下示例:

$query = 'SELECT * FROM book WHERE author = :author';
$statement = $db->prepare($query);
$statement->bindValue('author', 'George Orwell');
$statement->execute();
$rows = $statement->fetchAll();
var_dump($rows);

查询本身是一个普通的查询,只是它使用:author而不是我们想要查找的作者字符串。这是一个参数,我们将使用前缀:来识别它们。prepare方法将查询作为参数接收并返回一个PDOStatement实例。这个类包含多个方法来绑定值、执行语句、获取结果等。在这段代码中,我们只使用了其中三个,如下所示:

  • bindValue:它接受两个参数:查询中描述的参数名称和要分配的值。如果你提供了一个不在查询中的参数名称,这将抛出异常。

  • execute:这将发送查询到 MySQL,并用提供的值替换参数。如果有任何参数没有分配值,该方法将抛出异常。与它的兄弟exec一样,execute将返回一个布尔值,指定查询是否成功执行。

  • fetchAll:如果这是一个SELECT查询,这将从 MySQL 检索数据。作为一个查询,fetchAll将返回所有行的数组列表。

如果你尝试这段代码,你会注意到结果与使用query时非常相似;然而,这次代码要动态得多,因为你可以为任何需要的作者重用它。

准备好的语句

除了使用bindValue方法外,还有另一种方法可以将值绑定到查询的参数。你可以准备一个数组,其中键是参数的名称,值是你想要分配给它的值,然后你可以将其作为execute方法的第一个参数发送。这种方法非常有用,因为你通常已经准备好了这个数组,不需要多次调用bindValue及其内容。添加以下代码以进行测试:

$query = <<<SQL
INSERT INTO book (isbn, title, author, price)
VALUES (:isbn, :title, :author, :price)
SQL;
$statement = $db->prepare($query);
$params = [
    'isbn' => '9781412108614',
    'title' => 'Iliad',
    'author' => 'Homer',
    'price' => 9.25
];
$statement->execute($params);
echo $db->lastInsertId(); // 8

在这个最后的例子中,我们创建了一本几乎包含所有参数的新书,但我们没有指定 ID,这是期望的行为,因为我们希望 MySQL 为我们选择一个有效的 ID。然而,如果你想知道插入行的 ID 会发生什么?好吧,你可以查询具有相同 ISBN 的 MySQL 中的书籍,返回的行将包含 ID,但这似乎很麻烦。相反,PDO 有一个 lastInsertId 方法,它返回由主键插入的最后一个 ID,这样我们就避免了额外的查询。

连接表

尽管查询 MySQL 很快,尤其是在它与我们的 PHP 应用程序在同一服务器上时,我们仍然应该尝试减少将要执行的查询数量,以提高我们应用程序的性能。到目前为止,我们只从一个表中查询数据,但这很少是情况。想象一下,你想检索关于借阅书籍的信息:该表只包含 ID 和日期,所以如果你查询它,你不会得到非常有意义的数据,对吧?一种方法是对 borrowed_books 中的数据进行查询,并根据返回的 ID,通过过滤我们感兴趣的 ID 来查询 bookcustomer 表。然而,这种方法至少需要向 MySQL 发出三个查询,并在 PHP 中进行大量的数组操作。这似乎应该有更好的选择!

在 SQL 中,你可以执行 连接查询。连接查询是一种通过公共字段连接两个或更多表,从而允许你从这些表中检索数据,减少所需查询数量的查询。当然,连接查询的性能不如普通查询,但如果你有正确的键和关系定义,这个选项比单独查询要好得多。

为了连接表,你需要使用公共字段将它们链接起来。外键在这个问题中非常有用,因为你知道这两个字段是相同的。让我们看看我们如何查询与借阅书籍相关的所有重要信息:

mysql> SELECT CONCAT(c.firstname, ' ', c.surname) AS name,
 ->     b.title,
 ->     b.author,
 ->     DATE_FORMAT(bb.start, '%d-%m-%y') AS start,
 ->     DATE_FORMAT(bb.end, '%d-%m-%y') AS end
 -> FROM borrowed_books bb
 ->     LEFT JOIN customer c ON bb.customer_id = c.id
 ->     LEFT JOIN book b ON b.id = bb.book_id
 -> WHERE bb.start >= "2015-01-01";
+------------+---------+---------------+----------+----------+
| name       | title   | author        | start    | end      |
+------------+---------+---------------+----------+----------+
| Han Solo   | Dracula | Bram Stoker   | 10-01-15 | 13-01-15 |
| James Kirk | Dracula | Bram Stoker   | 01-02-15 | 10-02-15 |
| James Kirk | 1984    | George Orwell | 12-03-15 | NULL     |
+------------+---------+---------------+----------+----------+
3 rows in set (0.00 sec)

在这个最后的查询中引入了几个新概念。特别是当我们进行连接查询时,因为我们连接了不同表的字段,可能会出现两个表有相同字段名的情况,MySQL 需要我们区分它们。我们将通过在字段名前添加表名来区分两个不同表的两个字段。想象一下,如果我们想区分客户 ID 和书籍 ID,我们应该使用 customer.idbook.id。然而,每次都写表名会让我们的查询变得冗长。

MySQL 具有通过在表的真正名称旁边写入别名来向表添加别名的功能,就像我们在 borrowed_books (bb)、customer (c) 或 book (b) 中所做的那样。一旦添加了别名,您就可以使用它来引用此表,允许我们编写类似 bb.customer_id 而不是 borrowed_books.customer_id 的内容。即使字段在其他任何地方都没有重复,将表作为字段写入也是良好的实践,因为连接表会使知道每个字段来自哪里变得有些混乱。

在连接表时,您需要在 FROM 子句中使用 LEFT JOIN,然后是表名、可选的别名以及连接两个表的字段。有不同的连接类型,但让我们关注对我们最有用的类型。左连接将第一个表(定义中的左侧表)的每一行取出来,并在右侧表中搜索等效字段。一旦找到,它将像是一个整体一样连接这两行。例如,当将 borrowed_bookscustomer 连接时,对于每一行 borrowed_books,MySQL 将在 customer 中搜索一个与当前 customer_id 匹配的 ID,然后它将像它们是一个大表一样将此行的所有信息添加到我们的当前行 borrowed_books 中。由于 customer_id 是外键,我们可以确定总会有一个客户与之匹配。

您可以连接多个表,MySQL 将从左到右解决它们;也就是说,它将首先将前两个表作为一个整体连接起来,然后尝试将这个结果与第三个表连接起来,依此类推。这正是我们在示例中所做的:我们首先将 borrowed_bookscustomer 连接起来,然后将这两个连接起来。

正如您所注意到的,字段也有别名。有时,我们不仅仅是获取一个字段;一个例子是我们如何使用 COUNT(*) 来获取查询匹配的行数。然而,当检索此信息时,列的标题也是 COUNT(*),这并不总是有用的。在其他时候,我们使用了具有冲突字段名的两个表,这会使一切变得混乱。当这种情况发生时,只需像我们对表名所做的那样给字段添加别名即可;AS 是可选的,但它有助于理解您正在做什么。

让我们现在转到查询中日期的使用。一方面,我们将首次使用 DATE_FORMAT。它接受日期/时间/日期时间值和带有格式的字符串。在这种情况下,我们使用了 %d-%m-%y,这意味着日-月-年,但我们也可以使用 %h-%i-%s 来指定小时-分钟-秒或任何其他组合。

还要注意我们在 WHERE 子句中比较日期的方式。对于相同类型的两个日期或时间值,您可以使用比较运算符,就像它们是数字一样。在这种情况下,我们将执行 bb.start >= "2015-01-01",这将给我们从 2015 年 1 月 1 日起借阅的书籍。

关于这个复杂查询的最后一件事是CONCAT函数的使用。我们不想返回两个字段,一个用于名字,一个用于姓氏,我们想要得到全名。为此,我们将使用这个函数连接字段,将我们想要的字符串作为函数的参数发送,并得到连接后的字符串。正如你所看到的,你可以发送字段和用单引号括起来的字符串。

好吧,如果你完全理解了这个查询,你应该对自己感到满意;这是我们本章中将要看到的最为复杂的查询。我们希望你能感受到数据库系统是多么强大,并且从现在开始,你将尽可能在数据库端而不是 PHP 端处理数据。如果你设置了正确的索引,它将表现得更好。

分组查询

我们将要讨论的查询的最后一个特性是GROUP BY子句。这个子句允许您使用一个公共字段将同一表的行分组。例如,假设我们只想通过一个查询知道每位作者有多少本书。尝试以下操作:

mysql> SELECT
 -> author,
 -> COUNT(*) AS amount,
 -> GROUP_CONCAT(title SEPARATOR ', ') AS titles
 -> FROM book
 -> GROUP BY author
 -> ORDER BY amount DESC, author;
+-----------------+--------+-------------------+
| author          | amount | titles            |
+-----------------+--------+-------------------+
| George Orwell   |      2 | 1984, Animal Farm |
| Homer           |      2 | Odyssey, Iliad    |
| Bram Stoker     |      1 | Dracula           |
| Haruki Murakami |      1 | 1Q84              |
| J. M. Barrie    |      1 | Peter Pan         |
| Jodi Picoult    |      1 | 19 minutes        |
+-----------------+--------+-------------------+
5 rows in set (0.00 sec)

GROUP BY子句,始终在WHERE子句之后,获取一个字段——或者多个,用逗号分隔——并将具有该字段相同值的所有行视为一个整体。因此,按作者选择将分组包含相同作者的所有行。这个特性可能看起来不太有用,但 MySQL 中有几个函数可以利用它。在这个例子中:

  • COUNT(*)在带有GROUP BY的查询中使用,显示该字段分组了多少行。在这种情况下,我们将用它来知道每位作者有多少本书。实际上,它总是这样工作的;然而,对于没有GROUP BY的查询,MySQL 将整个行集视为一个组。

  • GROUP_CONCAT与我们在前面讨论的CONCAT类似。唯一的区别是这次函数将连接一个组中所有行的字段。如果您没有指定SEPARATOR,MySQL 将使用单个逗号。然而,在我们的情况下,我们需要一个逗号和一个空格来使其可读,所以我们添加了SEPARATOR ', '在末尾。请注意,您可以在CONCAT中添加您需要的任何要连接的内容,分隔符将仅按行分隔连接。

即使这不是关于分组的,请注意我们添加的ORDER子句。我们按两个字段而不是一个字段排序。这意味着 MySQL 将按amount字段对所有行进行排序;请注意,这是一个别名,但您也可以在这里使用它。然后,MySQL 将按title字段对具有相同amount值的每一组行进行排序。

在我们已经介绍了SELECT查询可以包含的所有重要子句之后,还有一件事需要记住:MySQL 期望查询的子句始终以相同的顺序排列。如果你写了一个相同的查询但改变了这个顺序,你会得到一个错误。顺序如下:

  1. SELECT

  2. FROM

  3. WHERE

  4. GROUP BY

  5. ORDER BY

更新和删除数据

我们已经对插入和检索数据有了相当多的了解,但如果应用程序只能做到这一点,它们将非常静态。根据我们的需要编辑这些数据使应用程序变得动态,并给用户带来价值。在 MySQL 以及大多数数据库系统中,你有两个命令来更改数据:UPDATEDELETE。让我们详细讨论它们。

更新数据

在 MySQL 中更新数据时,最重要的是有一个你想要更新的行的唯一引用。为此,主键非常有用;然而,如果你有一个没有主键的表,这在大多数情况下不应该发生,你仍然可以根据其他字段更新行。除了引用之外,你还需要新值,当然还有要更新的表名和字段。让我们看看一个非常简单的例子:

mysql> UPDATE book SET price = 12.75 WHERE id = 2;
Query OK, 1 row affected (0.00 sec)
Rows matched: 1  Changed: 1  Warnings: 0

在这个 UPDATE 查询中,我们将 ID 为 2 的书的售价设置为 12.75SET 子句不需要指定仅一个更改;你可以在用逗号分隔的情况下指定同一行的多个更改——例如,SET price = 12.75, stock = 14。此外,请注意 WHERE 子句,其中我们指定了要更改的行。MySQL 根据这些条件获取此表的所有行,就像它是一个 SELECT 查询一样,并将更改应用于这些行集。

MySQL 将返回的内容非常重要:匹配的行数和更改的行数。第一个是匹配 WHERE 子句条件的行数。第二个指定了可以更改的行数。有不同原因不更改一行——例如,当行已经具有相同的值时。为了看到这一点,让我们再次运行相同的查询:

mysql> UPDATE book SET price = 12.75 WHERE id = 2;
Query OK, 0 rows affected (0.00 sec)
Rows matched: 1  Changed: 0  Warnings: 0

现在相同的行显示有 1 行匹配,这是预期的,但 0 行被更改。原因是我们已经将这本书的价格设置为 12.75,所以 MySQL 现在不需要对此做任何事情。

如前所述,WHERE 子句是此查询中最重要的部分。很多时候,我们发现一些开发者运行先验无辜的 UPDATE 查询,结果却因为遗漏了 WHERE 子句而改变了整个表;因此,MySQL 会将整个表匹配为有效的更新行。这通常不是开发者的意图,而且这种情况并不愉快,所以请务必确保你总是提供一组有效的条件。首先写下返回你需要编辑的行的 SELECT 查询是一个好习惯,一旦你确定条件与所需的行集匹配,你就可以编写 UPDATE 查询。

然而,有时影响多行是预期的场景。想象一下,我们正经历艰难时期,需要提高我们所有书籍的价格。我们决定将价格提高 16%,即当前价格的 1.16 倍。我们可以运行以下查询来执行这些更改:

mysql> UPDATE book SET price = price * 1.16;
Query OK, 8 rows affected (0.00 sec)
Rows matched: 8  Changed: 8  Warnings: 0

这个查询不包含任何WHERE子句,因为我们想要匹配所有的书籍。此外,请注意,SET子句使用price字段来获取当前的价格值,这是完全有效的。最后,请注意匹配并更改的行数,这是8——这个表的整个行集。

为了完成这个子节,让我们考虑如何使用 PHP 通过 PDO 执行UPDATE查询。一个非常常见的场景是我们想要将现有书籍的副本添加到我们的库存中。给定一个书籍 ID 和一个可选的书籍数量——默认情况下,这个值将是 1——我们将通过这些副本增加这本书的库存值。在init.php文件中编写这个函数:

function addBook(int $id, int $amount = 1): void {
    $db = new PDO(
        'mysql:host=127.0.0.1;dbname=bookstore',
        'root',
        ''
    );

    $query = 'UPDATE book SET stock = stock + :n WHERE id = :id';
    $statement = $db->prepare($query);
    $statement->bindValue('id', $id);
    $statement->bindValue('n', $amount);

    if (!$statement->execute()) {
        throw new Exception($statement->errorInfo()[2]);
    }
}

有两个参数:$id$amount。第一个始终是必需的,而第二个可以省略,默认值为 1。函数首先准备一个类似于本节第一个查询的查询,其中我们增加了指定书籍的库存量,然后绑定这两个参数到语句中,并最终执行查询。如果发生某些情况并且execute返回false,我们将抛出一个包含 MySQL 错误消息内容的异常。

这个函数在我们购买更多股票或顾客归还书籍时非常有用。我们甚至可以通过给$amount提供一个负值来用它来移除书籍,但这是一种非常不好的做法。原因是即使我们强制股票字段为无符号,将其设置为负值也不会触发任何错误,只会显示警告。MySQL 不会将行设置为负值,但execute调用将返回true,而我们却不会知道。更好的做法是创建一个名为removeBook的第二个方法,并首先验证要移除的书籍数量是否低于或等于当前库存。

外键行为

在更新或删除行时需要管理的一个棘手问题是当我们更新的行是其他地方外键的一部分。例如,我们的borrowed_books表包含客户和书籍的 ID,正如你所知道的那样,MySQL 强制这些 ID 始终有效并且存在于这些相应的表中。那么,如果我们更改book表上书籍本身的 ID 会发生什么?或者更糟糕的是,如果我们从book中移除了一本书,并且borrowed_books表中有一行引用了这个 ID,会发生什么?

MySQL 允许你设置当这些场景之一发生时的期望反应。这必须在添加外键时定义;因此,在我们的情况下,我们首先需要删除现有的外键,然后再次添加。要删除或丢弃一个键,你需要知道这个键的名称,我们可以使用SHOW CREATE TABLE命令找到它,如下所示:

mysql> SHOW CREATE TABLE borrowed_books \G
*************************** 1\. row ***************************
 Table: borrowed_books
Create Table: CREATE TABLE `borrowed_books` (
 `book_id` int(10) unsigned NOT NULL,
 `customer_id` int(10) unsigned NOT NULL,
 `start` datetime NOT NULL,
 `end` datetime DEFAULT NULL,
 KEY `book_id` (`book_id`),
 KEY `customer_id` (`customer_id`),
 CONSTRAINT `borrowed_books_ibfk_1` FOREIGN KEY (`book_id`) REFERENCES `book` (`id`),
 CONSTRAINT `borrowed_books_ibfk_2` FOREIGN KEY (`customer_id`) REFERENCES `customer` (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=latin1
1 row in set (0.00 sec)

我们想要删除的两个外键是borrowed_books_ibfk_1borrowed_books_ibfk_2。让我们使用ALTER TABLE命令来移除它们,就像我们之前做的那样:

mysql> ALTER TABLE borrowed_books
 -> DROP FOREIGN KEY borrowed_books_ibfk_1;
Query OK, 4 rows affected (0.02 sec)
Records: 4  Duplicates: 0  Warnings: 0

mysql> ALTER TABLE borrowed_books
 -> DROP FOREIGN KEY borrowed_books_ibfk_2;
Query OK, 4 rows affected (0.01 sec)
Records: 4  Duplicates: 0  Warnings: 0

现在,我们需要再次添加外键。命令的格式将与添加时相同,但需要附加新的期望行为。在我们的情况下,如果我们从表中删除客户或书籍,我们希望从 borrowed_books 中删除引用这些书籍和客户的行;因此,我们需要使用 CASCADE 选项。让我们考虑它们会是什么样子:

mysql> ALTER TABLE borrowed_books
 -> ADD FOREIGN KEY (book_id) REFERENCES book (id)
 -> ON DELETE CASCADE ON UPDATE CASCADE,
 -> ADD FOREIGN KEY (customer_id) REFERENCES customer (id)
 -> ON DELETE CASCADE ON UPDATE CASCADE;
Query OK, 4 rows affected (0.01 sec)
Records: 4  Duplicates: 0  Warnings: 0

注意,我们可以为这两种操作定义 CASCADE 行为:在更新和删除行时。除了 CASCADE 之外,还有其他选项——例如 SET NULL,它将外键列设置为 NULL,允许删除原始行,或者默认的 RESTRICT,它拒绝更新/删除命令。

删除数据

删除数据几乎与更新相同。你需要提供一个 WHERE 子句来匹配你想要删除的行。同样,就像更新数据时一样,强烈建议在执行 DELETE 命令之前,首先构建一个 SELECT 查询来检索你想要删除的行。不要认为你在这种方法上浪费时间;俗话说,量两次,切一次。删除行后,并不总是可能恢复数据!

让我们尝试通过观察我们之前设置的 CASCADE 选项的行为来删除一本书。为此,我们首先通过以下查询获取现有的借阅书籍列表:

mysql> SELECT book_id, customer_id FROM borrowed_books;
+---------+-------------+
| book_id | customer_id |
+---------+-------------+
|       1 |           1 |
|       4 |           1 |
|       4 |           2 |
|       1 |           2 |
+---------+-------------+
4 rows in set (0.00 sec)

有两本不同的书,14,每本书都被借阅了两次。让我们尝试删除 ID 为 4 的书籍。首先,构建一个查询,例如 SELECT * FROM book WHERE id = 4,以确保 WHERE 子句中的条件是适当的。一旦你确定,执行以下查询:

mysql> DELETE FROM book WHERE id = 4;
Query OK, 1 row affected (0.02 sec)

正如你所注意到的,我们只指定了 DELETE FROM 命令,后面跟着表名和 WHERE 子句。MySQL 告诉我们影响了 1 行,考虑到我们之前执行的 SELECT 语句,这是有道理的。

如果我们回到我们的 borrowed_books 表并查询现有的记录,我们会注意到引用 ID 为 4 的书籍的所有行都消失了。这是因为当我们从 book 表中删除它们时,MySQL 注意到了外键引用,检查了在删除时需要做什么——在这种情况下,CASCADE——然后也删除了 borrowed_books 中的行。看看下面的例子:

mysql> SELECT book_id, customer_id FROM borrowed_books;
+---------+-------------+
| book_id | customer_id |
+---------+-------------+
|       1 |           1 |
|       1 |           2 |
+---------+-------------+
2 rows in set (0.00 sec)

使用事务

在前面的部分中,我们再次强调了确保更新或删除查询包含期望的匹配行集的重要性。尽管这始终适用,但有一种方法可以撤销你刚刚所做的更改,那就是使用 事务

事务是一个状态,MySQL 跟踪你在数据中做出的所有更改,以便在需要时能够撤销所有更改。你需要显式地开始一个事务,并且在关闭与服务器的连接之前,你需要提交你的更改。这意味着 MySQL 不会真正执行这些更改,直到你告诉它这样做。如果在事务期间你想撤销更改,你应该回滚而不是提交。

PDO 允许你通过三个函数来完成这个操作:

  • beginTransaction: 这将开始事务。

  • commit: 这将提交你的更改。请注意,如果你没有提交并且 PHP 脚本结束或者你显式地关闭了连接,MySQL 将会拒绝你在这次事务中做出的所有更改。

  • rollBack: 这将回滚在这次事务中做出的所有更改。

在你的应用程序中,事务的一个可能用途是在你需要执行多个查询,并且所有这些查询都必须成功,否则整个查询集不应该被执行的情况下。这将是将销售添加到数据库的情况。记住,我们的销售存储在两个表中:一个用于销售本身,另一个用于与这次销售相关的书籍列表。当你添加一个新的时,你需要确保所有书籍都添加到这个数据库中;否则,销售将被破坏。你应该执行所有查询,检查它们的返回值。如果任何一个返回 false,整个销售应该被回滚。

让我们在 init.php 文件中创建一个 addSale 函数来模拟这种行为。内容应该如下所示:

function addSale(int $userId, array $bookIds): void {
    $db = new PDO(
        'mysql:host=127.0.0.1;dbname=bookstore',
        'root',
        ''
    );

 $db->beginTransaction();
    try {
        $query = 'INSERT INTO sale (customer_id, date) '
            . 'VALUES(:id, NOW())';
        $statement = $db->prepare($query);
        if (!$statement->execute(['id' => $userId])) {
            throw new Exception($statement->errorInfo()[2]);
        }
        $saleId = $db->lastInsertId();

        $query = 'INSERT INTO sale_book (book_id, sale_id) '
            . 'VALUES(:book, :sale)';
        $statement = $db->prepare($query);
        $statement->bindValue('sale', $saleId);
        foreach ($bookIds as $bookId) {
            $statement->bindValue('book', $bookId);
            if (!$statement->execute()) {
                throw new Exception($statement->errorInfo()[2]);
            }
        }

 $db->commit();
    } catch (Exception $e) {
 $db->rollBack();
        throw $e;
    }
}

这个函数相当复杂。它接受客户 ID 和书籍列表作为参数,因为我们假设销售日期是当前日期。我们首先要做的事情是连接到数据库,实例化 PDO 类。紧接着,我们将开始事务,这个事务只在这个函数的执行过程中持续。一旦开始事务,我们将打开一个 try…catch 块,它将包含函数的其余代码。原因是如果我们抛出一个异常,catch 块将捕获它,回滚事务并传播异常。try 块内的代码只是首先添加销售,然后迭代书籍列表,并将它们也插入到数据库中。在所有时候,我们都会检查 execute 函数的响应,如果它是 false,我们将抛出一个包含错误信息的异常。

让我们尝试使用这个函数。编写以下代码尝试为三本书添加销售;然而,其中一本不存在,就是 ID 为 200 的那本:

try {
    addSale(1, [1, 2, 200]);
} catch (Exception $e) {
    echo 'Error adding sale: ' . $e->getMessage();
}

这段代码将输出错误信息,抱怨不存在这本书。如果你在 MySQL 中检查,sales 表中将没有行,因为当抛出异常时函数已经回滚。

最后,让我们尝试以下代码。这个代码将添加三本有效的书籍,以确保查询总是成功的,并且try块可以一直执行到末尾,在那里我们将提交更改:

try {
    addSale(1, [1, 2, 3]);
} catch (Exception $e) {
    echo 'Error adding sale: ' . $e->getMessage();
}

测试一下,你就会看到在你的浏览器上没有打印出任何消息。然后,前往你的数据库以确保有一个新的sales行,并且有三本书与之关联。

摘要

在本章中,我们学习了数据库的重要性以及如何在我们的 Web 应用程序中使用它们:从使用 PDO 设置连接到按需创建和获取数据,再到构建满足我们需求的更复杂的查询。有了所有这些,我们的应用程序现在看起来比完全静态时更有用得多。

在下一章中,我们将发现如何通过模型-视图-控制器MVC)应用 Web 应用程序最重要的设计模式。当你以这种方式组织应用程序时,你的代码将变得更加清晰。

第六章。适应 MVC

Web 应用程序比我们迄今为止构建的更复杂。您添加的功能越多,代码的维护和理解就越困难。这就是为什么以有组织的方式组织代码至关重要。您可以设计自己的结构,但就像面向对象编程一样,已经存在一些设计模式试图解决这个问题。

MVC模型-视图-控制器)一直是 Web 开发者的最爱模式。它帮助我们分离 Web 应用程序的不同部分,即使对于初学者来说,代码也易于理解。我们将尝试重构我们的书店示例以使用 MVC 模式,您将意识到在那之后您可以多么快速地添加新功能。

在本章中,您将学习以下内容:

  • 使用 Composer 管理依赖关系

  • 为您的应用程序设计路由器

  • 将您的代码组织成模型、视图和控制器

  • 使用 Twig 作为模板引擎

  • 依赖注入

MVC 模式

到目前为止,每次我们添加新功能时,我们都会为该特定页面添加一个包含 PHP 和 HTML 混合的新 PHP 文件。对于具有单一目的的代码块,并且我们必须重复使用,我们创建了函数并将它们添加到函数文件中。即使是像我们这样非常小的 Web 应用程序,代码也开始变得非常混乱,代码的重用能力并不像它本可以那样有帮助。现在想象一个具有大量功能的程序:那几乎就是混乱本身。

问题并没有在这里停止。在我们的代码中,我们在一个文件中混合了 HTML 和 PHP 代码。当我们试图更改 Web 应用程序的设计,或者即使我们想要在所有页面上进行非常小的更改,例如更改页面的菜单或页脚时,这会给我们带来很多麻烦。应用程序越复杂,我们遇到的问题就越多。

MVC 作为一个模式出现,帮助我们划分应用程序的不同部分。这些部分被称为模型、视图和控制器。模型管理数据和/或业务逻辑,视图包含我们的响应模板(例如,HTML 页面),而控制器协调请求,决定使用哪些数据以及如何渲染适当的模板。我们将在本章的后续部分中详细介绍它们。

使用 Composer

尽管在实现 MVC 模式时这不是一个必要的组件,但 Composer 在过去几年中一直是任何 PHP Web 应用程序不可或缺的工具。这个工具的主要目标是帮助您管理应用程序的依赖关系,即我们应用中需要使用的第三方库(代码)。我们可以通过创建一个列出它们的配置文件,并在您的命令行中运行一个命令来实现这一点。

您需要在您的开发机器上安装 Composer(见第一章,设置环境)。请确保您已执行以下命令:

$ composer –version

这应该会返回您 Composer 安装的版本。如果它没有这样做,请返回安装部分以解决问题。

管理依赖项

如我们之前所述,Composer 的主要目标是管理依赖项。例如,我们已经实现了我们的配置读取器,即Config类,但如果我们知道有人实现了更好的版本,我们就可以直接使用他们的版本而不是重新发明轮子;只需确保他们允许这样做即可!

注意

开源

开源指的是开发者编写的并与社区共享的代码,以便其他人可以无限制地使用。实际上存在不同类型的许可证,有些比其他提供更大的灵活性,但基本思想是我们可以在我们的应用程序中重用其他开发者编写的库。这有助于社区在知识上的增长,因为我们可以从他人的工作中学习,改进它,并在之后分享它。

我们已经实现了一个不错的配置读取器,但我们的应用程序中还有其他元素需要完成。让我们利用 Composer 来重用他人的库。向我们的项目添加依赖项有几种方法:在我们的命令行中执行命令,或手动编辑配置文件。由于我们还没有 Composer 的配置文件,让我们使用第一种方法。在您的应用程序根目录中执行以下命令:

$ composer require monolog/monolog

此命令将显示以下结果:

Using version ¹.17 for monolog/monolog
./composer.json has been created
Loading composer repositories with package information
Updating dependencies (including require-dev)
 - Installing psr/log (1.0.0)
 Downloading: 100%

 - Installing monolog/monolog (1.17.2)
 Downloading: 100%
...
Writing lock file
Generating autoload files

使用此命令,我们要求 Composer 将库monolog/monolog添加为我们的应用程序的依赖项。执行后,我们现在可以看到目录中的一些变化:

  • 我们有一个名为composer.json的新文件。这是配置文件,我们可以在此添加我们的依赖项。

  • 我们有一个名为composer.lock的新文件。这是一个 Composer 用来跟踪已安装的依赖项及其版本的文件。

  • 我们有一个名为vendor的新目录。这个目录包含 Composer 下载的依赖项代码。

命令的输出还显示了一些额外信息。在这种情况下,它说它下载了两个库或包,尽管我们只请求了一个。原因是所需的包也包含其他依赖项,这些依赖项由 Composer 解决。请注意 Composer 下载的版本;由于我们没有指定任何版本,Composer 选择了可用的最新版本,但您始终可以尝试写入您需要的特定版本。

我们需要另一个库,在这种情况下是twig/twig。让我们使用以下命令将其添加到我们的依赖项列表中:

$ composer require twig/twig

此命令将显示以下结果:

Using version ¹.23 for twig/twig
./composer.json has been updated
Loading composer repositories with package information
Updating dependencies (including require-dev)
 - Installing twig/twig (v1.23.1)
 Downloading: 100%

Writing lock file
Generating autoload files

如果我们检查composer.json文件,我们将看到以下内容:

{
    "require": {
        "monolog/monolog": "¹.17",
        "twig/twig": "¹.23"
    }
}

该文件只是一个包含我们应用程序配置的 JSON 映射;在这种情况下,是我们安装的两个依赖项的列表。如您所见,依赖项的名称遵循一个模式:由斜杠分隔的两个单词。第一个单词指的是开发库的供应商。第二个单词是库本身的名称。依赖项有一个版本,可以是确切的版本号,如本例所示,或者可以包含通配符字符或标签名称。您可以在getcomposer.org/doc/articles/aliases.md上了解更多信息。

最后,如果您想添加另一个依赖项或以任何方式编辑composer.json文件,您应该在命令行中运行composer update,或者在composer.json文件所在的任何位置,以便更新依赖项。

使用 PSR-4 的自动加载器

在前面的章节中,我们也为我们应用程序添加了一个自动加载器。由于我们现在使用的是他人的代码,我们需要知道如何加载他们的类。很快,开发者意识到如果没有标准,这种场景几乎无法管理,因此他们提出了一些大多数开发者都遵循的标准。您可以在www.php-fig.org找到关于这个主题的大量信息。

现在,PHP 有两个主要的自动加载标准:PSR-0PSR-4。它们非常相似,但我们将实现后者,因为它是最新的发布标准。这个标准基本上遵循我们在讨论命名空间时已经介绍的内容:类的命名空间必须与它所在的目录相同,类的名称应该是文件名,后跟扩展名.php。例如,src/Domain/Book.php文件包含在Bookstore\Domain命名空间内的Book类。

使用 Composer 的应用程序应遵循这些标准之一,并在它们各自的composer.json文件中注明它们使用的是哪一个。这意味着 Composer 知道如何自动加载它自己的应用程序文件,因此当我们下载外部库时,我们不需要关心它。为了指定这一点,我们编辑我们的composer.json文件,并添加以下内容:

{
    "require": {
        "monolog/monolog": "¹.17",
        "twig/twig": "¹.23"
    },
 "autoload": {
 "psr-4": {
 "Bookstore\\": "src"
 }
 }
}

上述代码表示我们将使用 PSR-4 在我们的应用程序中,并且所有以Bookstore开头的命名空间都应该在src/目录内找到。这正是我们的自动加载器已经做到的,但现在简化为配置文件中的几行。我们现在可以安全地删除我们的自动加载器及其任何引用。

Composer 生成一些映射来帮助加快类的加载速度。为了将配置文件中添加的新信息更新到这些映射中,我们需要运行之前运行的 composer update 命令。这次,输出将告诉我们没有需要更新的包,但将再次生成自动加载文件:

$ composer update
Loading composer repositories with package information
Updating dependencies (including require-dev)
Nothing to install or update
Writing lock file
Generating autoload files

添加元数据

为了知道你定义的依赖项库在哪里,Composer 维护着一个包和版本的仓库,称为 Packagist。这个仓库为开发者提供了大量有用的信息,例如给定包的所有版本、作者、一些关于包功能的描述(或指向该信息的网站),以及该包将下载的依赖项。你还可以通过名称或类别浏览包。

但 Packagist 是如何知道这个文件的?这都要归功于 composer.json 文件本身。在那里,你可以以 Composer 理解的格式定义你应用程序的所有元数据。让我们看看一个例子。将以下内容添加到你的 composer.json 文件中:

{
    "name": "picahielos/bookstore",
    "description": "Manages an online bookstore.",
    "minimum-stability": "stable",
    "license": "Apache-2.0",
    "type": "project",
    "authors": [
        {
            "name": "Antonio Lopez",
            "email": "antonio.lopez.zapata@gmail.com"
        }
    ],
    // ...
}

配置文件现在包含了遵循 Composer 约定的包名称:供应商名称,斜杠,包名称——在这个例子中,picahielos/bookstore。我们还添加了描述、许可证、作者和其他元数据。如果你在公共仓库(如 GitHub)中有代码,添加这个 composer.json 文件将允许你访问 Packagist 并插入你仓库的 URL。Packagist 将你的代码作为一个新的包添加,从你的 composer.json 文件中提取信息。它将根据你的标签或分支显示可用的版本。为了了解更多信息,我们鼓励你访问官方文档getcomposer.org/doc/04-schema.md

index.php 文件

在 MVC 应用程序中,我们通常有一个文件来获取所有请求,并根据 URL 将它们路由到特定的控制器。这种逻辑通常可以在我们根目录中的 index.php 文件中找到。我们已经有了一个,但随着我们适应 MVC 模式,我们不再需要当前的 index.php。因此,你可以安全地将其替换为以下内容:

<?php

require_once __DIR__ . '/vendor/autoload.php';

现在这个文件唯一要做的就是包含处理所有自动加载的 Composer 代码的文件。稍后,我们将在这里初始化一切,例如数据库连接、配置读取器等等,但现在让我们先留空。

处理请求

如同你可能在之前的章节中回忆的那样,Web 应用程序的主要目的是处理来自客户端的 HTTP 请求并返回响应。如果你的应用程序的主要目标是这个,那么管理请求和响应应该是你代码中的一个重要部分。

PHP 是一种可以用于脚本的编程语言,但它的主要用途是 Web 应用程序。由于这个原因,语言自带了很多用于管理请求和响应的辅助工具。尽管如此,原生的方式并不理想,并且作为好的面向对象开发者,我们应该提出一套有助于此的类。这个小项目的主要元素——仍然在你的应用内部——是请求和路由器。让我们开始吧!

请求对象

随着我们开始我们的迷你框架,我们需要稍微改变一下我们的目录结构。我们将为所有与框架相关的类创建一个src/Core目录。由于上一章中的配置读取器也是框架的一部分(而不是用户的功能),我们应该也将Config.php文件移动到这个目录。

首先要考虑的是请求看起来是什么样子。如果你还记得第二章,使用 PHP 的 Web 应用程序,一个请求基本上是一个发送到 URL 的消息,并且有一个方法——目前是 GET 或 POST。URL 同时由两部分组成:Web 应用的域名,即你的服务器名称,以及服务器内请求的路径。例如,如果你尝试访问http://bookstore.com/my-books,第一部分http://bookstore.com将是域名,而/my-books将是路径。实际上,http不会是域名的一部分,但对我们应用来说,我们不需要那么细粒度的信息。你可以从 PHP 为每个请求填充的全局数组$_SERVER中获取这些信息。

我们的Request类应该为这三个元素中的每一个都有一个属性,然后是一系列获取器和一些对用户有用的其他辅助方法。此外,我们应该在构造函数中从$_SERVER初始化所有属性。让我们看看它会是怎样的:

<?php

namespace Bookstore\Core;

class Request {
    const GET = 'GET';
    const POST = 'POST';

    private $domain;
    private $path;
    private $method;

    public function __construct() {
        $this->domain = $_SERVER['HTTP_HOST'];
        $this->path = $_SERVER['REQUEST_URI'];
        $this->method = $_SERVER['REQUEST_METHOD'];
    }

    public function getUrl(): string {
        return $this->domain . $this->path;
    }

    public function getDomain(): string {
        return $this->domain;
    }

    public function getPath(): string {
        return $this->path;
    }

    public function getMethod(): string {
        return $this->method;
    }

    public function isPost(): bool {
        return $this->method === self::POST;
    }

    public function isGet(): bool {
        return $this->method === self::GET;
    }
}

我们可以在前面的代码中看到,除了每个属性的获取器之外,我们还添加了getUrlisPostisGet方法。用户可以使用现有的获取器找到相同的信息,但鉴于它们将非常需要,总是好的让它们更容易使用。另外请注意,属性来自$_SERVER数组的值:HTTP_HOSTREQUEST_URIREQUEST_METHOD

从请求中过滤参数

请求的另一个重要部分是来自用户的信息,即 GET 和 POST 参数,以及 cookies。与$_SERVER全局数组一样,这些信息来自$_POST$_GET$_COOKIE,但总是好的避免直接使用它们,没有过滤,因为用户可能会发送恶意代码。

现在,我们将实现一个表示可以过滤的映射(键值对)的类。我们将称之为 FilteredMap,并将其包含在我们的命名空间 Bookstore\Core 中。我们将使用它来包含 GET 和 POST 参数以及作为我们 Request 类中的两个新属性。这个映射将只包含一个属性,即数据数组,并且将有一些方法来从它中获取信息。为了构造对象,我们需要将数据数组作为参数发送给构造函数:

<?php

namespace Bookstore\Core;

class FilteredMap {
    private $map;

    public function __construct(array $baseMap) {
        $this->map = $baseMap;
    }

    public function has(string $name): bool {
        return isset($this->map[$name]);
    }

    public function get(string $name) {
        return $this->map[$name] ?? null;
    }
}

这个类到目前为止并没有做什么。我们可以用普通的数组实现相同的功能。这个类的实用性在于我们在获取数据时添加过滤器。我们将实现三个过滤器,但你可以根据需要添加更多:

public function getInt(string $name) {
    return (int) $this->get($name);
}

public function getNumber(string $name) {
    return (float) $this->get($name);
}

public function getString(string $name, bool $filter = true) {
    $value = (string) $this->get($name);
    return $filter ? addslashes($value) : $value;
}

上述代码中的这三个方法允许用户获取特定类型的参数。假设开发者需要从请求中获取书籍的 ID。最佳选项是使用 getInt 方法来确保返回的值是一个有效的整数,而不是可能破坏我们数据库的恶意代码。还要注意 getString 函数,我们使用了 addSlashed 方法。这个方法向一些可疑字符添加斜杠,例如斜杠或引号,试图用它来防止恶意代码。

现在,我们已经准备好使用我们的 FilteredMap 从我们的 Request 类中获取 GET 和 POST 参数以及 cookies。新的代码将如下所示:

<?php

namespace Bookstore\Core;

class Request {
    // ...
 private $params;
 private $cookies;

    public function __construct() {
        $this->domain = $_SERVER['HTTP_HOST'];
        $this->path = explode('?', $_SERVER['REQUEST_URI'])[0];
        $this->method = $_SERVER['REQUEST_METHOD'];
 $this->params = new FilteredMap(
 array_merge($_POST, $_GET)
 );
 $this->cookies = new FilteredMap($_COOKIE);
    }

    // ...

 public function getParams(): FilteredMap {
 return $this->params;
 }

 public function getCookies(): FilteredMap {
 return $this->cookies;
 }
}

使用这个新功能,开发者可以通过以下代码行获取 POST 参数 price

$price = $request->getParams()->getNumber('price');

这比通常调用全局数组要安全得多:

$price = $_POST['price'];

将路由映射到控制器

如果你能够回忆起你每天使用的任何 URL,你可能会看不到任何 PHP 文件作为路径的一部分,就像我们在 http://localhost:8000/init.php 中看到的那样。网站试图格式化它们的 URL,使它们更容易记住,而不是依赖于应该处理该请求的文件。同样,正如我们已经提到的,所有我们的请求都通过同一个文件 index.php,无论它们的路径如何。正因为如此,我们需要保留一个 URL 路径的映射,以及谁应该处理它们。

有时,我们的 URL 会包含作为路径一部分的参数,这与它们包含 GET 或 POST 参数的情况不同。例如,要获取显示特定书籍的页面,我们可能会在 URL 中包含书籍的 ID,例如 /book/12/book/3。每个不同的书籍的 ID 都会改变,但同一个控制器应该处理所有这些请求。为了实现这一点,我们说 URL 包含一个参数,我们可以用 /book/:id 来表示它,其中 id 是标识书籍 ID 的参数。可选地,我们可以指定这个参数可以接受的价值类型,例如数字、字符串等等。

负责处理请求的控制器由一个方法的类定义。该方法接受所有由 URL 的路径定义的参数,例如书的 ID。我们根据功能分组控制器,也就是说,一个 BookController 类将包含与书籍请求相关的所有方法。

定义了路由的所有元素——URL-控制器关系后,我们就可以创建我们的 routes.json 文件了,这是一个配置文件,将保存这个映射。该文件的每个条目应包含一个路由,键是 URL,值是关于控制器的信息映射。让我们看看一个例子:

{
  "books/:page": {
    "controller": "Book",
    "method": "getAllWithPage",
    "params": {
      "page": "number"
    }
  }
}

在前面的示例中,该路由指的是所有符合模式 /books/:page 的 URL,其中 page 可以是任何数字。因此,这个路由将匹配像 /books/23/books/2 这样的 URL,但不应该匹配 /books/one/books。将处理此请求的控制器设置为 BookController 中的 getAllWithPage 方法;我们将把 Controller 添加到所有类名中。根据我们定义的参数,方法的定义可能如下所示:

public function getAllWithPage(int $page): string {
    //...
}

在定义路由时,我们应考虑最后一件事。对于某些端点,我们应该强制用户进行身份验证,例如当用户试图访问他们自己的销售时。我们可以以多种方式定义此规则,但我们选择将其作为路由的一部分,在控制器的信息中添加 "login": true 条目。考虑到这一点,让我们添加定义我们期望拥有的所有视图的其余路由:

{
//...
  "books": {
    "controller": "Book",
    "method": "getAll"
  },
  "book/:id": {
    "controller": "Book",
    "method": "get",
    "params": {
      "id": "number"
    }
  },
  "books/search": {
    "controller": "Book",
    "method": "search"
  }, 
  "login": {
    "controller": "Customer",
    "method": "login"
  },
  "sales": {
    "controller": "Sales",
    "method": "getByUser" ,
    "login": true
  },
  "sales/:id": {
    "controller": "Sales",
    "method": "get",
    "login": true,
    "params": {
      "id": "number"
    }
  },
  "my-books": {
    "controller": "Book",
    "method": "getByUser",
    "login": true
  }
}

这些路由定义了我们需要的所有页面;我们可以以分页的方式获取所有书籍,或者通过它们的 ID 获取特定书籍,我们可以搜索书籍,列出用户的销售情况,显示特定 ID 的销售,以及列出某个用户借阅的所有书籍。然而,我们仍然缺少一些应用程序应该能够处理的端点。对于所有试图修改数据而不是请求数据的操作,即借阅一本书或购买它,我们也需要添加端点。将以下内容添加到您的 routes.json 文件中:

{
  // ...
  "book/:id/buy": {
    "controller": "Sales",
    "method": "add",
    "login": true
    "params": {
      "id": "number"
    }
  },
  "book/:id/borrow": {
    "controller": "Book",
    "method": "borrow",
    "login": true
    "params": {
      "id": "number"
    }
  },
  "book/:id/return": {
    "controller": "Book",
    "method": "returnBook",
    "login": true
    "params": {
      "id": "number"
    }
  }
}

路由器

路由器将是我们的应用程序中最复杂的代码部分。主要目标是接收一个 Request 对象,决定哪个控制器应该处理它,用必要的参数调用它,并返回该控制器的响应。本节的主要目标是理解路由的重要性,而不是其详细实现,但我们将尝试描述其各个部分。将以下内容复制到您的 src/Core/Router.php 文件中:

<?php

namespace Bookstore\Core;

use Bookstore\Controllers\ErrorController;
use Bookstore\Controllers\CustomerController;

class Router {
    private $routeMap;
    private static $regexPatters = [
        'number' => '\d+',
        'string' => '\w'
    ];

    public function __construct() {
        $json = file_get_contents(
            __DIR__ . '/../../config/routes.json'
        );
        $this->routeMap = json_decode($json, true);
    }

    public function route(Request $request): string {
        $path = $request->getPath();

        foreach ($this->routeMap as $route => $info) {
            $regexRoute = $this->getRegexRoute($route, $info);
            if (preg_match("@^/$regexRoute$@", $path)) {
                return $this->executeController(
                    $route, $path, $info, $request
                );
            }
        }

        $errorController = new ErrorController($request);
        return $errorController->notFound();
    }
}

该类的构造函数从 routes.json 文件中读取,并将内容存储为数组。其主要方法 route 接收一个 Request 对象并返回一个字符串,这是我们发送给客户端的输出。该方法迭代数组中的所有路由,尝试将每个路由与给定请求的路径进行匹配。一旦找到匹配项,它将尝试执行与该路由相关的控制器。如果没有任何路由与请求匹配,则路由器将执行 ErrorControllernotFound 方法,然后返回一个错误页面。

与正则表达式匹配的 URL

在匹配路由中的 URL 时,我们需要注意动态 URL 的参数,因为它们不允许我们进行简单的字符串比较。PHP 以及其他语言有一个非常强大的工具用于执行带有动态内容的字符串比较:正则表达式。成为正则表达式专家需要时间,而且这超出了本书的范围,但我们将为您简要介绍它们。

正则表达式是一个包含一些通配符的字符串,这些通配符将匹配动态内容。其中一些最重要的如下:

  • ^: 这用于指定匹配的部分应该是整个字符串的开始

  • $: 这用于指定匹配的部分应该是整个字符串的末尾

  • \d: 这用于匹配一个数字

  • \w: 这用于匹配一个单词

  • +: 这用于跟随一个字符或表达式,让该字符或表达式至少出现一次或多次

  • *: 这用于跟随一个字符或表达式,让该字符或表达式出现零次或多次

  • .: 这用于匹配任何单个字符

让我们看看一些例子:

  • 模式 .* 将匹配任何内容,甚至是一个空字符串

  • 模式 .+ 将匹配任何包含至少一个字符的内容

  • 模式 ^\d+$ 将匹配任何至少包含一个数字的数字

在 PHP 中,我们有不同的函数用于处理正则表达式。其中最简单的一个,也是我们将要使用的,是 pregmatch。该函数将其第一个参数(由两个字符分隔,通常是 @/)作为模式,我们将尝试匹配的字符串作为第二个参数,可选地,一个数组,其中 PHP 存储找到的匹配项。该函数返回一个布尔值,如果找到匹配项则为 true,否则为 false。我们在 Route 类中如下使用它:

preg_match("@^/$regexRoute$@", $path)

$path 变量包含请求的路径,例如,/books/2。我们使用一个由 @ 分隔的模式进行匹配,该模式包含 ^$ 通配符,以强制模式匹配整个字符串,并包含 / 和变量 $regexRoute 的连接。此变量的内容由以下方法提供;同时将其添加到您的 Router 类中:

private function getRegexRoute(
    string $route,
    array $info
): string {
    if (isset($info['params'])) {
        foreach ($info['params'] as $name => $type) {
            $route = str_replace(
                ':' . $name, self::$regexPatters[$type], $route
            );
        }
    }

    return $route;
}

前面的方法遍历来自路由信息的参数列表。对于每个参数,函数将路由中参数的名称替换为对应参数类型的通配符字符——检查静态数组$regexPatterns。为了说明这个函数的用法,让我们看一些例子:

  • 路由/books将保持不变,因为它不包含任何参数

  • 路由books/:id/borrow将更改为books/\d+/borrow,因为 URL 参数id是一个数字

提取 URL 的参数

为了执行控制器,我们需要三份数据:要实例化的类的名称、要执行的方法的名称以及方法需要接收的参数。我们已经有了前两个作为路由$info数组的一部分,所以让我们集中精力寻找第三个。向Router类添加以下方法:

private function extractParams(
    string $route,
    string $path
): array {
    $params = [];

    $pathParts = explode('/', $path);
    $routeParts = explode('/', $route);

    foreach ($routeParts as $key => $routePart) {
        if (strpos($routePart, ':') === 0) {
            $name = substr($routePart, 1);
            $params[$name] = $pathParts[$key+1];
        }
    }

    return $params;
}

这个最后的方法期望请求的路径和路由的 URL 遵循相同的模式。使用explode方法,我们得到两个应该匹配它们各自条目的数组。我们遍历它们,并且对于路由数组中的每个看起来像参数的条目,我们在 URL 中获取它的值。例如,如果我们有路由/books/:id/borrow和路径/books/12/borrow,这个方法的结果将是数组['id' => 12]

执行控制器

我们通过实现负责给定路由的控制器执行方法来结束本节。我们已经有类的名称、方法的名称以及方法需要的参数,因此我们可以使用call_user_func_array这个原生函数,它给定一个对象、一个方法名称和方法的参数,调用对象的方法。我们必须使用它,因为参数的数量是不固定的,我们不能执行正常的调用。

但我们在创建routes.json文件时遗漏了一个行为。有一些路由强制用户登录,在我们的情况下,这意味着用户有一个包含用户 ID 的 cookie。给定一个强制授权的路由,我们将检查我们的请求是否包含 cookie,如果是的话,我们将通过setCustomerId将其设置到控制器类中。如果用户没有 cookie,我们将不会执行当前路由的控制器,而是执行CustomerController类的showLogin方法,这将渲染登录表单的模板。让我们看看在添加我们的Router类的最后一个方法后,一切将如何看起来:

private function executeController(
    string $route,
    string $path,
    array $info,
    Request $request
): string {
    $controllerName = '\Bookstore\Controllers\\'
        . $info['controller'] . 'Controller';
    $controller = new $controllerName($request);

    if (isset($info['login']) && $info['login']) {
        if ($request->getCookies()->has('user')) {
            $customerId = $request->getCookies()->get('user');
            $controller->setCustomerId($customerId);
        } else {
            $errorController = new CustomerController($request);
            return $errorController->login();
        }
    }

    $params = $this->extractParams($route, $path);
    return call_user_func_array(
        [$controller, $info['method']], $params
    );
}

我们已经警告过你我们应用程序的安全性不足,因为这个项目只是具有教学目的。所以,避免复制这里实现的授权系统。

M 代表模型

暂时想象一下,我们的书店网站非常成功,所以我们考虑建立一个移动应用来扩大我们的市场。当然,我们希望使用与我们的网站相同的数据库,因为我们需要同步两个应用中人们借阅或购买的书籍。我们不希望处于两个人购买同一本书最后一本的情况!

不仅数据库,用于获取书籍、更新它们等的查询也必须相同,否则我们最终会遇到意外的行为。当然,一个显然简单的选择是在两个代码库中复制查询,但这有一个巨大的可维护性问题。如果我们更改数据库中的一个字段怎么办?我们需要至少在两个不同的代码库中应用相同的更改。这似乎根本没有什么用。

商业逻辑在这里也扮演着重要的角色。把它想象成你需要做出的决策,这些决策会影响你的业务。在我们的案例中,那就是高级客户可以借阅 10 本书,而普通客户只能借阅 3 本,这是商业逻辑。这种逻辑也应该放在一个公共的地方,因为如果我们想改变它,我们会遇到与我们的数据库查询相同的问题。

我们希望到现在我们已经说服你,数据和商业逻辑应该从其他代码中分离出来,以便使其可重用。如果你觉得难以定义什么应该作为模型的一部分或作为控制器的一部分,不要担心;很多人都在这个区别上挣扎。由于我们的应用程序非常简单,并且没有很多商业逻辑,我们只需关注添加所有与 MySQL 查询相关的代码。

如你所想,对于一个与 MySQL 或任何其他数据库系统集成的应用程序,数据库连接是模型的一个重要元素。我们选择使用 PDO 来与 MySQL 交互,你可能还记得,实例化这个类有点痛苦。让我们创建一个单例类,它返回一个PDO实例,使事情变得更容易。将此代码添加到src/Core/Db.php中:

<?php

namespace Bookstore\Core;

use PDO;

class Db {
    private static $instance;

    private static function connect(): PDO {
        $dbConfig = Config::getInstance()->get('db');
        return new PDO(
            'mysql:host=127.0.0.1;dbname=bookstore',
            $dbConfig['user'],
            $dbConfig['password']
        );
    }

    public static function getInstance(){
        if (self::$instance == null) {
            self::$instance = self::connect();
        }
        return self::$instance;
    }
}
PDO instance. From now on, in order to get a database connection, we just need to write Db::getInstance().

虽然这可能对所有模型都不成立,但在我们的应用程序中,它们将始终需要访问数据库。我们可以创建一个抽象类,所有模型都从这个类扩展。这个类可以包含一个$db受保护的属性,它将在构造函数中设置。有了这个,我们就避免了在所有模型中重复相同的构造函数和属性定义。将以下类复制到src/Models/AbstractModel.php中:

<?php

namespace Bookstore\Models;

use PDO;

abstract class AbstractModel {
    private $db;

    public function __construct(PDO $db) {
        $this->db = $db;
    }
}

最后,为了完成模型的设置,我们可以创建一个新的异常(就像我们为NotFoundException类所做的那样),它代表数据库中的错误。它将不包含任何代码,但我们能够区分异常是从哪里来的。我们将它保存在src/Exceptions/DbException.php中:

<?php

namespace Bookstore\Exceptions;

use Exception;

class DbException extends Exception {
}

现在我们已经奠定了基础,我们可以开始编写我们的模型了。组织模型由你决定,但模仿领域对象结构是一个好主意。在这种情况下,我们将有三个模型:CustomerModelBookModelSalesModel。在接下来的章节中,我们将解释每个模型的内容。

客户模型

让我们从最简单的一个开始。由于我们的应用程序仍然非常原始,我们不会允许创建新的客户,而是使用我们手动插入数据库的客户。这意味着我们只需要对客户进行查询。让我们在 src/Models/CustomerModel.php 中创建一个 CustomerModel 类,其内容如下:

<?php

namespace Bookstore\Models;

use Bookstore\Domain\Customer;
use Bookstore\Domain\Customer\CustomerFactory;
use Bookstore\Exceptions\NotFoundException;

class CustomerModel extends AbstractModel {
    public function get(int $userId): Customer {
        $query = 'SELECT * FROM customer WHERE customer_id = :user';
        $sth = $this->db->prepare($query);
        $sth->execute(['user' => $userId]);

        $row = $sth->fetch();

        if (empty($row)) {
            throw new NotFoundException();
        }

        return CustomerFactory::factory(
            $row['type'],
            $row['id'],
            $row['firstname'],
            $row['surname'],
            $row['email']
        );
    }

    public function getByEmail(string $email): Customer {
        $query = 'SELECT * FROM customer WHERE email = :user';
        $sth = $this->db->prepare($query);
        $sth->execute(['user' => $email]);

        $row = $sth->fetch();

        if (empty($row)) {
            throw new NotFoundException();
        }

        return CustomerFactory::factory(
            $row['type'],
            $row['id'],
            $row['firstname'],
            $row['surname'],
            $row['email']
        );
    }
}

CustomerModel 类继承自 AbstractModel 类,包含两个方法;这两个方法都返回一个 Customer 实例,一个在提供客户 ID 时调用,另一个在提供电子邮件时调用。因为我们已经通过 $db 属性拥有了数据库连接,所以我们只需要准备一个给定的查询语句,用参数执行该语句,并获取结果。如果我们期望获取一个客户,如果用户提供的 ID 或电子邮件不属于任何客户,我们需要抛出一个异常——在这种情况下,NotFoundException 就足够了。如果我们找到了一个客户,我们使用我们的工厂创建对象并返回它。

书籍模型

我们的 BookModel 类给我们带来了一些额外的工作。客户有一个工厂,但对于书籍来说,拥有一个工厂并不值得。我们用于从 MySQL 行创建它们的不是构造函数,而是 PDO 具有的一种获取模式,它允许我们将一行映射到对象。为了做到这一点,我们需要对 Book 领域对象进行一些调整:

  • 属性的名称必须与数据库中的字段名称相同

  • 没有必要提供构造函数或设置器,除非我们需要它们用于其他目的

  • 为了与封装性相匹配,属性应该是私有的,因此我们需要为它们提供获取器

新的 Book 类应该如下所示:

<?php

namespace Bookstore\Domain;

class Book {
    private $id;
    private $isbn;
    private $title;
    private $author;
    private $stock;
    private $price;

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

    public function getIsbn(): string {
        return $this->isbn;
    }

    public function getTitle(): string {
        return $this->title;
    }

    public function getAuthor(): string {
        return $this->author;
    }

    public function getStock(): int {
        return $this->stock;
    }

    public function getCopy(): bool {
        if ($this->stock < 1) {
            return false;
        } else {
            $this->stock--;
            return true;
        }
    }

    public function addCopy() {
        $this->stock++;
    }

    public function getPrice(): float {
        return $this->price;
    }
}

我们保留了 getCopyaddCopy 方法,即使它们不是获取器,因为我们在以后还需要它们。现在,当我们使用 fetchAll 方法从 MySQL 获取一组行时,我们可以发送两个参数:常量 PDO::FETCH_CLASS,它告诉 PDO 将行映射到类,以及我们想要映射到的类的名称。让我们创建一个 BookModel 类,它有一个简单的 get 方法,该方法使用给定的 ID 从数据库中获取一本书。此方法将返回一个 Book 对象,或者在 ID 不存在的情况下抛出一个异常。将其保存为 src/Models/BookModel.php

<?php

namespace Bookstore\Models;

use Bookstore\Domain\Book;
use Bookstore\Exceptions\DbException;
use Bookstore\Exceptions\NotFoundException;
use PDO;

class BookModel extends AbstractModel {
    const CLASSNAME = '\Bookstore\Domain\Book';

    public function get(int $bookId): Book {
        $query = 'SELECT * FROM book WHERE id = :id';
        $sth = $this->db->prepare($query);
        $sth->execute(['id' => $bookId]);

 $books = $sth->fetchAll(
 PDO::FETCH_CLASS, self::CLASSNAME
 );
        if (empty($books)) {
            throw new NotFoundException();
        }

        return $books[0];
    }
}

使用这种获取模式有优点和缺点。一方面,在从行创建对象时,我们避免了大量的枯燥代码。通常,我们要么将行数组的所有元素发送到类的构造函数,要么使用所有属性的 setter。如果我们向 MySQL 表添加更多字段,我们只需将属性添加到我们的领域类中,而无需更改我们实例化对象的所有地方。另一方面,你被迫在表和类的属性中使用相同的字段名,这意味着高度耦合(这始终是一个坏主意)。这也导致了一些遵循约定时的冲突,因为在 MySQL 中,通常使用book_id,但在 PHP 中,属性是$bookId

现在我们知道了这种获取模式是如何工作的,让我们添加三个其他方法,这些方法从 MySQL 获取数据。将以下代码添加到您的模型中:

public function getAll(int $page, int $pageLength): array {
    $start = $pageLength * ($page - 1);

    $query = 'SELECT * FROM book LIMIT :page, :length';
    $sth = $this->db->prepare($query);
    $sth->bindParam('page', $start, PDO::PARAM_INT);
    $sth->bindParam('length', $pageLength, PDO::PARAM_INT);
    $sth->execute();

    return $sth->fetchAll(PDO::FETCH_CLASS, self::CLASSNAME);
}

public function getByUser(int $userId): array {
    $query = <<<SQL
SELECT b.*
FROM borrowed_books bb LEFT JOIN book b ON bb.book_id = b.id
WHERE bb.customer_id = :id
SQL;
    $sth = $this->db->prepare($query);
    $sth->execute(['id' => $userId]);

    return $sth->fetchAll(PDO::FETCH_CLASS, self::CLASSNAME);
}

public function search(string $title, string $author): array {
    $query = <<<SQL
SELECT * FROM book
WHERE title LIKE :title AND author LIKE :author
SQL;
    $sth = $this->db->prepare($query);
    $sth->bindValue('title', "%$title%");
    $sth->bindValue('author', "%$author%");
    $sth->execute();

    return $sth->fetchAll(PDO::FETCH_CLASS, self::CLASSNAME);
}

添加的方法如下:

  • getAll返回给定页面的所有书籍数组。记住,LIMIT允许你通过偏移量返回特定数量的行,这可以作为分页器使用。

  • getByUser返回给定客户借阅的所有书籍——我们需要为此使用连接查询。注意,我们返回b.*,即只返回book表的字段,跳过其他字段。

  • 最后,有一个方法可以通过标题或作者进行搜索,或者两者都搜索。我们可以使用LIKE运算符和用%包围的模式来实现这一点。如果我们没有指定其中一个参数,我们将尝试用%%匹配字段,它匹配一切。

到目前为止,我们一直在添加获取数据的方法。现在让我们添加允许我们修改数据库中数据的方法。对于书籍模型,我们需要能够借阅书籍并归还它们。以下是这两个操作的代码:

public function borrow(Book $book, int $userId) {
    $query = <<<SQL
INSERT INTO borrowed_books (book_id, customer_id, start)
VALUES(:book, :user, NOW())
SQL;
    $sth = $this->db->prepare($query);
    $sth->bindValue('book', $book->getId());
    $sth->bindValue('user', $userId);
    if (!$sth->execute()) {
        throw new DbException($sth->errorInfo()[2]);
    }

    $this->updateBookStock($book);
}

public function returnBook(Book $book, int $userId) {
    $query = <<<SQL
UPDATE borrowed_books SET end = NOW()
WHERE book_id = :book AND customer_id = :user AND end IS NULL 
SQL;
    $sth = $this->db->prepare($query);
    $sth->bindValue('book', $book->getId());
    $sth->bindValue('user', $userId);
    if (!$sth->execute()) {
        throw new DbException($sth->errorInfo()[2]);
    }

    $this->updateBookStock($book);
}

private function updateBookStock(Book $book) {
    $query = 'UPDATE book SET stock = :stock WHERE id = :id';
    $sth = $this->db->prepare($query);
    $sth->bindValue('id', $book->getId());
    $sth->bindValue('stock', $book->getStock());
    if (!$sth->execute()) {
        throw new DbException($sth->errorInfo()[2]);
    }
}
borrow and returnBook methods.

销售模型

现在,我们需要向我们的应用程序添加最后一个模型:SalesModel。使用与书籍相同的获取模式,我们还需要调整领域类。在这种情况下,我们需要思考更多,因为我们不仅要获取数据。我们的应用程序必须能够根据需求创建新的销售,包含客户和书籍的 ID。我们当前的实施方案已经可以添加书籍,但我们需要添加一个客户 ID 的 setter。销售的 ID 将由 MySQL 中的自增 ID 提供,因此不需要为它添加 setter。最终的实现将如下所示:

<?php

namespace Bookstore\Domain;

class Sale {
    private $id;
    private $customer_id;
    private $books;
    private $date;

    public function setCustomerId(int $customerId) {
        $this->customer_id = $customerId;
    }

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

    public function getCustomerId(): int {
        return $this->customer_id;
    }

    public function getBooks(): array {
        return $this->books;
    }

    public function getDate(): string {
        return $this->date;
    }

    public function addBook(int $bookId, int $amount = 1) {
        if (!isset($this->books[$bookId])) {
            $this->books[$bookId] = 0;
        }
        $this->books[$bookId] += $amount;
    }

    public function setBooks(array $books) {
        $this->books = $books;
    }
}

SalesModel将是编写起来最困难的一个。这个模型的问题在于它包括操作不同的表:salesale_book。例如,当获取销售信息时,我们需要从sale表获取信息,然后从sale_book表获取所有书籍的信息。你可以争论是否应该有一个唯一的方法来获取与销售相关的所有必要信息,或者有两个不同的方法,一个用于获取销售信息,另一个用于获取书籍信息,让控制器决定使用哪一个。

这实际上引发了一场非常有趣的讨论。一方面,我们希望让控制器更容易操作——有一个唯一的方法来获取整个Sale对象。这是有意义的,因为控制器不需要了解Sale对象的内部实现,这降低了耦合。另一方面,强迫模型总是获取整个对象,即使我们只需要sale表中的信息,也是一个坏主意。想象一下,如果销售包含大量的书籍,从 MySQL 中获取它们将无必要地降低性能。

你应该思考你的控制器如何管理销售。如果你总是需要整个对象,你可以有一个方法而不必担心性能。如果你有时需要获取整个对象,也许你可以添加两个方法。对于我们的应用程序,我们将有一个方法来处理所有这些,因为我们总是需要它。

注意

延迟加载

就像任何其他设计挑战一样,其他开发者已经对这个问题进行了很多思考。他们提出了一种名为延迟加载的设计模式。这个模式基本上让控制器认为只有一个方法可以获取整个域对象,但实际上我们只会从数据库中获取所需的数据。

模型获取对象最常用的信息,并将需要额外数据库查询的其他属性留空。一旦控制器使用一个空属性的 getter,模型会自动从数据库中获取那些数据。我们得到了两全其美的效果:控制器有简单的操作,但我们不会在查询未使用的数据上浪费更多时间。

将以下内容添加到你的src/Models/SaleModel.php文件中:

<?php
namespace Bookstore\Models;

use Bookstore\Domain\Sale;
use Bookstore\Exceptions\DbException;
use PDO;

class SaleModel extends AbstractModel {
    const CLASSNAME = '\Bookstore\Domain\Sale';

    public function getByUser(int $userId): array {
        $query = 'SELECT * FROM sale WHERE s.customer_id = :user';
        $sth = $this->db->prepare($query);
        $sth->execute(['user' => $userId]);

        return $sth->fetchAll(PDO::FETCH_CLASS, self::CLASSNAME);
    }

    public function get(int $saleId): Sale {
        $query = 'SELECT * FROM sale WHERE id = :id';
        $sth = $this->db->prepare($query);
        $sth->execute(['id' => $saleId]);
        $sales = $sth->fetchAll(PDO::FETCH_CLASS, self::CLASSNAME);

        if (empty($sales)) {
            throw new NotFoundException('Sale not found.');
        }
        $sale = array_pop($sales);

        $query = <<<SQL
SELECT b.id, b.title, b.author, b.price, sb.amount as stock, b.isbn
FROM sale s
LEFT JOIN sale_book sb ON s.id = sb.sale_id
LEFT JOIN book b ON sb.book_id = b.id
WHERE s.id = :id
SQL;
        $sth = $this->db->prepare($query);
        $sth->execute(['id' => $saleId]);
        $books = $sth->fetchAll(
            PDO::FETCH_CLASS, BookModel::CLASSNAME
        );

        $sale->setBooks($books);
        return $sale;
    }
}

在这个模型中,另一个棘手的方法是处理在数据库中创建销售的方法。这个方法必须在sale表中创建一个销售记录,然后将该销售的所有书籍添加到sale_book表中。如果我们添加书籍时出现问题,会发生什么?我们会在数据库中留下一个损坏的销售记录。为了避免这种情况,我们需要使用事务,从模型或控制器方法的开始处开始,在出现错误时回滚,或者在方法结束时提交。

在相同的方法中,我们还需要注意销售项的 ID。在创建sale对象时,我们没有设置销售项的 ID,因为我们依赖于数据库中的自增字段。但是,当将书籍插入到sale_book中时,我们需要销售项的 ID。为此,我们需要使用lastInsertId方法请求 PDO 的最后一个插入 ID。让我们将create方法添加到您的SaleModel中:

public function create(Sale $sale) {
 $this->db->beginTransaction();

    $query = <<<SQL
INSERT INTO sale(customer_id, date)
VALUES(:id, NOW())
SQL;
    $sth = $this->db->prepare($query);
    if (!$sth->execute(['id' => $sale->getCustomerId()])) {
 $this->db->rollBack();
        throw new DbException($sth->errorInfo()[2]);
    }

 $saleId = $this->db->lastInsertId();
    $query = <<<SQL
INSERT INTO sale_book(sale_id, book_id, amount)
VALUES(:sale, :book, :amount)
SQL;
    $sth = $this->db->prepare($query);
    $sth->bindValue('sale', $saleId);
    foreach ($sale->getBooks() as $bookId => $amount) {
        $sth->bindValue('book', $bookId);
        $sth->bindValue('amount', $amount);
        if (!$sth->execute()) {
 $this->db->rollBack();
            throw new DbException($sth->errorInfo()[2]);
        }
    }

 $this->db->commit();
}

从这个方法中需要注意的最后一点是,我们准备一个语句,将其绑定到一个值(销售 ID),然后绑定并执行与数组中书籍数量相同的相同语句。一旦你有一个语句,你可以绑定你想要的任何次数的值。同样,你可以执行你想要的任何次数的相同语句,而值保持不变。

V 代表视图

视图层负责处理视图。在这个层中,你可以找到所有渲染用户获取的 HTML 的模板。尽管视图与应用程序其他部分的分离很容易看出,但这并不意味着视图是一个容易的部分。实际上,你将不得不学习一项新技术才能正确编写视图。让我们深入了解细节。

Twig 简介

在我们第一次尝试编写视图时,我们将 PHP 和 HTML 代码混合在一起。我们已经知道逻辑不应该与 HTML 混合在同一地方,但这并不是故事的结尾。在渲染 HTML 时,我们同样需要一些逻辑。例如,如果我们想打印一本书的列表,我们需要为每本书重复一定的 HTML 块。由于我们事先不知道要打印多少本书,最佳选择是使用foreach循环。

许多人选择的一个选项是尽量减少在视图中包含的逻辑量。你可以设定一些规则,例如我们只应包含条件和循环,这是渲染基本视图所需的合理逻辑量。问题是无法强制执行这类规则,其他开发者可以轻易地开始在其中添加复杂的逻辑。虽然有些人对此表示可以接受,假设没有人会这样做,但其他人更喜欢实施更严格的系统。这就是模板引擎的起源。

你可以将模板引擎视为另一种需要学习的新语言。你为什么要这样做呢?因为这种新的“语言”比 PHP 更有限。这些语言通常允许你执行条件和简单的循环,仅此而已。开发者无法将 PHP 添加到该文件中,因为模板引擎不会将其视为 PHP 代码。相反,它只会将代码打印到输出——响应体——就像它是纯文本一样。此外,由于它专门面向编写模板,当与 HTML 混合时,语法通常更容易阅读。几乎一切都是优点。

使用模板引擎的不便之处在于,它需要一些时间将新语言翻译成 PHP,然后再翻译成 HTML。这可能会非常耗时,因此选择一个好的模板引擎非常重要。大多数模板引擎还允许你缓存模板,从而提高性能。我们的选择是一个相当轻量级且广泛使用的:Twig。因为我们已经在 Composer 文件中添加了依赖项,所以我们可以直接使用它。

设置 Twig 相当简单。在 PHP 方面,你只需要指定模板的位置。一个常见的约定是使用views目录。创建该目录,并在你的index.php中添加以下两行:

$loader = new Twig_Loader_Filesystem(__DIR__ . '/views');
$twig = new Twig_Environment($loader);

书籍视图

在这些部分,当我们处理模板时,看到你工作的结果会很好。我们还没有实现任何控制器,所以我们将强制index.php渲染一个特定的模板,无论请求如何。我们可以开始渲染单个书籍的视图。为此,让我们在创建你的twig对象之后,在index.php的末尾添加以下代码:

$bookModel = new BookModel(Db::getInstance());
$book = $bookModel->get(1);

$params = ['book' => $book];
echo $twig->loadTemplate('book.twig')->render($params);

在前面的代码中,我们向BookModel请求 ID 为 1 的书籍,获取book对象,并创建一个数组,其中book键的值为book对象。之后,我们告诉 Twig 加载模板book.twig,并通过发送数组来渲染它。这会将模板和$book对象注入其中,这样你就可以在模板内部使用它了。

现在我们来创建我们的第一个模板。将以下代码写入view/book.twig。按照惯例,所有 Twig 模板都应该有.twig扩展名:

<h2>{{ book.title }}</h2>
<h3>{{ book.author }}</h3>

<hr>

<p>
    <strong>ISBN</strong> {{ book.isbn }}
</p>
<p>
    <strong>Stock</strong> {{ book.stock }}
</p>
<p>
    <strong>Price</strong> {{ book.price|number_format(2) }} €
</p>

<hr>

<h3>Actions</h3>

<form method="post" action="/book/{{ book.id }}/borrow">
    <input type="submit" value="Borrow">
</form>

<form method="post" action="/book/{{ book.id }}/buy">
    <input type="submit" value="Buy">
</form>

由于这是你的第一个 Twig 模板,让我们一步一步来。你可以看到大部分内容是 HTML:一些标题,几个段落,以及两个带有两个按钮的表单。你可以识别出 Twig 部分,因为它被{{ }}包围。在 Twig 中,所有那些在花括号之间的内容都将被打印出来。我们找到的第一个包含book.title。你还记得我们在渲染模板时注入了book对象吗?我们在这里可以访问它,只是不是用常规的 PHP 语法。要访问一个对象的属性,使用.而不是->。所以,这个book.title将返回book对象的title属性的值,而{{ }}将使 Twig 打印它出来。模板的其余部分也是如此。

有一个功能不仅限于访问对象的属性。book.price|number_format(2)获取书籍的价格,并将其作为参数(使用管道符号)发送到已经获得2作为另一个参数的number_format函数。这段代码基本上将价格格式化为两位数字。在 Twig 中,你也有一些函数,但它们主要被简化为格式化输出,这是可接受的逻辑量。

你现在相信使用模板引擎为你的视图提供多么清晰的解决方案了吗?你可以在浏览器中尝试它:访问任何路径,你的 Web 服务器应该执行index.php文件,强制渲染模板book.twig

布局和区块

当你设计你的 Web 应用程序时,通常你会在大多数视图中共享一个共同的布局。在我们的例子中,我们希望视图的顶部始终有一个菜单,允许我们访问网站的各个部分,甚至可以从用户所在的位置搜索书籍。与模型一样,我们想要避免代码重复,因为如果我们把布局复制粘贴到每个地方,更新它将是一场噩梦。相反,Twig 提供了定义布局的能力。

在 Twig 中,布局只是一个模板文件。其内容是我们想要在所有视图中显示的公共 HTML 代码(在我们的案例中,是菜单和搜索栏),并包含一些带标签的空隙(Twig 世界中的区块),你将能够注入每个视图的特定 HTML。你可以使用{% block %}标签定义其中一个区块。让我们看看我们的views/layout.twig文件会是什么样子:

<html>
<head>
 <title>{% block title %}{% endblock %}</title>
</head>
<body>
    <div style="border: solid 1px">
        <a href="/books">Books</a>
        <a href="/sales">My Sales</a>
        <a href="/my-books">My Books</a>
        <hr>
        <form action="/books/search" method="get">
            <label>Title</label>
            <input type="text" name="title">
            <label>Author</label>
            <input type="text" name="author">
            <input type="submit" value="Search">
        </form>
    </div>
 {% block content %}{% endblock %}
</body>
</html>

如前述代码所示,区块有一个名称,这样使用布局的模板就可以引用它们。在我们的布局中,我们定义了两个区块:一个用于视图的标题,另一个用于内容本身。当模板使用布局时,我们只需要为布局中定义的每个区块编写 HTML 代码,Twig 就会完成剩余的工作。此外,为了让 Twig 知道我们的模板想要使用布局,我们使用带有布局文件名的{% extends %}标签。让我们更新views/book.twig以使用我们新的布局:

{% extends 'layout.twig' %}

{% block title %}
 {{ book.title }}
{% endblock %}

{% block content %}
<h2>{{ book.title }}</h2>
//...
</form>
{% endblock %}

在文件顶部,我们添加我们需要使用的布局。然后,我们用一个带有参考名称的区块标签打开,并在其中写入我们想要使用的 HTML。你可以在区块中使用任何有效的 HTML,无论是 Twig 代码还是纯 HTML。在我们的模板中,我们使用书的标题作为title区块,它引用了视图的标题,并将所有之前的 HTML 放入content区块。请注意,现在文件中的所有内容都在一个区块内。现在在你的浏览器中尝试一下,看看变化。

分页书籍列表

让我们再添加另一个视图,这次是为书籍分页列表。为了看到你工作的结果,更新index.php的内容,用以下代码替换上一节的代码:

$bookModel = new BookModel(Db::getInstance());
$books = $bookModel->getAll(1, 3);

$params = ['books' => $books, 'currentPage' => 2];
echo $twig->loadTemplate('books.twig')->render($params);
books.twig template, sending an array of books from page number 1, and showing 3 books per page. This array, though, might not always return 3 books, maybe because there are only 2 books in the database. We should then use a loop to iterate the list instead of assuming the size of the array. In Twig, you can emulate a foreach loop using {% for <element> in <array> %} in order to iterate an array. Let's use it for your views/books.twig:
{% extends 'layout.twig' %}

{% block title %}
    Books
{% endblock %}

{% block content %}
<table>
    <thead>
        <th>Title</th>
        <th>Author</th>
        <th></th>
    </thead>
{% for book in books %}
    <tr>
        <td>{{ book.title }}</td>
        <td>{{ book.author }}</td>
        <td><a href="/book/{{ book.id }}">View</a></td>
    </tr>
{% endfor %}
</table>
{% endblock %}

我们还可以在 Twig 模板中使用条件语句,它们的工作方式与 PHP 中的条件语句相同。语法是{% if <boolean expression> %}。让我们使用它来决定是否在我们的页面上显示上一页和/或下一页链接。在内容区块的末尾添加以下代码:

{% if currentPage != 1 %}
    <a href="/books/{{ currentPage - 1 }}">Previous</a>
{% endif %}
{% if not lastPage %}
    <a href="/books/{{ currentPage + 1 }}">Next</a>
{% endif %}

从这个模板中需要注意的最后一件事是,我们在使用{{ }}打印内容时,不仅限于使用变量。我们可以添加任何有效的 Twig 表达式,它返回一个值,就像我们使用{{ currentPage + 1 }}一样。

销售视图

我们已经向你展示了使用模板所需的一切,现在我们只需完成添加所有模板。列表中的下一个模板是显示特定用户销售列表的模板。使用以下技巧更新你的 index.php 文件:

$saleModel = new SaleModel(Db::getInstance());
$sales = $saleModel->getByUser(1);

$params = ['sales' => $sales];
echo $twig->loadTemplate('sales.twig')->render($params);

这个视图的模板将与列出书籍的模板非常相似:一个填充了数组内容的表格。以下是 views/sales.twig 的内容:

{% extends 'layout.twig' %}

{% block title %}
    My sales
{% endblock %}

{% block content %}
<table>
    <thead>
        <th>Id</th>
        <th>Date</th>
    </thead>
{% for sale in sales %}
    <tr>
        <td>{{ sale.id}}</td>
        <td>{{ sale.date }}</td>
        <td><a href="/sales/{{ sale.id }}">View</a></td>
    </tr>
{% endfor %}
</table>
{% endblock %}

与销售相关的另一种观点是我们希望展示特定内容的全部。这次销售,同样,将与书籍列表相似,因为我们将会列出与这次销售相关的书籍。强制渲染此模板的技巧如下:

$saleModel = new SaleModel(Db::getInstance());
$sale = $saleModel->get(1);

$params = ['sale' => $sale];
echo $twig->loadTemplate('sale.twig')->render($params);

并且,Twig 模板应该放置在 views/sale.twig

{% extends 'layout.twig' %}

{% block title %}
    Sale {{ sale.id }}
{% endblock %}

{% block content %}
<table>
    <thead>
        <th>Title</th>
        <th>Author</th>
        <th>Amount</th>
        <th>Price</th>
        <th></th>
    </thead>
    {% for book in sale.books %}
        <tr>
            <td>{{ book.title }}</td>
            <td>{{ book.author }}</td>
            <td>{{ book.stock }}</td>
            <td>{{ (book.price * book.stock)|number_format(2) }} €</td>
            <td><a href="/book/{{ book.id }}">View</a></td>
        </tr>
    {% endfor %}
</table>
{% endblock %}

错误模板

我们应该添加一个非常简单的模板,当我们的应用中出现错误时,将显示给用户,而不是显示 PHP 错误消息。这个模板只期望 errorMessage 变量,它可能看起来像以下这样。将其保存为 views/error.twig

{% extends 'layout.twig' %}

{% block title %}
    Error
{% endblock %}

{% block content %}
    <h2>Error: {{ errorMessage }}</h2>
{% endblock %}

注意,即使是错误页面也扩展自布局,因为我们希望用户在发生这种情况时能够做些其他事情。

登录模板

我们最后的模板将允许用户登录。这个模板与其他模板略有不同,因为它将在两种不同的场景中使用。在第一种情况下,用户首次访问登录视图,因此我们需要显示表单。在第二种情况下,用户已经尝试登录,但在登录过程中出现了错误,即找不到电子邮件地址。在这种情况下,我们将向模板添加一个额外的变量 errorMessage,并且我们将添加一个条件来显示其内容,仅当这个变量被定义时。你可以使用 is defined 操作符来检查。将以下模板作为 views/login.twig 添加:

{% extends 'layout.twig' %}

{% block title %}
    Login
{% endblock %}

{% block content %}
 {% if errorMessage is defined %}
        <strong>{{ errorMessage }}</strong>
    {% endif %}
    <form action="/login" method="post">
        <label>Email</label>
        <input type="text" name="email">
        <input type="submit">
    </form>
{% endblock %}

C 代表控制器

现在,轮到乐团指挥了。控制器代表我们的应用中这样一个层次:给定一个请求,与模型通信并构建视图。它们就像一个团队的经理:根据情况决定使用哪些资源。

正如我们在解释模型时所说的,有时很难决定某些逻辑是否应该放入控制器或模型中。最终,MVC 只是一个模式,就像一个指导你的食谱,而不是一个需要你一步一步遵循的精确算法。有些情况下答案并不直接,所以这取决于你;在这些情况下,只需尽量保持一致性。以下是一些可能难以本地化的常见场景:

  • 请求指向我们不支持的路径。这种情况在我们的应用中已经得到处理,并且应该由路由器来处理,而不是控制器。

  • 请求尝试访问一个不存在的元素,例如,数据库中不存在的书籍 ID。在这种情况下,控制器应该询问模型书籍是否存在,并根据响应渲染包含书籍内容的模板,或者渲染另一个包含“未找到”信息的模板。

  • 用户尝试执行一个动作,例如购买一本书,但请求中来的参数无效。这是一个棘手的问题。一个选项是获取请求中的所有参数而不进行检查,直接将它们发送到模型,并将清理信息的工作留给模型。另一个选项是控制器检查提供的参数是否合理,然后将它们交给模型。还有其他解决方案,例如构建一个检查参数是否有效的类,这个类可以在不同的控制器中重用。在这种情况下,它将取决于参数的数量和清理中涉及的逻辑。对于接收大量数据的请求,第三个选项看起来是其中最好的,因为我们将在不同的端点重用代码,并且我们没有编写过长的控制器。但在用户发送一个或两个参数的请求中,在控制器中清理它们可能就足够了。

现在我们已经奠定了基础,让我们准备我们的应用程序使用控制器。首先要做的是更新我们的index.php文件,它一直迫使应用程序始终渲染相同的模板。相反,我们应该将这个任务交给路由器,它将返回一个字符串作为响应,我们可以直接使用echo打印。使用以下内容更新您的index.php文件:

<?php

use Bookstore\Core\Router;
use Bookstore\Core\Request;

require_once __DIR__ . '/vendor/autoload.php';

$router = new Router();
$response = $router->route(new Request());
echo $response;

如您可能记得,路由器实例化一个控制器类,将请求对象发送到构造函数。但是控制器还有其他依赖项,例如模板引擎、数据库连接或配置读取器。尽管这不是最佳解决方案(我们将在下一节介绍依赖注入时对其进行改进),我们可以创建一个AbstractController,它将是所有控制器的父类,并将设置这些依赖项。将以下内容复制为src/Controllers/AbstractController.php

<?php

namespace Bookstore\Controllers;

use Bookstore\Core\Config;
use Bookstore\Core\Db;
use Bookstore\Core\Request;
use Monolog\Logger;
use Twig_Environment;
use Twig_Loader_Filesystem;
use Monolog\Handler\StreamHandler;

abstract class AbstractController {
    protected $request;
    protected $db;
    protected $config;
    protected $view;
    protected $log;

    public function __construct(Request $request) {
        $this->request = $request;
        $this->db = Db::getInstance();
        $this->config = Config::getInstance();

        $loader = new Twig_Loader_Filesystem(
            __DIR__ . '/../../views'
        );
        $this->view = new Twig_Environment($loader);

        $this->log = new Logger('bookstore');
        $logFile = $this->config->get('log');
        $this->log->pushHandler(
            new StreamHandler($logFile, Logger::DEBUG)
        );
    }

    public function setCustomerId(int $customerId) {
        $this->customerId = $customerId;
    }
}

在实例化控制器时,我们将设置一些在处理请求时有用的属性。我们已经知道如何实例化数据库连接、配置读取器和模板引擎。第四个属性$log将允许开发者在必要时将日志写入指定的文件。我们将使用 Monolog 库来完成这项工作,但还有许多其他选项。请注意,为了实例化记录器,我们从配置中获取日志的值,这应该是日志文件的路径。惯例是使用/var/log/目录,因此创建/var/log/bookstore.log文件,并将"log": "/var/log/bookstore.log"添加到您的配置文件中。

另一件对某些控制器有用的信息(但不是所有控制器)是关于执行动作的用户的信息。由于这仅适用于某些路由,我们不应该在构建控制器时设置它。相反,我们有一个 setter 用于路由器,当可用时设置客户 ID;实际上,路由器已经这样做了。

最后,一个方便的辅助方法是我们可以使用的一个,它使用参数渲染给定的模板,因为所有控制器最终都会渲染一个模板或另一个。让我们向AbstractController类添加以下受保护的方法:

protected function render(string $template, array $params): string {
    return $this->view->loadTemplate($template)->render($params);
}

错误控制器

让我们先创建最简单的控制器:ErrorController。这个控制器并没有做很多事情;它只是渲染error.twig模板,发送“页面未找到!”的消息。你可能还记得,当路由器无法匹配到其他定义的路由时,它会使用这个控制器。将以下类保存到src/Controllers/ErrorController.php中:

<?php

namespace Bookstore\Controllers;

class ErrorController extends AbstractController {
    public function notFound(): string {
        $properties = ['errorMessage' => 'Page not found!'];
        return $this->render('error.twig', $properties);
    }
}

登录控制器

我们必须添加的第二个控制器是管理客户登录的控制器。如果我们考虑用户想要进行身份验证时的流程,我们会遇到以下场景:

  • 用户想要获取登录表单,以便提交必要的信息并登录。

  • 用户试图提交表单,但我们无法获取电子邮件地址。我们应该再次渲染表单,并让他们知道问题所在。

  • 用户提交了带有电子邮件的表单,但不是一个有效的电子邮件。在这种情况下,我们应该再次显示登录表单,并带有解释情况的错误信息。

  • 用户提交了一个有效的电子邮件,我们设置了 cookie,并显示了书籍列表,以便用户可以开始搜索。这完全是任意的;你也可以选择将他们发送到他们借阅的书籍页面、他们的销售页面等。这里重要的是要注意,我们将请求重定向到另一个控制器。

有多达四种可能的路径。我们将使用request对象来决定在每种情况下使用哪一条路径,并返回相应的响应。因此,让我们在src/Controllers/CustomerController.php中创建CustomerController类,并添加login方法,如下所示:

<?php

namespace Bookstore\Controllers;

use Bookstore\Exceptions\NotFoundException;
use Bookstore\Models\CustomerModel;

class CustomerController extends AbstractController {
    public function login(string $email): string {
        if (!$this->request->isPost()) {
 return $this->render('login.twig', []);
        }

        $params = $this->request->getParams();

        if (!$params->has('email')) {
            $params = ['errorMessage' => 'No info provided.'];
 return $this->render('login.twig', $params);
        }

        $email = $params->getString('email');
        $customerModel = new CustomerModel($this->db);

        try {
            $customer = $customerModel->getByEmail($email);
        } catch (NotFoundException $e) {
            $this->log->warn('Customer email not found: ' . $email);
            $params = ['errorMessage' => 'Email not found.'];
 return $this->render('login.twig', $params);
        }

        setcookie('user', $customer->getId());

        $newController = new BookController($this->request);
 return $newController->getAll();
    }
}

如你所见,有四种不同的返回值对应于四种不同的情况。控制器本身并没有做什么,而是协调其他组件,并做出决策。首先,我们检查请求是否为 POST,如果不是,我们将假设用户想要获取表单。如果是,我们将检查参数中的电子邮件,如果电子邮件不存在,则返回错误。如果存在,我们将尝试使用我们的模型找到具有该电子邮件的客户。如果得到一个异常,表示没有这样的客户,我们将渲染带有“未找到”错误信息的表单。如果登录成功,我们将设置包含客户 ID 的 cookie,并执行BookControllergetAll方法(尚未编写),返回书籍列表。

到目前为止,你应该能够使用浏览器端到端测试你应用程序的登录功能。尝试访问 http://localhost:8000/login 来查看表单,添加随机的电子邮件以获取错误信息,并添加一个有效的电子邮件(检查 MySQL 中的customer表)以成功登录。之后,你应该能看到包含客户 ID 的 cookie。

书籍控制器

BookController类将是我们的控制器中最大的一个,因为大多数应用程序都依赖于它。让我们先添加最简单的函数,即只从数据库检索信息的函数。将其保存为src/Controllers/BookController.php

<?php

namespace Bookstore\Controllers;

use Bookstore\Models\BookModel;

class BookController extends AbstractController {
    const PAGE_LENGTH = 10;

    public function getAllWithPage($page): string {
        $page = (int)$page;
        $bookModel = new BookModel($this->db);

        $books = $bookModel->getAll($page, self::PAGE_LENGTH);

        $properties = [
            'books' => $books,
            'currentPage' => $page,
            'lastPage' => count($books) < self::PAGE_LENGTH
        ];
        return $this->render('books.twig', $properties);
    }

    public function getAll(): string {
        return $this->getAllWithPage(1);
    }

    public function get(int $bookId): string {
        $bookModel = new BookModel($this->db);

        try {
            $book = $bookModel->get($bookId);
        } catch (\Exception $e) {
            $this->log->error(
                'Error getting book: ' . $e->getMessage()
            );
            $properties = ['errorMessage' => 'Book not found!'];
            return $this->render('error.twig', $properties);
        }

        $properties = ['book' => $book];
        return $this->render('book.twig', $properties);
    }

    public function getByUser(): string {
        $bookModel = new BookModel($this->db);

        $books = $bookModel->getByUser($this->customerId);

        $properties = [
            'books' => $books,
            'currentPage' => 1,
            'lastPage' => true
        ];
        return $this->render('books.twig', $properties);
    }
}

到目前为止,这段前置代码并没有什么特别之处。getAllWithPagegetAll 方法执行相同的功能,一个通过用户提供的页面编号作为 URL 参数,另一个将页面编号设置为 1——默认情况。它们请求模型以获取要显示并传递给视图的书籍列表。当前页面的信息——以及我们是否在最后一页——也会发送到模板中,以便添加“上一页”和“下一页”的链接。

get 方法将获取客户感兴趣的书籍的 ID。它将尝试使用模型获取它。如果模型抛出异常,我们将渲染带有“书籍未找到”信息的错误模板。相反,如果书籍 ID 有效,我们将按预期渲染书籍模板。

getByUser方法将返回认证客户借阅的所有书籍。我们将使用从路由器设置的customerId属性。这里没有进行合理性检查,因为我们不是试图获取特定的书籍,而是一个列表,如果用户尚未借阅任何书籍,这个列表可能为空——但这不是问题。

另一个获取器控制器是搜索书籍标题和/或作者的方法。当用户在布局模板中提交表单时,将触发此方法。表单发送 titleauthor 字段,因此控制器将请求这两个字段。模型已经准备好使用空参数,因此我们在这里不会进行任何额外的检查。将方法添加到 BookController 类中:

public function search(): string {
    $title = $this->request->getParams()->getString('title');
    $author = $this->request->getParams()->getString('author');

    $bookModel = new BookModel($this->db);
    $books = $bookModel->search($title, $author);

    $properties = [
        'books' => $books,
        'currentPage' => 1,
        'lastPage' => true
    ];
    return $this->render('books.twig', $properties);
}

您的应用程序无法执行任何操作,但至少您最终可以浏览书籍列表,并点击其中任何一本书查看详细信息。我们终于有所收获了!

借阅书籍

借阅和归还书籍可能是涉及最多逻辑的操作,与购买书籍一起,将由不同的控制器处理。这是一个开始记录用户操作的好地方,因为这对于以后的调试非常有用。让我们先看看代码,然后再简要讨论一下。向您的 BookController 类添加以下两个方法:

public function borrow(int $bookId): string {
    $bookModel = new BookModel($this->db);

    try {
        $book = $bookModel->get($bookId);
    } catch (NotFoundException $e) {
 $this->log->warn('Book not found: ' . $bookId);
        $params = ['errorMessage' => 'Book not found.'];
        return $this->render('error.twig', $params);
    }

    if (!$book->getCopy()) {
        $params = [
            'errorMessage' => 'There are no copies left.'
       ];
        return $this->render('error.twig', $params);
    }

    try {
        $bookModel->borrow($book, $this->customerId);
    } catch (DbException $e) {
 $this->log->error(
 'Error borrowing book: ' . $e->getMessage()
        );
        $params = ['errorMessage' => 'Error borrowing book.'];
        return $this->render('error.twig', $params);
    }

    return $this->getByUser();
}

public function returnBook(int $bookId): string {
    $bookModel = new BookModel($this->db);

    try {
        $book = $bookModel->get($bookId);
    } catch (NotFoundException $e) {
 $this->log->warn('Book not found: ' . $bookId);
        $params = ['errorMessage' => 'Book not found.'];
        return $this->render('error.twig', $params);
    }

    $book->addCopy();

    try {
        $bookModel->returnBook($book, $this->customerId);
    } catch (DbException $e) {
 $this->log->error(
 'Error returning book: ' . $e->getMessage()
        );
        $params = ['errorMessage' => 'Error returning book.'];
        return $this->render('error.twig', $params);
    }

    return $this->getByUser();
}

正如我们之前提到的,这里的新功能之一是我们正在记录用户操作,例如尝试借阅或归还无效的书籍。Monolog 允许您使用不同的优先级级别写入日志:错误、警告和通知。您可以使用 errorwarnnotice 等方法来引用它们。当发生意外但非关键的情况时,我们会使用警告,例如尝试借阅不存在的书籍。当出现我们无法恢复的未知问题时,我们会使用错误,例如数据库错误。

这两个方法的操作模式如下:我们从数据库中根据给定的书籍 ID 获取 book 对象。通常情况下,如果没有这样的书籍,我们会返回一个错误页面。一旦我们有了 book 领域对象,我们就使用 addCopygetCopy 等辅助器来更新书籍库存,并将其连同客户 ID 一起发送到模型,以便在数据库中存储信息。在借阅书籍时,我们也会进行一个合理性检查,以防没有更多的书籍可用。在这两种情况下,我们都将用户已借阅的书籍列表作为控制器的响应返回。

销售控制器

我们来到了最后一个控制器:SalesController。由于使用了不同的模型,它最终将执行与借阅书籍相关的方法几乎相同的功能。但我们需要在控制器中创建 sale 领域对象,而不是从模型中获取它。让我们添加以下代码,其中包含一个购买书籍的方法 add 和两个获取器:一个获取特定用户的全部销售记录,另一个获取特定销售的信息,即 getByUserget。按照惯例,文件将是 src/Controllers/SalesController.php

<?php

namespace Bookstore\Controllers;

use Bookstore\Domain\Sale;
use Bookstore\Models\SaleModel;

class SalesController extends AbstractController {
    public function add($id): string {
        $bookId = (int)$id;
        $salesModel = new SaleModel($this->db);

        $sale = new Sale();
        $sale->setCustomerId($this->customerId);
        $sale->addBook($bookId);

        try {
            $salesModel->create($sale);
        } catch (\Exception $e) {
            $properties = [
                'errorMessage' => 'Error buying the book.'
           ];
            $this->log->error(
                'Error buying book: ' . $e->getMessage()
            );
            return $this->render('error.twig', $properties);
        }

        return $this->getByUser();
    }

    public function getByUser(): string {
        $salesModel = new SaleModel($this->db);

        $sales = $salesModel->getByUser($this->customerId);

        $properties = ['sales' => $sales];
        return $this->render('sales.twig', $properties);
    }

    public function get($saleId): string {
        $salesModel = new SaleModel($this->db);

        $sale = $salesModel->get($saleId);

        $properties = ['sale' => $sale];
        return $this->render('sale.twig', $properties);
    }
}

依赖注入

在本章结束时,我们将介绍与 MVC 模式以及一般 OOP 相关的最有趣和最具争议的话题之一:依赖注入。我们将向你展示为什么它如此重要,以及如何实施一个适合我们特定应用的解决方案,尽管有相当多的不同实现可以满足不同的需求。

为什么需要依赖注入?

我们仍然需要介绍如何对代码进行单元测试,因此你还没有亲自体验过。但潜在问题来源的一个迹象是在你的代码中使用new语句创建不属于你的代码库的类的实例——也称为依赖项。使用new创建域对象,如BookSale是可以的。用它来实例化模型也是可以接受的。但手动实例化其他东西,比如模板引擎、数据库连接或记录器,是你应该避免的。有不同理由支持这个观点:

  • 如果你想在两个不同的地方使用控制器,并且每个地方都需要不同的数据库连接或日志文件,那么在控制器内部实例化这些依赖项将不允许我们这样做。同一个控制器将始终使用相同的依赖项。

  • 在控制器内部实例化依赖项意味着控制器完全了解每个依赖项的具体实现,也就是说,控制器知道我们正在使用 PDO 和 MySQL 驱动程序,以及连接凭证的位置。这意味着你的应用程序耦合度很高——所以,坏消息。

  • 如果你在每个地方都显式实例化依赖项,那么用实现相同接口的另一个依赖项替换一个依赖项并不容易,因为你将不得不搜索所有这些地方,并手动更改实例化。

由于所有这些原因,以及更多原因,总是提供控制器等类需要的依赖项,而不是让它自己创建,这是一个大家都能接受的观点。问题在于实施解决方案。有几种不同的选择:

  • 我们有一个期望(通过参数)所有控制器或任何其他类需要的依赖项的构造函数。构造函数将每个参数分配给类的属性。

  • 我们有一个空构造函数,而不是添加与类的依赖项一样多的 setter 方法。

  • 这是一种混合方式,我们通过构造函数设置主要依赖项,其余依赖项则通过 setter 设置。

  • 将包含所有依赖项的对象作为唯一参数传递给构造函数,控制器从该容器中获取它需要的依赖项。

每个解决方案都有其优缺点。如果我们有一个具有许多依赖的类,通过构造函数注入所有这些依赖会使它难以理解,所以最好使用 setter 来注入它们,即使一个具有许多依赖的类看起来像是一个糟糕的设计。如果我们只有一个或两个依赖,使用构造函数可能是可以接受的,而且我们会写更少的代码。对于具有几个依赖但并非所有依赖都是强制的类,使用混合版本可能是一个好的解决方案。第四个选项在注入依赖时更简单,因为我们不需要知道每个对象期望什么。问题是每个类都应该知道如何获取其依赖,即依赖名称,这并不理想。

实现我们自己的依赖注入器

对于依赖注入器的开源解决方案已经可用,但我们认为亲自实现一个简单的依赖注入器会是一个很好的体验。我们的依赖注入器的想法是一个包含代码所需依赖实例的类。这个类,基本上是一个依赖名称到依赖实例的映射,将有两个方法:依赖的获取器和设置器。我们不想使用静态属性作为依赖数组,因为其中一个目标是有能力拥有多个具有不同依赖集的依赖注入器。将以下类添加到src/Utils/DependencyInjector.php

<?php

namespace Bookstore\Utils;

use Bookstore\Exceptions\NotFoundException;

class DependencyInjector {
    private $dependencies = [];

    public function set(string $name, $object) {
        $this->dependencies[$name] = $object;
    }

    public function get(string $name) {
        if (isset($this->dependencies[$name])) {
            return $this->dependencies[$name];
        }
        throw new NotFoundException(
            $name . ' dependency not found.'
        );
    }
}

拥有一个依赖注入器意味着每次我们请求一个给定类的实例时,我们都会使用相同的实例,而不是每次都创建一个新的实例。这意味着不再需要单例实现;实际上,正如在第四章中提到的,《使用面向对象编程创建整洁的代码》,避免它们是更好的选择。让我们摆脱它们吧。我们使用它的一个地方是在我们的配置读取器中。在src/Core/Config.php文件中将现有代码替换为以下代码:

<?php

namespace Bookstore\Core;

use Bookstore\Exceptions\NotFoundException;

class Config {
    private $data;

    public function __construct() {
        $json = file_get_contents(
            __DIR__ . '/../../config/app.json'
        );
        $this->data = json_decode($json, true);
    }

    public function get($key) {
        if (!isset($this->data[$key])) {
            throw new NotFoundException("Key $key not in config.");
        }
        return $this->data[$key];
    }
}

我们使用单例模式的另一个地方是在DB类中。实际上,这个类的作用只是为我们提供一个数据库连接的单例,但如果我们没有使用它,我们可以删除整个类。所以,删除你的src/Core/DB.php文件。

现在我们需要定义所有这些依赖并将它们添加到我们的依赖注入器中。在路由请求之前,index.php文件是一个放置依赖注入器的好地方。在实例化Router类之前添加以下代码:

$config = new Config();

$dbConfig = $config->get('db');
$db = new PDO(
    'mysql:host=127.0.0.1;dbname=bookstore',
    $dbConfig['user'],
    $dbConfig['password']
);

$loader = new Twig_Loader_Filesystem(__DIR__ . '/../../views');
$view = new Twig_Environment($loader);

$log = new Logger('bookstore');
$logFile = $config->get('log');
$log->pushHandler(new StreamHandler($logFile, Logger::DEBUG));

$di = new DependencyInjector();
$di->set('PDO', $db);
$di->set('Utils\Config', $config);
$di->set('Twig_Environment', $view);
$di->set('Logger', $log);

$router = new Router($di);
//...

现在我们需要做一些修改。其中最重要的修改是关于AbstractController类,这个类将会大量使用依赖注入器。给这个类添加一个名为$di的属性,并用以下代码替换构造函数:

public function __construct(
    DependencyInjector $di,
    Request $request
) {
    $this->request = $request;
    $this->di = $di;

    $this->db = $di->get('PDO');
    $this->log = $di->get('Logger');
    $this->view = $di->get('Twig_Environment');
    $this->config = $di->get('Utils\Config');

    $this->customerId = $_COOKIE['id'];
}

其他更改涉及Router类,因为我们现在将其作为构造函数的一部分发送,我们需要将其注入到我们创建的控制器中。给这个类添加一个$di属性,并将构造函数更改为以下形式:

public function __construct(DependencyInjector $di) {
    $this->di = $di;

    $json = file_get_contents(__DIR__ . '/../../config/routes.json');
    $this->routeMap = json_decode($json, true);
}

还需要更改executeControllerroute方法的内容:

public function route(Request $request): string {
    $path = $request->getPath();

    foreach ($this->routeMap as $route => $info) {
        $regexRoute = $this->getRegexRoute($route, $info);
        if (preg_match("@^/$regexRoute$@", $path)) {
            return $this->executeController(
                $route, $path, $info, $request
            );
        }
    }

 $errorController = new ErrorController(
 $this->di,
 $request
 );
    return $errorController->notFound();
}

private function executeController(
    string $route,
    string $path,
    array $info,
    Request $request
): string {
    $controllerName = '\Bookstore\Controllers\\' 
        . $info['controller'] . 'Controller';
 $controller = new $controllerName($this->di, $request);

    if (isset($info['login']) && $info['login']) {
        if ($request->getCookies()->has('user')) {
            $customerId = $request->getCookies()->get('user');
            $controller->setCustomerId($customerId);
        } else {
 $errorController = new CustomerController(
 $this->di,
 $request
 );
            return $errorController->login();
        }
    }

    $params = $this->extractParams($route, $path);
    return call_user_func_array(
        [$controller, $info['method']], $params
    );
}

你还需要更改一个地方。CustomerControllerlogin方法也在实例化控制器,所以我们也需要在那里注入依赖注入器:

$newController = new BookController($this->di, $this->request);

摘要

在本章中,你学习了什么是 MVC,以及如何编写遵循该模式的程序。你还知道了如何使用路由器将请求路由到控制器,使用 Twig 编写模板,以及使用 Composer 管理你的依赖和自动加载器。你被介绍了依赖注入,甚至自己构建了一个实现,尽管这是一个非常有争议的话题,有很多人不同的观点。

在下一章中,我们将探讨编写良好代码和应用程序时最需要的部分之一:对你的代码进行单元测试以获得快速的反馈。

第七章:测试 Web 应用程序

当你谈论应用程序时,你很可能已经听说过“bug”这个词。像“我们在应用程序中发现了这样的 bug,…”然后是一些非常不希望出现的行为这样的句子比你想象的要常见。编写代码并不是开发者的唯一任务;测试同样至关重要。你不应该发布未经测试的应用程序版本。然而,你能想象每次修改一行代码时都必须测试整个应用程序吗?那将是一场噩梦!

嗯,我们不是第一个遇到这个问题的人,所以幸运的是,开发者已经找到了一个相当好的解决方案来解决这个问题。事实上,他们找到了不止一个解决方案,使测试成为了一个非常热门的讨论话题。甚至测试开发者已经变成了一个相当常见的角色。在本章中,我们将向你介绍测试代码的一种方法:单元测试。

在本章中,你将了解:

  • 单元测试的工作原理

  • 配置 PHPUnit 以测试你的代码

  • 使用断言、数据提供者和模拟编写测试

  • 编写单元测试时的好习惯和坏习惯

测试的必要性

当你在一个项目上工作时,你很可能不是唯一一个会使用这段代码的开发者。即使在你是唯一一个会修改它的案例中,如果你在创建它几周后进行修改,你很可能不会记得所有受这段代码影响的地方。好吧,让我们假设你是唯一一个开发者,你的记忆力是超乎常人的;你能否验证对常用对象(如请求)的更改始终按预期工作?更重要的是,你愿意每次进行微小更改时都这样做吗?

测试类型

当你在编写应用程序、修改现有代码或添加新功能时,获得良好的反馈非常重要。你怎么知道你得到的反馈是否足够好?它应该实现 AEIOU 原则:

  • 自动化: 获取反馈应该尽可能不痛苦。通过运行一条命令来获取反馈总是比手动测试应用程序更可取。

  • 广泛性: 我们应该尽可能覆盖尽可能多的用例,包括在编写代码时难以预见的边缘情况。

  • 即时性: 你应该尽快得到它。这意味着你在引入更改后立即得到的反馈要比你的代码在生产环境中得到的反馈要好得多。

  • 开放性: 结果应该是透明的,而且,测试应该让我们了解其他开发者如何集成或操作代码。

  • 实用性: 它应该回答诸如“这个更改会起作用吗?”、“它会不会意外地破坏应用程序?”或“有没有不正常工作的边缘情况?”等问题。

因此,尽管一开始这个概念相当奇怪,但测试你的代码的最佳方式是…用更多的代码。没错!我们将编写代码,目的是测试我们应用程序的代码。为什么?因为这是我们已知的最能满足所有 AEIU 原则的方法,并且它有以下优点:

  • 我们可以通过在命令行或我们喜欢的 IDE 中运行一个命令来执行测试。没有必要不断地通过浏览器手动测试你的应用程序。

  • 我们只需要编写一次测试。一开始,可能会有些痛苦,但一旦代码编写完成,你就不需要反复重复它了。这意味着经过一些工作后,我们将能够轻松地测试每一个单独的情况。如果我们不得不手动测试,包括所有用例和边缘情况,那将是一场噩梦。

  • 你不需要整个应用程序都运行正常才能知道你的代码是否工作。想象一下你正在编写你的路由器:为了知道它是否工作,你必须等到你的应用程序在浏览器中工作。相反,你可以在完成课程后立即编写测试并运行它们。

  • 在编写测试时,你将获得关于哪些测试失败的反馈。当路由器的特定功能不工作以及失败的原因时,这非常有用,这比在浏览器上收到 500 错误要好得多。

我们希望到现在你已经接受了编写测试是不可或缺的想法。虽然这很简单,但问题是我们知道几种不同的方法。我们是编写测试来测试整个应用程序,还是测试特定部分?我们在测试时是否要隔离测试区域?我们是否希望在测试时与数据库或其他外部资源交互?根据你的回答,你将决定你想编写哪种类型的测试。让我们讨论开发者一致同意的三个主要方法:

  • 单元测试:这些测试具有非常集中的范围。它们的目的是测试单个类或方法,将它们从其他代码中隔离出来。以你的Sale域类为例:它有关书籍添加的一些逻辑,对吧?一个单元测试可能只是实例化一个新的销售,向对象添加书籍,并验证书籍数组是否有效。由于它们的范围缩小,单元测试非常快,因此你可以轻松地拥有几个不同功能的不同场景,覆盖你所能想象的所有边缘情况。它们也是隔离的,这意味着我们不会太关心我们应用程序的所有部分是如何集成的。相反,我们将确保每个部分都工作得非常好。

  • 集成测试:这些测试的范围更广。它们的目的是验证你的应用程序的所有部件是否能够协同工作,因此它们的范围不仅限于一个类或函数,而是包括一组类或整个应用程序。如果我们不想使用真实的数据库或依赖其他外部 Web 服务,仍然有一些隔离。在我们应用程序中的一个例子是模拟一个Request对象,将其发送到路由器,并验证响应是否符合预期。

  • 验收测试:这些测试的范围更广。它们试图从用户的角度测试整个功能。在 Web 应用程序中,这意味着我们可以启动一个浏览器并模拟用户会进行的点击,每次都在浏览器中断言响应。是的,所有这些都可以通过代码来实现!正如你所想象的那样,这些测试运行起来较慢,因为它们的范围更广,而且与浏览器一起工作也会使它们变慢很多。

那么,有了所有这些测试类型,你应该编写哪一个呢?答案是全部都要。技巧在于知道何时以及每种类型应该编写多少。一种好的方法是编写大量的单元测试,覆盖你代码中的所有内容,然后编写较少的集成测试来确保你的应用程序的所有组件都能协同工作,最后编写验收测试,但只测试应用程序的主要流程。以下测试金字塔图展示了这个想法:

测试类型

原因很简单:你真正的反馈将来自你的单元测试。一旦你完成编写,它们就会告诉你是否因为你的更改而搞错了什么,因为执行单元测试既简单又快捷。一旦你知道所有你的类和函数都按预期工作,你需要验证它们能否协同工作。然而,为此,你不需要再次测试所有边缘情况;你已经在编写单元测试时这样做过了。在这里,你需要编写仅几个集成测试来确认所有部件都能正确通信。最后,为了确保代码不仅能够工作,用户体验也是预期的,我们将编写验收测试来模拟用户遍历不同的视图。在这里,测试非常慢,并且只有在流程完成之后才可能进行,因此反馈来得较晚。我们将添加验收测试以确保主要流程正常工作,但不需要像集成和单元测试那样测试每一个单独的场景。

单元测试和代码覆盖率

现在你已经知道了什么是测试,为什么我们需要它们,以及我们有哪几种测试类型,我们将把本章的剩余部分集中在编写好的单元测试上,因为它们将是占据你大部分时间的测试。

正如我们之前解释的,单元测试的想法是确保一段代码(通常是一个类或方法)按预期工作。由于一个方法包含的代码量应该很小,运行测试应该几乎不需要时间。利用这一点,我们将运行多个测试,试图覆盖尽可能多的用例。

如果你不是第一次听说单元测试,你可能知道代码覆盖率的概念。这个概念指的是我们的测试执行的代码量,即测试代码的百分比。例如,如果你的应用程序有 10,000 行代码,而你的测试总共测试了 7,500 行代码,那么你的代码覆盖率是 75%。有一些工具会在你的代码上显示标记,以指示某一行是否被测试,这在确定你的应用程序哪些部分没有被测试以及警告你更改它们可能更危险时非常有用。

然而,代码覆盖率是一把双刃剑。为什么是这样呢?这是因为开发者往往会沉迷于代码覆盖率,目标是达到 100%的覆盖率。然而,你应该意识到代码覆盖率只是一个结果,而不是你的目标。你的目标是编写单元测试,以验证某些代码片段的所有用例,以便每次你不得不更改此代码时都让你感到更安全。这意味着对于给定的方法,可能只写一个测试是不够的,因为相同的行在不同的输入值下可能表现不同。然而,如果你的重点是代码覆盖率,写一个测试就会满足它,你可能不需要再写更多的测试。

集成 PHPUnit

编写测试是一个你可以自己完成的任务;你只需要编写当条件不满足时抛出异常的代码,然后在你需要的时候运行脚本。幸运的是,其他开发者对这种手动过程并不满意,所以他们实现了工具来帮助我们自动化这个过程并获得良好的反馈。在 PHP 中最常用的工具是PHPUnit。PHPUnit 是一个框架,它提供了一套工具,使我们能够以更简单的方式编写测试,能够自动运行测试,并向开发者提供有用的反馈。

为了使用 PHPUnit,传统上,我们在我们的笔记本电脑上安装它。这样做,我们将框架的类添加到包含 PHP 路径的路径中,并将可执行文件添加到运行测试的路径中。这并不理想,因为我们强迫开发者在他们的发展机器上安装一个额外的工具。如今,Composer(参考第六章,适应 MVC,以刷新你的记忆)帮助我们包括 PHPUnit 作为项目的依赖项。这意味着运行 Composer(你肯定会这样做,以获取其余的依赖项),也会得到 PHPUnit。然后,将以下内容添加到composer.json中:

{
//...
    "require": {
        "monolog/monolog": "¹.17",
        "twig/twig": "¹.23"
    },
 "require-dev": {
 "phpunit/phpunit": "5.1.3"
 },
    "autoload": {
        "psr-4": {
            "Bookstore\\": "src"
        }
    }
}

注意,这个依赖项是以require-dev的方式添加的。这意味着只有在开发环境中,我们才会下载这个依赖项,但在我们部署到生产环境的应用程序中,它不会成为一部分,因为我们不需要在那里运行测试。要获取依赖项,像往常一样,运行composer update

另一种方法是全局安装 PHPUnit,这样你开发环境中的所有项目都可以使用它,而不是每次都本地安装。你可以在akrabat.com/global-installation-of-php-tools-with-composer/上阅读有关如何使用 Composer 全局安装工具的说明。

phpunit.xml 文件

PHPUnit 需要一个phpunit.xml文件来定义我们想要运行测试的方式。这个文件定义了一系列规则,比如测试在哪里,测试在测试什么代码,等等。在你的根目录中添加以下文件:

<?xml version="1.0" encoding="UTF-8"?>

<phpunit backupGlobals="false"
         backupStaticAttributes="false"
         colors="true"
         convertErrorsToExceptions="true"
         convertNoticesToExceptions="true"
         convertWarningsToExceptions="true"
         processIsolation="false"
         stopOnFailure="false"
         syntaxCheck="false"
 bootstrap="vendor/autoload.php"
>
<testsuites>
<testsuite name="Bookstore Test Suite">
<directory>./tests/</directory>
</testsuite>
</testsuites>
<filter>
<whitelist>
<directory>./src</directory>
</whitelist>
</filter>
</phpunit>

这个文件定义了很多东西。以下是最重要的解释:

  • convertErrorsToExceptionsconvertNoticesToExceptionsconvertWarningsToExceptions设置为true会使你的测试在出现 PHP 错误、警告或通知时失败。目标是确保你的代码在边缘情况下不包含小错误,这些错误总是潜在问题的来源。

  • stopOnFailure告诉 PHPUnit 在出现失败的测试时是否应该继续执行剩余的测试。在这种情况下,我们希望运行所有测试,以了解有多少测试失败以及为什么。

  • bootstrap定义了在开始运行测试之前我们应该执行哪个文件。最常见的使用方法是包含自动加载器,但你也可以包含一个初始化一些依赖项的文件,例如数据库或配置读取器。

  • testsuites 定义了 PHPUnit 将查找测试的目录。在我们的例子中,我们定义了./tests,如果我们有其他目录的话,我们还可以添加更多。

  • whitelist定义了包含我们正在测试的代码的目录列表。这可以用来生成与代码覆盖率相关的输出。

当使用 PHPUnit 运行测试时,只需确保你从phpunit.xml文件所在的目录运行命令。我们将在下一节中向你展示如何操作。

你的第一个测试

好的,准备工作和技术理论就到这里;让我们写一些代码。我们将为基本的客户编写测试,这是一个具有少量逻辑的领域对象。首先,我们需要重构Unique特质,因为它在将我们的应用程序与 MySQL 集成后仍然包含一些不必要的代码。我们谈论的是分配下一个可用 ID 的能力,现在这由自增字段处理。移除它,代码如下:

<?php

namespace Bookstore\Utils;

trait Unique {
    protected $id;

    public function setId(int $id) {
        $this->id = $id;
    }

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

测试将位于 tests/ 目录中。目录结构应该与 src/ 目录中的结构相同,这样更容易识别每个测试应该在哪里。文件和类名需要以 Test 结尾,这样 PHPUnit 才知道一个文件包含测试。了解这一点后,我们的测试应该在 tests/Domain/Customer/BasicTest.php 中,如下所示:

<?php

namespace Bookstore\Tests\Domain\Customer;

use Bookstore\Domain\Customer\Basic;
use PHPUnit_Framework_TestCase;

class BasicTest extends PHPUnit_Framework_TestCase {
    public function testAmountToBorrow() {
        $customer = new Basic(1, 'han', 'solo', 'han@solo.com');

 $this->assertSame(
            3,
            $customer->getAmountToBorrow(),
            'Basic customer should borrow up to 3 books.'
        );
    }
}

如您所注意到的,BasicTest 类继承自 PHPUnit_Framework_TestCase。所有测试类都必须从这个类继承。这个类提供了一套方法,允许你进行断言。在 PHPUnit 中,断言只是对一个值进行的检查。断言可以是与其他值的比较,对值的某些属性的验证,等等。如果断言不成立,测试将被标记为失败,并向开发者输出适当的错误消息。示例显示了使用 assertSame 方法的断言,它将比较两个值,期望它们完全相同。第三个参数是断言失败时将显示的错误消息。

此外,请注意,以 test 开头的函数名是使用 PHPUnit 执行的。在这个例子中,我们有一个唯一的测试名为 testAmountToBorrow,它实例化了一个基本客户并验证客户可以借阅的书籍数量为 3。在下一节中,我们将向您展示如何运行此测试并从中获取反馈。

可选地,如果你在方法的 DocBlock 中添加了 @test 注解,可以使用任何函数名,如下所示:

/**
 * @test
 */
public function thisIsATestToo() {
  //...
}

运行测试

为了运行你编写的测试,你需要执行 Composer 生成的 vendor/bin 中的脚本。请记住,始终从项目的根目录运行,以便 PHPUnit 可以找到你的 phpunit.xml 配置文件。然后,输入 ./vendor/bin/phpunit

运行测试

当执行此程序时,我们将得到测试给出的反馈。输出显示我们有一个测试(一个方法)和一个断言,以及这些是否令人满意。这正是你每次运行测试时希望看到的结果,但你可能会得到比预期更多的失败测试。让我们通过添加以下测试来查看它们:

public function testFail() {
    $customer = new Basic(1, 'han', 'solo', 'han@solo.com');

    $this->assertSame(
        4,
        $customer->getAmountToBorrow(),
        'Basic customer should borrow up to 3 books.'
    );
}

此测试将失败,因为我们正在检查 getAmountToBorrow 是否返回 4,但你知道它总是返回 3。让我们运行测试并查看我们得到什么样的输出。

运行测试

我们可以快速注意到输出不好,因为红色。它显示我们有一个失败,指向失败的类和测试方法。反馈指出失败类型(因为 3 不等于 4)以及可选的错误消息,我们在调用 assert 方法时添加了它。

编写单元测试

让我们开始深入了解 PHPUnit 为我们提供的所有功能,以便编写测试。我们将将这些功能分为不同的子部分:设置测试、断言、异常和数据提供者。当然,您不需要在每次编写测试时都使用所有这些工具。

测试的开始和结束

PHPUnit 为您提供了在每个类中的测试中设置共同场景的机会。为此,您需要使用setUp方法,如果存在,则每次执行此类测试时都会执行。调用setUptest方法的类实例是相同的,因此您可以使用类的属性来保存上下文。一个常见的用途是创建我们将用于测试的对象,如果这个对象始终相同的话。例如,在tests/Domain/Customer/BasicTest.php中编写以下代码:

<?php

namespace Bookstore\Tests\Domain\Customer;

use Bookstore\Domain\Customer\Basic;
use PHPUnit_Framework_TestCase;

class BasicTest extends PHPUnit_Framework_TestCase {
    private $customer;

 public function setUp() {
 $this->customer = new Basic(
 1, 'han', 'solo', 'han@solo.com'
 );
 }

    public function testAmountToBorrow() {
        $this->assertSame(
            3,
            $this->customer->getAmountToBorrow(),
            'Basic customer should borrow up to 3 books.'
        );
    }
}

当调用testAmountToBorrow时,$customer属性已经通过setUp方法的执行而初始化。如果类中有多个测试,setUp方法会在每次测试时执行。

尽管使用较少,但在测试执行后清理场景的另一种方法是tearDown。它的工作方式相同,但会在执行此类的每个测试之后执行。可能的用途包括清理数据库数据、关闭连接、删除文件等。

断言

您已经了解了断言的概念,所以让我们只列出本节中最常见的断言。对于完整的列表,我们建议您访问官方文档phpunit.de/manual/current/en/appendixes.assertions.html,因为它相当详尽;然而,说实话,您可能不会使用其中很多。

我们将看到的第一个断言类型是布尔断言,即检查一个值是true还是false。这些方法非常简单,如assertTrueassertFalse,它们期望一个参数,即要断言的值,以及可选的失败时显示的文本。在同一个BasicTest类中,添加以下测试:

public function testIsExemptOfTaxes() {
 $this->assertFalse(
 $this->customer->isExemptOfTaxes(),
 'Basic customer should be exempt of taxes.'
 );
}

此测试确保基本客户永远不会免税。注意,我们可以通过以下方式执行相同的断言:

$this->assertSame(
    $this->customer->isExemptOfTaxes(),
    false,
    'Basic customer should be exempt of taxes.'
);

另一组断言将是比较断言。最著名的是assertSameassertEquals。您已经使用了第一个,但您确定其含义吗?让我们添加另一个测试并运行它:

public function testGetMonthlyFee() {
 $this->assertSame(
 5,
 $this->customer->getMonthlyFee(),
 'Basic customer should pay 5 a month.'
 );
}

测试的结果显示在以下屏幕截图中:

断言

测试失败了!原因是assertSame等同于使用身份比较,即不使用类型转换。getMonthlyFee方法的结果始终是浮点数,我们将它与一个整数进行比较,所以它永远不会相同,正如错误信息所告诉我们的。将断言更改为assertEquals,它使用相等性进行比较,这样测试就会通过。

当与对象一起工作时,我们可以使用断言来检查给定的对象是否是预期类的实例。在这样做的时候,请记住发送类的全名,因为这是一个相当常见的错误。更好的是,你可以使用::class获取类名,例如,Basic::class。在tests/Domain/Customer/CustomerFactoryTest.php中添加以下测试:

<?php

namespace Bookstore\Tests\Domain\Customer;

use Bookstore\Domain\Customer\CustomerFactory;
use PHPUnit_Framework_TestCase;

class CustomerFactoryTest extends PHPUnit_Framework_TestCase {
    public function testFactoryBasic() {
        $customer = CustomerFactory::factory(
            'basic', 1, 'han', 'solo', 'han@solo.com'
        );

 $this->assertInstanceOf(
Basic::class,
 $customer,
 'basic should create a Customer\Basic object.'
 );
    }
}

此测试使用customer工厂创建客户。由于客户类型是basic,结果应该是一个Basic实例,这是我们使用assertInstanceOf进行测试的。第一个参数是预期的类,第二个参数是我们正在测试的对象,第三个参数是错误信息。此测试还帮助我们注意比较断言与对象的行为。让我们创建一个预期的基本customer对象,并将其与工厂的结果进行比较。然后,按照以下方式运行测试:

$expectedBasicCustomer = new Basic(1, 'han', 'solo', 'han@solo.com');

$this->assertSame(
    $customer,
    $expectedBasicCustomer,
    'Customer object is not as expected.'
);

此测试的结果如下所示:

断言

测试失败是因为当你使用身份比较来比较两个对象时,你实际上是在比较对象引用,只有当两个对象是完全相同的实例时,它们才会相同。如果你创建了具有相同属性的两个对象,它们将是相等的,但永远不会相同。为了修复测试,请按以下方式更改断言:

$expectedBasicCustomer = new Basic(1, 'han', 'solo', 'han@solo.com');

$this->assertEquals(
    $customer,
    $expectedBasicCustomer,
    'Customer object is not as expected.'
);

现在我们来编写sale域对象的测试,在tests/Domain/SaleTest.php。这个类非常容易测试,并允许我们使用一些新的断言,如下所示:

<?php

namespace Bookstore\Tests\Domain\Customer;

use Bookstore\Domain\Sale;
use PHPUnit_Framework_TestCase;

class SaleTest extends PHPUnit_Framework_TestCase {
    public function testNewSaleHasNoBooks() {
        $sale = new Sale();

 $this->assertEmpty(
 $sale->getBooks(),
 'When new, sale should have no books.'
 );
    }

    public function testAddNewBook() {
        $sale = new Sale();
        $sale->addBook(123);

 $this->assertCount(
 1,
 $sale->getBooks(),
 'Number of books not valid.'
 );
 $this->assertArrayHasKey(
 123,
 $sale->getBooks(),
 'Book id could not be found in array.'
 );
        $this->assertSame(
            $sale->getBooks()[123],
            1,
            'When not specified, amount of books is 1.'
        );
    }
}

我们在这里添加了两个测试:一个确保对于一个新的sale实例,与之关联的书籍列表为空。为此,我们使用了assertEmpty方法,它接受一个数组作为参数,并断言它是空的。第二个测试是在销售中添加一本书,然后确保书籍列表具有正确的内容。为此,我们将使用assertCount方法,它验证数组(即第二个参数)具有与提供的第一个参数一样多的元素。在这种情况下,我们期望书籍列表只有一个条目。此测试的第二个断言是使用assertArrayHasKey方法验证书籍数组是否包含一个特定的键,即书籍的 ID。在assertArrayHasKey方法中,第一个参数是键,第二个参数是数组。最后,我们将使用已知的assertSame方法检查插入的书籍数量是否为 1。

尽管这两个新的断言方法有时很有用,但最后一个测试的所有三个断言都可以用一个 assertSame 方法来替换,比较整个书籍数组与期望的一个,如下所示:

$this->assertSame(
    [123 => 1],
    $sale->getBooks(),
    'Books array does not match.'
);

如果我们不测试类在添加多本书时的行为,那么 sale 领域对象的测试套件将是不够的。在这种情况下,使用 assertCountassertArrayHasKey 会使测试变得不必要地长,所以让我们通过以下代码比较数组与期望的一个:

public function testAddMultipleBooks() {
    $sale = new Sale();
    $sale->addBook(123, 4);
    $sale->addBook(456, 2);
    $sale->addBook(456, 8);

    $this->assertSame(
        [123 => 4, 456 => 10],
        $sale->getBooks(),
        'Books are not as expected.'
    );
}

预期异常

有时候,一个方法预期会在某些意外的使用情况下抛出异常。当这种情况发生时,你可以在测试中捕获这个异常,或者利用 PHPUnit 提供的另一个工具:预期异常。为了标记一个测试期望一个特定的异常,只需添加 @expectedException 注解,后跟异常的全名。可选地,你可以使用 @expectedExceptionMessage 来断言异常的消息。让我们向我们的 CustomerFactoryTest 类添加以下测试:

/**
 * @expectedException \InvalidArgumentException
 * @expectedExceptionMessage Wrong type.
 */
public function testCreatingWrongTypeOfCustomer() {
    $customer = CustomerFactory::factory(
        'deluxe', 1, 'han', 'solo', 'han@solo.com'

   );
}

在这个测试中,我们将尝试使用我们的工厂创建一个豪华客户,但由于这种类型的客户不存在,我们将得到一个异常。期望的异常类型是 InvalidArgumentException,错误信息是 "类型错误"。如果你运行测试,你会看到它们通过。

如果我们定义了一个期望的异常,但这个异常从未被抛出,测试将失败;预期异常只是另一种断言类型。为了看到这种情况发生,将以下内容添加到你的测试中并运行它;你将得到一个失败,PHPUnit 将会抱怨说它期望异常,但它从未被抛出:

/**
 * @expectedException \InvalidArgumentException
 */
public function testCreatingCorrectCustomer() {
    $customer = CustomerFactory::factory(
        'basic', 1, 'han', 'solo', 'han@solo.com'
    );
}

数据提供者

如果你思考一下测试的流程,大多数时候,我们用一个输入调用一个方法并期望得到一个输出。为了覆盖所有边缘情况,我们自然会用一组输入和期望的输出重复相同的操作。PHPUnit 给我们提供了这样做的能力,从而减少了大量的重复代码。这个特性被称为数据提供

数据提供者是在 test 类中定义的一个公共方法,它返回一个具有特定模式的数组。数组的每个条目代表一个测试,键是测试的名称——可选地,你可以使用数字键——值是测试需要的参数。一个测试将声明它需要一个数据提供者,使用 @dataProvider 注解,当执行测试时,数据提供者会注入测试方法需要的参数。让我们通过一个例子来使它更容易理解。在你的 CustomerFactoryTest 类中编写以下两个方法:

public function providerFactoryValidCustomerTypes() {
    return [
        'Basic customer, lowercase' => [
            'type' => 'basic',
            'expectedType' => '\Bookstore\Domain\Customer\Basic'
        ],
        'Basic customer, uppercase' => [
            'type' => 'BASIC',
            'expectedType' => '\Bookstore\Domain\Customer\Basic'
        ],
        'Premium customer, lowercase' => [
            'type' => 'premium',
            'expectedType' => '\Bookstore\Domain\Customer\Premium'
        ],
        'Premium customer, uppercase' => [
            'type' => 'PREMIUM',
            'expectedType' => '\Bookstore\Domain\Customer\Premium'
        ]
    ];
}

/**
 * @dataProvider providerFactoryValidCustomerTypes
 * @param string $type
 * @param string $expectedType
 */
public function testFactoryValidCustomerTypes(
 string $type,
 string $expectedType
) {
    $customer = CustomerFactory::factory(
        $type, 1, 'han', 'solo', 'han@solo.com'
    );
    $this->assertInstanceOf(
        $expectedType,
        $customer,
        'Factory created the wrong type of customer.'
    );
}

这里的测试是testFactoryValidCustomerTypes,它期望两个参数:$type$expectedType。测试使用它们通过工厂创建一个客户并验证结果的类型,这是我们通过硬编码类型已经做到的。测试还声明它需要providerFactoryValidCustomerTypes数据提供者。这个数据提供者返回一个包含四个条目的数组,这意味着测试将使用四组不同的参数执行四次。每个测试的名称是每个条目的键——例如,“基本客户,小写”。如果测试失败,这会非常有用,因为它将作为错误消息的一部分显示。每个条目是一个包含两个值的映射,typeexpectedType,它们是test方法参数的名称。这些条目的值是test方法将获得的值。

重要的是,我们编写的代码将与我们四次编写testFactoryValidCustomerTypes时相同,每次都硬编码$type$expectedType。现在想象一下,如果test方法包含数十行代码,或者我们想要用数十个数据集重复相同的测试;你看到它的强大之处了吗?

使用双倍进行测试

到目前为止,我们测试了相当孤立的类;也就是说,它们与其他类没有太多交互。尽管如此,我们也有一些使用多个类的类,例如控制器。我们能对这些交互做些什么呢?单元测试的想法是测试一个特定的方法,而不是整个代码库,对吧?

PHPUnit 允许你模拟这些依赖项;也就是说,你可以提供看起来与测试类需要的依赖项相似但不需要这些类代码的假对象。这样做的目的是提供一个虚拟实例,类可以使用并调用其方法,而不会产生这些调用可能产生的副作用。以模型为例:如果控制器使用真实模型,那么每次调用其方法时,模型都会访问数据库,这使得测试变得非常不可预测。

如果我们使用模拟作为模型,控制器可以按需调用其方法,而不会产生任何副作用。更好的是,我们可以对模拟接收到的参数进行断言,或者强制它返回特定的值。让我们看看如何使用它们。

使用依赖注入(DI)注入模型

我们首先需要理解的是,如果我们使用new在控制器内部创建对象,我们将无法对其进行模拟。这意味着我们需要注入所有依赖项——例如,使用依赖注入器。我们将为所有依赖项执行此操作,但有一个例外:模型。在本节中,我们将测试BookController类的borrow方法,因此我们将展示这个方法需要的变化。当然,如果你想测试其余的代码,你应该将这些相同的更改应用到其余的控制器上。

首件事是在我们的index.php文件中将BookModel实例添加到依赖注入器中。由于这个类也有一个依赖项,即PDO,因此使用相同的依赖注入器来获取它的实例,如下所示:

$di->set('BookModel', new BookModel($di->get('PDO')));

现在,在BookController类的borrow方法中,我们将更改模型的新实例化为以下内容:

public function borrow(int $bookId): string {
 $bookModel = $this->di->get('BookModel');

    try {
//...

自定义 TestCase

当编写单元测试套件时,通常会有一个自定义的TestCase类,所有测试都从这个类扩展。这个类始终从PHPUnit_Framework_TestCase扩展,所以我们仍然得到所有的断言和其他方法。由于所有测试都必须导入这个类,让我们更改我们的自动加载器,使其能够识别来自tests目录的命名空间。之后,运行composer update,如下所示:

"autoload": {
    "psr-4": {
 "Bookstore\\Tests\\": "tests",
        "Bookstore\\": "src"
    }
}

通过这个更改,我们将告诉 Composer,所有以Bookstore\Tests开头的命名空间都将位于tests目录下,其余的将遵循之前的规则。

让我们现在添加我们的自定义TestCase类。我们现在需要的唯一助手方法是创建 mock 的方法。这并不是真的必要,但它使事情更干净。在tests/AbstractTestClase.php中添加以下类:

<?php

namespace Bookstore\Tests;

use PHPUnit_Framework_TestCase;
use InvalidArgumentException;

abstract class AbstractTestCase extends PHPUnit_Framework_TestCase {
    protected function mock(string $className) {
        if (strpos($className, '\\') !== 0) {
            $className = '\\' . $className;
        }

        if (!class_exists($className)) {
            $className = '\Bookstore\\' . trim($className, '\\');

            if (!class_exists($className)) {
                throw new InvalidArgumentException(
                    "Class $className not found."
                );
            }
        }

        return $this->getMockBuilder($className)
            ->disableOriginalConstructor()
            ->getMock();
    }
}

这种方法以类的名称命名,并试图确定该类是否是Bookstore命名空间的一部分。当我们模拟自己的代码库中的对象时,这将非常有用,因为我们不必每次都写Bookstore。在确定真正的完整类名之后,它使用 PHPUnit 的 mock 构建器来创建一个实例,然后返回它。

更多助手!这次,它们是为控制器准备的。每个控制器都将始终需要相同的依赖项:记录器、数据库连接、模板引擎和配置读取器。了解这一点后,让我们从所有覆盖控制器的测试都将扩展的ControllerTestCase类开始。这个类将包含一个setUp方法,它创建所有常见的 mock 并设置在依赖注入器中。将其添加为你的tests/ControllerTestCase.php文件,如下所示:

<?php

namespace Bookstore\Tests;

use Bookstore\Utils\DependencyInjector;
use Bookstore\Core\Config;
use Monolog\Logger;
use Twig_Environment;
use PDO;

abstract class ControllerTestCase extends AbstractTestCase {
    protected $di;

    public function setUp() {
        $this->di = new DependencyInjector();
        $this->di->set('PDO', $this->mock(PDO::class));
        $this->di->set('Utils\Config', $this->mock(Config::class));
        $this->di->set(
            'Twig_Environment',
            $this->mock(Twig_Environment::class)
        );
        $this->di->set('Logger', $this->mock(Logger::class));
    }
}

使用 mock

好吧,我们已经足够了解助手了;让我们开始测试。这里的难点是如何与 mock 互动。当你创建一个 mock 时,你可以添加一些期望值和返回值。方法如下:

  • expects:这个指定了 mock 的方法被调用的次数。你可以发送$this->never()$this->once()$this->any()作为参数来指定 0 次、1 次或任何调用。

  • method:这个用于指定我们正在讨论的方法。它期望的参数只是方法的名称。

  • with:这是一个用于设置模拟在调用时将接收的参数期望的方法。例如,如果模拟的方法预期得到basic作为第一个参数和123作为第二个参数,则with方法将被调用为with("basic", 123)。这个方法不是必需的,但如果设置了它,PHPUnit 将在模拟的方法没有接收到预期的参数时抛出一个错误,因此它作为一个断言工作。

  • will:用于定义模拟将返回的内容。最常用的两种用法是$this->returnValue($value)$this->throwException($exception)。这个方法也不是必需的,如果没有调用,模拟将始终返回 null。

让我们添加第一个测试来看看它会如何工作。将以下代码添加到tests/Controllers/BookControllerTest.php文件中:

<?php

namespace Bookstore\Tests\Controllers;

use Bookstore\Controllers\BookController;
use Bookstore\Core\Request;
use Bookstore\Exceptions\NotFoundException;
use Bookstore\Models\BookModel;
use Bookstore\Tests\ControllerTestCase;
use Twig_Template;

class BookControllerTest extends ControllerTestCase {
    private function getController(
        Request $request = null
    ): BookController {
        if ($request === null) {
            $request = $this->mock('Core\Request');
        }
        return new BookController($this->di, $request);
    }

    public function testBookNotFound() {
        $bookModel = $this->mock(BookModel::class);
 $bookModel
 ->expects($this->once())
 ->method('get')
 ->with(123)
 ->will(
 $this->throwException(
 new NotFoundException()
 )
 );
        $this->di->set('BookModel', $bookModel);

        $response = "Rendered template";
        $template = $this->mock(Twig_Template::class);
 $template
 ->expects($this->once())
 ->method('render')
 ->with(['errorMessage' => 'Book not found.'])
 ->will($this->returnValue($response));
 $this->di->get('Twig_Environment')
 ->expects($this->once())
 ->method('loadTemplate')
 ->with('error.twig')
 ->will($this->returnValue($template));

        $result = $this->getController()->borrow(123);

        $this->assertSame(
            $result,
            $response,
            'Response object is not the expected one.'
        );
    }
}

测试的第一件事是创建BookModel类的模拟。然后,它添加了一个这样的期望:get方法将被调用一次,带有一个参数123,并且会抛出NotFoundException。这在测试试图模拟我们在数据库中找不到书籍的场景时是有意义的。

测试的第二部分包括添加模板引擎的期望。这稍微复杂一些,因为涉及到两个模拟。Twig_EnvironmentloadTemplate方法预期会被调用一次,使用error.twig作为模板名称。这个模拟应该返回Twig_Template,这又是一个模拟。这个第二个模拟的render方法预期会被调用一次,使用正确的错误消息,并返回一个硬编码的字符串。定义了所有依赖项之后,我们只需要调用控制器的borrow方法并期望得到一个响应。

记住,这个测试不仅仅只有一个断言,而是有四个:assertSame方法和三个模拟期望。如果其中任何一个没有完成,测试将失败,所以我们可以说这个方法相当稳健。

在我们的第一次测试中,我们验证了当找不到书籍时的场景是有效的。还有两个场景也会失败:当没有足够的书籍副本可以借阅,以及当尝试保存借阅的书籍时出现数据库错误。然而,你现在可以看到,它们都共享一段模拟模板的代码。让我们将这段代码提取到一个protected方法中,当给定模板名称时,该方法会生成模拟。将参数发送到模板,并接收预期的响应。运行以下代码:

protected function mockTemplate(
    string $templateName,
    array $params,
    $response
) {
    $template = $this->mock(Twig_Template::class);
    $template
        ->expects($this->once())
        ->method('render')
        ->with($params)
        ->will($this->returnValue($response));
    $this->di->get('Twig_Environment')
        ->expects($this->once())
        ->method('loadTemplate')
        ->with($templateName)
        ->will($this->returnValue($template));
}

public function testNotEnoughCopies() {
    $bookModel = $this->mock(BookModel::class);
    $bookModel
        ->expects($this->once())
        ->method('get')
        ->with(123)
        ->will($this->returnValue(new Book()));
 $bookModel
 ->expects($this->never())
 ->method('borrow');
    $this->di->set('BookModel', $bookModel);

    $response = "Rendered template";
    $this->mockTemplate(
        'error.twig',
        ['errorMessage' => 'There are no copies left.'],
        $response
    );

    $result = $this->getController()->borrow(123);

    $this->assertSame(
        $result,
        $response,
        'Response object is not the expected one.'
    );
}

public function testErrorSaving() {
    $controller = $this->getController();
    $controller->setCustomerId(9);

    $book = new Book();
    $book->addCopy();
    $bookModel = $this->mock(BookModel::class);
    $bookModel
        ->expects($this->once())
        ->method('get')
        ->with(123)
        ->will($this->returnValue($book));
    $bookModel
        ->expects($this->once())
        ->method('borrow')
        ->with(new Book(), 9)
        ->will($this->throwException(new DbException()));
    $this->di->set('BookModel', $bookModel);

    $response = "Rendered template";
    $this->mockTemplate(
        'error.twig',
        ['errorMessage' => 'Error borrowing book.'],
        $response
    );

    $result = $controller->borrow(123);

    $this->assertSame(
        $result,
        $response,
        'Response object is not the expected one.'
    );
}

这里唯一的创新之处在于当我们期望borrow方法永远不会被调用时。因为我们不期望它被调用,所以没有必要使用withwill方法。如果代码实际上调用了这个方法,PHPUnit 将标记测试为失败。

我们已经测试并发现所有可能失败的场景都已经失败。现在让我们添加一个测试,其中用户可以成功借阅一本书,这意味着我们将从数据库返回有效的书籍和客户信息,save方法将被正确调用,模板将获取所有正确的参数。测试如下:

public function testBorrowingBook() {
    $controller = $this->getController();
    $controller->setCustomerId(9);

    $book = new Book();
    $book->addCopy();
    $bookModel = $this->mock(BookModel::class);
    $bookModel
        ->expects($this->once())
        ->method('get')
        ->with(123)
        ->will($this->returnValue($book));
    $bookModel
        ->expects($this->once())
        ->method('borrow')
        ->with(new Book(), 9);
    $bookModel
        ->expects($this->once())
        ->method('getByUser')
        ->with(9)
        ->will($this->returnValue(['book1', 'book2']));
    $this->di->set('BookModel', $bookModel);

    $response = "Rendered template";
    $this->mockTemplate(
        'books.twig',
        [
            'books' => ['book1', 'book2'],
            'currentPage' => 1,
            'lastPage' => true
        ],
        $response
    );

    $result = $controller->borrow(123);

    $this->assertSame(
        $result,
        $response,
        'Response object is not the expected one.'
    );
}

所以这就是全部了。你已经写下了在这本书中你需要编写的一个最复杂的测试。你对它有什么看法?好吧,由于你没有太多的测试经验,你可能对结果相当满意,但让我们进一步分析一下。

数据库测试

这将是本章中最具争议的部分。当涉及到数据库测试时,存在不同的观点。我们应该使用数据库吗?我们应该使用开发数据库还是内存中的数据库?解释如何模拟数据库或为每个测试准备一个新的数据库超出了本书的范围,但我们将尝试在这里总结一些技术:

  • 我们将模拟数据库连接,并将期望写入模型与数据库之间的所有交互。在我们的案例中,这意味着我们将注入一个PDO对象的模拟。由于我们将手动编写查询,我们可能会引入错误的查询。模拟连接并不能帮助我们检测这个错误。如果我们使用 ORM 而不是手动编写查询,这个解决方案会很好,但我们将把这个话题从书中排除。

  • 对于每个测试,我们将创建一个新的数据库,在其中添加我们为特定测试想要的数据。这种方法可能需要很多时间,但它确保你将针对真实数据库进行测试,并且没有可能使我们的测试失败的不预期的数据;也就是说,测试是完全隔离的。在大多数情况下,这将是首选的方法,即使它可能不是性能最快的。为了解决这个不便,我们将创建内存数据库。

  • 对现有数据库进行的测试。通常,在测试开始时,我们启动一个事务,在测试结束时回滚,这样数据库就不会有任何变化。这种方法模拟了一个真实场景,我们可以找到各种数据,我们的代码应该始终按预期行为。然而,使用共享数据库总有一些副作用;例如,如果我们想对数据库模式进行更改,我们必须在运行测试之前将这些更改应用到数据库中,但其他使用数据库的应用程序或开发者可能还没有准备好这些更改。

为了保持事情简单,我们将尝试实现第二和第三种选项的混合。我们将使用现有的数据库,但在每个测试开始事务后,我们将清理所有涉及的表。这似乎需要 ModelTestCase 来处理。将以下内容添加到 tests/ModelTestCase.php

<?php

namespace Bookstore\Tests;

use Bookstore\Core\Config;
use PDO;

abstract class ModelTestCase extends AbstractTestCase {
    protected $db;
    protected $tables = [];

    public function setUp() {
        $config = new Config();

        $dbConfig = $config->get('db');
        $this->db = new PDO(
            'mysql:host=127.0.0.1;dbname=bookstore',
            $dbConfig['user'],
            $dbConfig['password']
        );
        $this->db->beginTransaction();
        $this->cleanAllTables();
    }

    public function tearDown() {
        $this->db->rollBack();
    }

    protected function cleanAllTables() {
        foreach ($this->tables as $table) {
            $this->db->exec("delete from $table");
        }
    }
}

setUp 方法使用与 config/app.yml 文件中找到的相同凭据创建数据库连接。然后,我们将开始一个事务并调用 cleanAllTables 方法,该方法遍历 $tables 属性中的表并删除它们的所有内容。tearDown 方法回滚事务。

注意

从 ModelTestCase 扩展

如果您编写一个扩展此类的测试,需要实现 setUptearDown 方法,请始终记住调用父类的这些方法。

让我们为 BookModel 类的 borrow 方法编写测试。此方法使用书籍和客户,因此我们希望清理包含它们的表。创建 test 类并将其保存到 tests/Models/BookModelTest.php

<?php

namespace Bookstore\Tests\Models;

use Bookstore\Models\BookModel;
use Bookstore\Tests\ModelTestCase;

class BookModelTest extends ModelTestCase {
    protected $tables = [
        'borrowed_books',
        'customer',
        'book'
    ];
    protected $model;

    public function setUp() {
        parent::setUp();

        $this->model = new BookModel($this->db);
    }
}

注意我们如何也覆盖了 setUp 方法,调用了父类中的方法,并创建了所有测试都将使用的模型实例,这样做是安全的,因为我们不会保留任何上下文。在添加测试之前,让我们向 ModelTestCase 添加一些额外的辅助工具:一个用于根据参数数组创建书籍对象的方法,以及两个用于在数据库中保存书籍和客户的方法。运行以下代码:

protected function buildBook(array $properties): Book {
    $book = new Book();
    $reflectionClass = new ReflectionClass(Book::class);

    foreach ($properties as $key => $value) {
        $property = $reflectionClass->getProperty($key);
        $property->setAccessible(true);
        $property->setValue($book, $value);
    }

    return $book;
}

protected function addBook(array $params) {
    $default = [
        'id' => null,
        'isbn' => 'isbn',
        'title' => 'title',
        'author' => 'author',
        'stock' => 1,
        'price' => 10.0,
    ];
    $params = array_merge($default, $params);

    $query = <<<SQL
insert into book (id, isbn, title, author, stock, price)
values(:id, :isbn, :title, :author, :stock, :price)
SQL;
    $this->db->prepare($query)->execute($params);
}

protected function addCustomer(array $params) {
    $default = [
        'id' => null,
        'firstname' => 'firstname',
        'surname' => 'surname',
        'email' => 'email',
        'type' => 'basic'
    ];
    $params = array_merge($default, $params);

    $query = <<<SQL
insert into customer (id, firstname, surname, email, type)
values(:id, :firstname, :surname, :email, :type)
SQL;
    $this->db->prepare($query)->execute($params);
}

正如您所注意到的,我们为所有字段添加了默认值,因此我们不必每次想要保存一本书/客户时都定义整个实体。相反,我们只需发送相关的字段并将它们与默认值合并。

此外,请注意,buildBook 方法使用了一个新概念,反射,来访问实例的私有属性。这超出了本书的范围,但如果您对此感兴趣,可以在php.net/manual/en/book.reflection.php上阅读更多内容。

我们现在准备开始编写测试。有了所有这些辅助工具,添加测试将会非常简单且清晰。borrow 方法有不同的使用场景:尝试借阅数据库中不存在的书籍,尝试使用未注册的客户,以及成功借阅书籍。让我们按照以下方式添加它们:

/**
 * @expectedException \Bookstore\Exceptions\DbException
 */
public function testBorrowBookNotFound() {
    $book = $this->buildBook(['id' => 123]);
    $this->model->borrow($book, 123);
}

/**
 * @expectedException \Bookstore\Exceptions\DbException
 */
public function testBorrowCustomerNotFound() {
    $book = $this->buildBook(['id' => 123]);
    $this->addBook(['id' => 123]);

    $this->model->borrow($book, 123);
}

public function testBorrow() {
    $book = $this->buildBook(['id' => 123, 'stock' => 12]);
    $this->addBook(['id' => 123, 'stock' => 12]);
    $this->addCustomer(['id' => 123]);

    $this->model->borrow($book, 123);
}

感到印象深刻吗?与控制器测试相比,这些测试要简单得多,主要是因为它们的代码只执行一个动作,但也得益于添加到 ModelTestCase 中的所有方法。一旦您需要与其他对象一起工作,例如 sales,您可以将 addSalebuildSale 添加到这个相同的类中,使事情更简洁。

测试驱动开发

你可能已经意识到,在谈论开发应用程序时,没有一种独特的方法。这本书的范围不包括展示所有这些方法——而且在你读完这些行的时候,可能已经融入了更多的技术——但有一种方法在编写好的、可测试的代码时非常有用:测试驱动开发TDD)。

这种方法包括在编写代码之前先编写单元测试。然而,想法并不是一次性编写所有测试,然后再编写类或方法,而是以渐进的方式完成。让我们通过一个例子来简化这个过程。假设你的Sale类尚未实现,我们唯一知道的是我们必须能够添加书籍。将src/Domain/Sale.php文件重命名为src/Domain/Sale2.php或直接删除它,这样应用程序就不会知道它的存在。

注意

所有这些冗长是否必要?

在这个例子中,你会注意到我们将执行大量的步骤来得到一个非常简单的代码片段。确实,对于这个例子来说,步骤太多了,但有时候这个数量是合适的。找到这些时刻需要经验,所以我们建议你先从简单的例子开始练习。最终,这会变得自然而然。

TDD 的机制包括以下四个步骤:

  1. 为尚未实现的功能编写一个测试。

  2. 运行单元测试,它们应该失败。如果它们没有失败,那么要么是你的测试错误,要么你的代码已经实现了这个功能。

  3. 编写最少的代码以使测试通过。

  4. 再次运行单元测试。这次,它们应该通过。

我们没有sale域对象,所以首先,正如我们应该从小事做起,然后逐步过渡到大事,我们需要确保我们可以实例化sale对象。在tests/Domain/SaleTest.php中编写以下单元测试,因为我们将会用 TDD 的方式编写所有现有的测试;你可以删除这个文件中的现有测试。

<?php

namespace Bookstore\Tests\Domain;

use Bookstore\Domain\Sale;
use PHPUnit_Framework_TestCase;

class SaleTest extends PHPUnit_Framework_TestCase {
    public function testCanCreate() {
        $sale = new Sale();
    }
}

运行测试以确保它们失败。为了运行一个特定的测试,你可以在运行 PHPUnit 时指定测试文件,如下面的脚本所示:

测试驱动开发

好的,它们失败了。这意味着 PHP 找不到要实例化的对象。现在,让我们编写最少的代码来使这个测试通过。在这种情况下,创建一个类就足够了,你可以通过以下代码行来完成:

<?php

namespace Bookstore\Domain;

class Sale {
}

现在,运行测试以确保没有错误。

测试驱动开发

这很简单,对吧?所以,我们需要重复这个过程,每次添加更多功能。让我们专注于销售所包含的书籍;当创建时,书籍列表应该是空的,如下所示:

public function testWhenCreatedBookListIsEmpty() {
    $sale = new Sale();

    $this->assertEmpty($sale->getBooks());
}

运行测试以确保它们失败——它们确实会失败。现在,在类中编写以下方法:

public function getBooks(): array {
return [];
}

现在,如果你运行...等等,什么?我们正在强制getBooks方法始终返回一个空数组?这不是我们需要的实现——也不是我们应得的——那么我们为什么要这样做呢?原因是步骤 3 的措辞:“编写最少的代码以使测试通过。”我们的测试套件应该足够广泛,能够检测这类问题,这是我们确保它的方法。这次,我们将故意编写糟糕的代码,但下次,我们可能无意中引入了一个错误,我们的单元测试应该能够尽快检测到它。运行测试;它们将通过。

现在,让我们讨论下一个功能。当向列表中添加一本书时,我们应该看到这本书的数量为 1。测试应该如下:

public function testWhenAddingABookIGetOneBook() {
    $sale = new Sale();
    $sale->addBook(123);

    $this->assertSame(
        $sale->getBooks(),
        [123 => 1]
    );
}

这个测试非常有用。它不仅迫使我们实现addBook方法,还帮助我们修复了getBooks方法——因为它现在硬编码为始终返回一个空数组。由于getBooks方法现在期望两个不同的结果,我们不能再欺骗测试了。类的新代码如下:

class Sale {
    private $books = [];

    public function getBooks(): array {
        return $this->books;
    }

    public function addBook(int $bookId) {
        $this->books[123] = 1;
    }
}

我们可以编写的一个新测试是允许你一次添加多本书,将数量作为第二个参数。测试看起来可能如下:

public function testSpecifyAmountBooks() {
    $sale = new Sale();
    $sale->addBook(123, 5);

    $this->assertSame(
        $sale->getBooks(),
        [123 => 5]
    );
}

现在,测试没有通过,所以我们需要修复它们。让我们重构addBook方法,使其能够接受第二个参数作为数量:

public function addBook(int $bookId, int $amount = 1) {
    $this->books[123] = $amount;
}

我们想要添加的下一个功能是相同的书籍调用方法多次,同时跟踪添加的书籍总数。测试可以如下:

public function testAddMultipleTimesSameBook() {
    $sale = new Sale();
    $sale->addBook(123, 5);
    $sale->addBook(123);
    $sale->addBook(123, 5);

    $this->assertSame(
        $sale->getBooks(),
        [123 => 11]
    );
}

这个测试将失败,因为当前的执行不会添加所有数量,而是保留最后一个。让我们通过执行以下代码来修复它:

public function addBook(int $bookId, int $amount = 1) {
    if (!isset($this->books[123])) {
        $this->books[123] = 0;
    }
    $this->books[123] += $amount;
}

好吧,我们几乎完成了。我们还需要添加最后一个测试,这个测试是关于能够添加多本不同书籍的能力。测试如下:

public function testAddDifferentBooks() {
    $sale = new Sale();
    $sale->addBook(123, 5);
    $sale->addBook(456, 2);
    $sale->addBook(789, 5);

    $this->assertSame(
        $sale->getBooks(),
        [123 => 5, 456 => 2, 789 => 5]
    );
}

这个测试失败是因为我们的实现中硬编码了书籍 ID。如果我们没有这样做,测试就已经通过了。那么让我们修复它;运行以下代码:

public function addBook(int $bookId, int $amount = 1) {
    if (!isset($this->books[$bookId])) {
        $this->books[$bookId] = 0;
    }
    $this->books[$bookId] += $amount;
}

我们完成了!看起来熟悉吗?这是我们在第一次实现中写的相同代码,除了其余的属性。你现在可以用之前的sale域对象替换它,这样你就有了所有需要的功能。

理论与实际

如前所述,这是一个相当长且冗长的过程,很少有经验丰富的开发者从头到尾遵循,但大多数人都会鼓励人们遵循。为什么是这样呢?当你首先编写所有代码,然后留到最后一刻编写单元测试时,有两个问题:

  • 首先,在太多的情况下,开发者足够懒惰,以至于跳过测试,告诉自己代码已经工作得很好,所以没有必要编写测试。你已经知道测试的一个目标是要确保未来的更改不会破坏当前的功能,所以这不是一个有效的理由。

  • 其次,代码编写之后的测试通常测试的是代码本身而不是功能。想象一下,你有一个最初旨在执行某个操作的方法。在编写方法之后,由于错误或设计不良,我们可能无法完美地执行该操作;相反,我们可能会做太多或者遗漏一些边缘情况。当我们编写代码之后的测试时,我们会测试我们看到的方法,而不是原始功能是什么!

如果你强迫自己先编写测试然后再编写代码,你就能确保始终有测试,并且它们能够测试代码的预期功能,从而得到一个按预期执行且完全覆盖的代码。此外,通过分小段进行,你可以快速获得反馈,不必等待数小时才能知道你编写的所有测试和代码是否合理。尽管这个想法很简单并且很有道理,但许多新手开发者发现很难实施。

经验丰富的开发者已经编写代码多年,因此他们已经将所有这些知识内化了。这就是为什么他们中的一些人更喜欢在开始编写代码之前先写几个测试,或者反过来,先编写代码然后再测试,因为他们认为这样更有效率。然而,如果他们有什么共同点的话,那就是他们的应用程序总是充满了测试。

摘要

在本章中,你学习了使用单元测试测试代码的重要性。你现在知道如何配置 PHPUnit 在你的应用程序上,以便你不仅可以运行测试,还可以获得良好的反馈。你对如何正确编写单元测试有了很好的了解,现在,你在应用程序中引入更改时会更安全。

在下一章中,我们将研究一些现有的框架,你可以在每次开始一个应用程序时使用这些框架而不是自己编写。这样,你不仅节省了时间和精力,而且其他开发者也能轻松地加入你并理解你的代码。

第八章。使用现有的 PHP 框架

就像你用 PHP 编写框架一样,其他人也在做同样的事情。人们很快意识到整个框架也是可重用的。当然,对某人是肉的人对另一个人来说是毒药,正如 IT 世界中的许多其他例子一样,大量的框架开始出现。你永远不会听说其中大多数,但其中一些框架获得了相当多的用户。

在我们编写的时候,有四到五个主要的框架是大多数 PHP 开发者所熟知的:SymfonyZend Framework是上一代 PHP 的主要角色,但 Laravel 也在其中,为那些需要较少特性的开发者提供了一个轻量级且快速的框架。由于这本书的性质,我们将重点关注最新的框架,SilexLaravel,因为它们足够快,可以在一章中学习——至少它们的基础是。

在本章中,你将学习以下内容:

  • 框架的重要性

  • 框架的其他特性

  • 使用 Laravel

  • 使用 Silex 编写应用程序

审查框架

在第六章《适应 MVC》中,我们只是简要介绍了使用 MVC 设计模式的框架概念。实际上,我们没有解释什么是框架;我们只是开发了一个非常简单的框架。如果你在寻找一个定义,这里就是:框架是你选择用来构建程序的结构。让我们更详细地讨论这个问题。

框架的目的

当你编写一个应用程序时,如果你使用 MVC 设计模式,你需要添加你的模型、视图和控制器,我们强烈建议你这样做。这三个元素,加上完成你视图的 JavaScript 和 CSS 文件,是使你的应用程序与其他应用程序区分开来的因素。你无法跳过编写它们。

另一方面,有一组类,尽管你需要它们来正确运行你的应用程序,但它们对所有其他应用程序都是通用的,或者至少非常相似。这些类的例子包括我们在src/Core目录中拥有的,比如路由器、配置读取器等等。

框架的目的明确且必要:它们为你的应用程序添加一些结构,并连接其不同元素。在我们的例子中,它帮助我们路由 HTTP 请求到正确的控制器,连接到数据库,并生成动态 HTML 作为响应。然而,必须努力实现的是框架的可重用性。如果你每次开始一个应用程序时都必须编写框架,那会好吗?

因此,为了使框架有用,它必须易于在不同的环境中重用。这意味着框架必须从源下载,并且必须易于安装。下载并安装依赖项?看来 Composer 又要派上用场了!尽管这在几年前相当不同,但现在,所有主要框架都可以使用 Composer 安装。我们将在稍后向您展示如何操作。

框架的主要部分

如果我们将我们的框架开源,以便其他开发者可以使用它,我们需要以直观的方式组织我们的代码。我们需要尽可能减少学习曲线;没有人愿意花几周时间来学习如何使用一个框架。

作为在 Web 应用程序中实际使用的 MVC 设计模式,大多数框架都会将模型、视图和控制器这三个层次分别放在三个不同的目录中。根据框架的不同,它们可能会位于src/目录下,尽管将视图放在这个目录之外是很常见的,就像我们自己的做法一样。然而,大多数框架都会给你足够的灵活性来决定每个层次放置的位置。

以前,框架中其余的类通常都放在一个单独的目录中——例如,src/Core。将它们与你的代码分开是很重要的,这样你就不会无意中混淆代码并修改核心类,从而搞乱整个框架。更好的是,这一代 PHP 框架通常将核心组件作为独立的模块来整合,这些模块将通过 Composer 来要求。这样做,框架的composer.json文件将要求所有不同的组件,如路由器、配置、数据库连接、日志记录器、模板引擎等,Composer 将在vendor/目录中下载它们,并通过自动生成的自动加载器使它们可用。

在不同的代码库中分离不同的组件有许多好处。首先,它允许不同的开发团队以隔离的方式使用不同的组件进行工作。由于代码足够分离,不会相互影响,因此维护它们也更容易。最后,它允许最终用户为其应用程序选择要获取哪些组件,以尝试自定义框架,排除那些未使用的重型组件。

框架要么是组织成独立的模块,要么是所有内容都在一起;然而,总是有相同的常见组件,它们是:

  • 路由器:这是一个类,给定一个 HTTP 请求,找到正确的控制器,实例化它并执行它,然后返回 HTTP 响应。

  • 请求处理:这包含了一些方法,允许你访问参数、cookies、headers 等。这通常由路由器使用,并发送到控制器。

  • 配置处理器:这允许你获取正确的配置文件,读取它,并使用其内容来配置其他组件。

  • 模板引擎:它将 HTML 与控制器中的内容合并,以便在响应中渲染模板。

  • 记录器:它将错误或其他我们认为重要的消息添加到日志文件中。

  • 依赖注入器:它管理你的类需要的所有依赖项。也许框架没有依赖注入器,但它有类似的东西——即服务定位器——它试图以类似的方式帮助你。

  • 编写和运行单元测试的方式:大多数框架都包含 PHPUnit,但社区中还有更多选择。

框架的其他特性

尽管我们已经在上一节中描述了框架的一些特性,但大多数框架的功能远不止这些,即使这些功能已经足够构建简单的应用程序,就像你之前自己做的那样。然而,大多数 Web 应用程序还有更多常见的特性,因此框架试图为每个特性实现通用的解决方案。多亏了这一点,我们不需要在那些几乎所有中大型 Web 应用程序都需要实现的功能上重新发明轮子。我们将尝试描述一些最有用的特性,以便你在选择框架时有一个更好的想法。

身份验证和角色

大多数网站强制用户进行身份验证才能执行某些操作。这样做的原因是让系统知道尝试执行特定操作的用户是否有权这样做。因此,管理用户及其角色是你在所有 Web 应用程序中可能最终要实现的事情。问题是当太多的人试图攻击你的系统以获取其他用户的信息或以他人的身份执行操作时,这被称为冒充。正因为如此,你的身份验证和授权系统应该尽可能安全——这是一项永远都不容易的任务。

几个框架包括一种相当安全的方式来管理用户、权限和会话。大多数情况下,你可以通过配置文件来管理这些,可能是指定凭据到一个数据库,框架可以在其中添加用户数据、你自定义的角色和一些其他自定义设置。缺点是每个框架都有自己的配置方式,因此你将不得不深入研究你当前使用的框架的文档。尽管如此,这还是比你自己实现它节省更多时间。

ORM

对象关系映射(ORM)是一种将数据库或任何其他数据存储中的数据转换为对象的技术。主要目标是尽可能地将业务逻辑与数据库的结构分离,并减少代码的复杂性。当使用 ORM 时,你很可能永远不会在 MySQL 中编写查询;相反,你将使用方法链。在幕后,ORM 将在每次方法调用时编写查询。

使用 ORM 既有好的一面也有不好的一面。一方面,你不必总是记住所有的 SQL 语法,只需记住正确的调用方法,如果你与一个可以自动完成方法的 IDE 一起工作,这可能会更容易。将你的代码从存储系统的类型中抽象出来也是一件好事,因为尽管这并不常见,但你可能以后想改变它。如果你使用 ORM,你可能只需要更改连接的类型,但如果你编写原始查询,你将不得不做很多工作来迁移你的代码。

使用 ORM 的潜在缺点可能是,使用方法链编写复杂的查询可能相当困难,你最终可能需要手动编写它们。你也会受到 ORM 的支配,以便加快查询的性能,而当你手动编写它们时,你可以选择在查询时更好地使用什么和如何使用。最后,面向对象编程(OOP)的纯粹主义者相当多抱怨的是,使用 ORM 会让你的代码充满大量的虚拟对象,类似于你已知的领域对象。

正如你所见,使用对象关系映射(ORM)并不总是容易的决定,但如果你选择使用它,大多数大型框架都包含一个。在你决定是否在你的应用程序中使用 ORM 时,请花些时间;如果你决定使用,请明智地选择哪一个。你可能会发现你需要一个与框架提供的 ORM 不同的 ORM。

缓存

书店是一个很好的例子,可以帮助描述缓存功能。它有一个数据库,每次有人列出所有书籍或请求特定一本书的详细信息时都会查询这个数据库。大多数时候,与书籍相关的信息将是相同的;唯一的变化是书籍的库存有时会变化。我们可以这样说,我们的系统有更多的读取操作而不是写入操作,其中读取意味着查询数据,写入意味着更新它。在这种类型的系统中,每次都访问数据库似乎是一种时间和资源的浪费,因为我们知道大多数时候,我们会得到相同的结果。如果我们对检索到的数据进行一些昂贵的转换,这种感觉会加剧。

缓存层允许应用程序在比数据库更快的存储系统中存储临时数据,通常是在内存中而不是在磁盘上。尽管缓存系统变得越来越复杂,但它们通常允许你通过键值对存储数据,就像在数组中一样。

策略不是为了节省时间和资源,而访问数据库获取我们知道与上次访问时相同的数据。实现方式可能大相径庭,但主要流程如下:

  1. 你试图首次访问某些数据。我们询问缓存是否存在某个键,它不存在。

  2. 你查询数据库,获取结果。在处理它——也许将其转换为你的领域对象——之后,你将结果存储在缓存中。键将与步骤 1 中使用的相同,而值将是您生成的对象/数组/JSON。

  3. 你试图再次访问同一份数据。你询问缓存该键是否存在;这里,它存在,因此你根本不需要访问数据库。

这看起来很简单,但缓存的主要问题在于我们需要使某个键失效时。我们应该如何以及何时进行操作?有几个值得注意的方法:

  • 你将为缓存中的键值对设置一个过期时间。在此时间过后,缓存将自动移除键值对,因此你需要再次查询数据库。尽管这个系统可能适用于某些应用程序,但对我们来说并不适用。如果库存在缓存过期之前变为 0,用户将看到他们无法借阅或购买的书。

  • 数据永远不会过期,但每次我们在数据库中做出更改时,我们都会确定哪些缓存中的键受到此更改的影响,然后清除它们。这是理想的,因为数据将保留在缓存中,直到它不再有效,无论是 2 秒还是 3 周。缺点是,根据你的数据结构,确定这些键可能是一项艰巨的任务。如果你遗漏了删除其中的一些,你的缓存中将会出现损坏的数据,这非常难以调试和检测。

你可以看到缓存是一把双刃剑,所以我们建议你仅在必要时使用它,而不仅仅是因为你的框架自带它。与 ORM 一样,如果你对你框架提供的缓存系统不满意,使用另一个系统不应该很难。实际上,你的代码不应该知道你正在使用哪个缓存系统,除非在创建连接对象时。

国际化

英语并不是唯一的语言,你希望你的网站尽可能易于访问。根据你的目标,将你的网站翻译成其他语言也是一个好主意,但你怎么做呢?我们希望你现在没有回答:“复制粘贴所有模板并翻译它们”。这太低效了;当你在模板中做一点改动时,你需要将改动复制到每个地方。

有一些工具可以与控制器和/或模板引擎集成,以翻译字符串。你通常会为每种语言保留一个文件,在其中添加所有需要翻译的字符串及其翻译。这种格式中最常见的是 PO 文件,其中包含原始翻译的键值对映射。稍后,你将调用一个 translate 方法,发送原始字符串,它将根据你选择的语言返回翻译后的字符串。

在编写模板时,每次你想显示一个字符串时调用翻译可能会感到疲倦,但最终你将只有一个模板,这比任何其他选项都更容易维护。

通常,国际化与国际上使用的框架紧密相关;然而,如果你有机会使用你选择的系统,请特别注意其性能、它使用的翻译文件以及它如何管理带参数的字符串——也就是说,我们如何请求系统翻译像“Hello %s, who are you?”这样的消息,其中“%s”需要每次都注入。

框架的类型

现在你已经对框架能为你提供什么有了相当多的了解,你就可以决定你想使用哪种类型的框架了。为了做出这个决定,了解可用的框架类型可能会有所帮助。这种分类并不是官方的,只是我们提供的一些指导,以帮助你更容易做出选择。

完整且健壮的框架

这种类型的框架包含了一个完整的包。它包含了我们之前讨论的所有功能,因此它将允许你开发非常完整的应用程序。通常,这些框架允许你通过仅使用几个配置文件来轻松创建应用程序,这些配置文件定义了诸如如何连接到数据库、你需要什么类型的角色或你是否想使用缓存等内容。除此之外,你只需添加你的控制器、视图和模型,这可以为你节省大量时间。

这些框架的问题在于学习曲线。鉴于它们包含的所有功能,你需要花费相当多的时间来学习如何使用每个框架,这通常并不愉快。事实上,大多数寻找网络开发者的公司都要求你有使用他们使用的框架的经验;否则,对他们来说这将是一个糟糕的投资。

在选择这些框架时,你还应该考虑它们是否以模块化结构构建或是一个庞大的单体。在前一种情况下,你将能够选择使用哪些模块,这提供了很大的灵活性。另一方面,如果你必须使用所有这些模块,即使你并不使用所有功能,这也可能使你的应用程序变慢。

轻量级且灵活的框架

即使是在开发小型应用程序时,你也希望使用一个框架来节省大量时间和痛苦,但你应该避免使用大型框架,因为它们对于你真正需要的功能来说处理起来会过于复杂。在这种情况下,你应该选择一个轻量级框架,它包含非常少的功能,类似于我们在前几章中实现的那样。

这些框架的好处是,尽管你获得了基本功能,如路由,但你完全自由地实现适合你特定应用的登录系统、缓存层或国际化系统。实际上,你可以使用这个作为基础构建一个更完整的框架,然后添加所有需要的补充,使其完全定制化。

正如你所注意到的,这两种类型都有其优缺点。每次选择正确的类型将取决于你的需求、你可以投入的时间以及你对每种类型的经验。

知名框架概述

你已经对框架能提供什么以及有哪些类型有了很好的了解。现在,是时候回顾一些最重要的框架了,这样你就可以了解从哪里开始寻找你的下一个 PHP 网络应用程序。请注意,随着 PHP 7 的发布,将会有相当多的新或改进的 PHP 框架。尽量保持跟进!

Symfony 2

在过去的 10 年中,Symfony 一直是开发者最喜欢的框架之一。在对其版本 2 进行自我革新之后,Symfony 进入了模块化框架的世代。实际上,发现其他项目使用 Symfony 2 组件与其他框架混合是很常见的,因为你只需在你的 Composer 文件中添加模块名称即可使用它。

你可以通过执行一个命令来使用 Symfony 2 开始应用程序。Symfony 2 为你创建所有目录、空配置文件等,一切准备就绪。你也可以从命令行添加空控制器。它们使用 Doctrine 2 作为 ORM,这可能是 PHP 现在能提供的最可靠的 ORM 之一。对于模板引擎,你会发现 Twig,这是我们框架中使用的相同工具。

通常,这是一个非常吸引人的框架,背后有一个庞大的社区提供支持;此外,许多公司也在使用它。至少检查一下模块列表总是值得的,以防你不想使用整个框架,但想利用其中的一些部分。

Zend Framework 2

第二大 PHP 框架,至少从去年开始,就是 Zend Framework 2。与 Symfony 一样,它也已经存在很长时间了。同样,像任何其他现代框架一样,它是用面向对象的方式构建的,试图实现用于 Web 应用程序的所有良好设计模式。它由多个组件组成,您可以在其他项目中重用,例如他们知名的认证系统。它缺少一些元素,例如模板引擎——通常他们会混合 PHP 和 HTML——以及 ORM,但您可以轻松地集成您喜欢的那些。

为了发布 Zend Framework 3,目前正在进行大量工作,它将支持 PHP 7、性能改进和一些其他新组件。我们建议您密切关注;它可能是一个不错的选择。

其他框架

尽管 Symfony 和 Zend Framework 是两大主要玩家,但在过去的几年里,越来越多的 PHP 框架出现了,发展迅速,带来了更多有趣的功能。像 CodeIgniter、Yii、PHPCake 等名字,一旦您开始浏览 PHP 项目,就会变得熟悉起来。由于其中一些比 Symfony 和 Zend Framework 出现得晚,它们实现了一些其他框架没有的新功能,例如与 JavaScript 和 jQuery 相关的组件、与 Selenium 的 UI 测试集成等。

尽管仅仅因为您可能会从其中一个或另一个中确切地得到您需要的东西,所以多样化总是好事,但在选择框架时也要明智。社区在这里扮演着重要角色,因为如果您有任何问题,它将帮助您解决问题,或者您可以帮助随着每个新的 PHP 版本而演变的框架。

Laravel 框架

尽管 Symfony 和 Zend Framework 已经很长时间一直是主要玩家,但在过去的几年里,第三个框架开始崭露头角,其受欢迎程度增长如此之快,以至于如今它已成为开发者最喜欢的框架。简洁、优雅的代码和高开发速度是这个“工匠框架”的杀手锏。在本节中,您将一瞥 Laravel 能做什么,并迈出创建一个非常简单的应用程序的第一步。

安装

Laravel 附带了一套命令行工具,这将使您的生活更加轻松。因此,建议您全局安装它,而不是按项目安装——也就是说,将 Laravel 作为您环境中的另一个程序。您仍然可以通过运行以下命令使用 Composer 来完成此操作:

$ composer global require "laravel/installer"

此命令应将 Laravel 安装程序下载到~/.composer/vendor。为了能够在命令行中使用可执行文件,您需要运行类似以下命令:

$ sudo ln -s ~/.composer/vendor/bin/laravel /usr/bin/laravel

现在,您可以使用laravel命令。为了确保一切顺利,只需运行以下命令:

$ laravel –version

如果一切顺利,这将输出已安装的版本。

项目设置

是的,我们知道。每个教程都是从创建博客开始的。然而,我们正在构建 Web 应用程序,这是我们能够采取的添加一些价值的简单方法。那么,让我们开始吧;在你想添加应用程序的任何地方执行以下命令:

$ laravel new php-blog

此命令将输出类似于 Composer 所做的事情,简单来说,因为它使用 Composer 来获取依赖项。几秒钟后,应用程序可能会告诉你一切安装成功,你现在可以开始了。

Laravel 创建了一个包含大量内容的新的php-blog目录。你应该有一个类似于以下截图所示的目录结构:

项目设置

让我们设置数据库。你应该做的第一件事是使用正确的数据库凭据更新.env文件。更新DB_DATABASE值为你自己的;以下是一个示例:

DB_HOST=localhost
DB_DATABASE=php_blog
DB_USERNAME=root
DB_PASSWORD=

你还需要创建php_blog数据库。只需一个命令即可完成,如下所示:

$ mysql -u root -e "CREATE SCHEMA php_blog"

使用 Laravel,你有一个迁移系统;也就是说,你将所有的数据库模式更改保存在database/migrations下,这样任何使用你代码的其他人都可以快速设置他们的数据库。第一步是运行以下命令,这将为blogs表创建一个迁移文件:

$ php artisan make:migration create_posts_table --create=posts

打开生成的文件,它应该类似于database/migrations/<日期>_create_posts_table.phpup方法定义了具有自增 ID 和时间戳字段的 blogs 表。我们希望添加一个标题,帖子的内容以及创建它的用户 ID。将up方法替换为以下内容:

public function up()
{
    Schema::create('posts', function (Blueprint $table) {
 $table->increments('id');
 $table->timestamps();
 $table->string('title');
 $table->text('content');
 $table->integer('user_id')->unsigned();
 $table->foreign('user_id')
 ->references('id')->on('users');
    });
}

在这里,标题将是一个字符串,而内容是文本。区别在于这些字段的长度,字符串是一个简单的VARCHAR,而文本是一个TEXT数据类型。对于用户 ID,我们定义了INT UNSIGNED,它引用了users表的id字段。Laravel 在创建项目时已经定义了users表,所以你不必担心。如果你对此感兴趣,请检查database/migrations/2014_10_12_000000_create_users_table.php文件。你会注意到一个用户由一个 ID、一个名称、唯一的电子邮件和密码组成。

到目前为止,我们只是编写了迁移文件。为了应用它们,你需要运行以下命令:

$ php artisan migrate

如果一切如预期进行,你现在应该有一个类似于以下内容的blogs表:

项目设置

为了完成所有准备工作,我们需要为我们的blogs表创建一个模型。此模型将扩展自Illuminate\Database\Eloquent\Model,这是 Laravel 使用的 ORM。要自动生成此模型,请运行以下命令:

$ php artisan make:model Post

模型的名称应该与数据库表的名称相同,但使用单数形式。运行此命令后,你可以在app/Post.php中找到空的模型。

添加第一个端点

让我们添加一个快速端点,以便了解路由的工作方式和如何将控制器与模板链接。为了避免数据库访问,让我们构建添加新帖子的视图,该视图将显示一个表单,允许用户添加带有标题和文本的新帖子。让我们先添加路由和控制器。打开 app/Http/routes.php 文件,并添加以下内容:

Route::group(['middleware' => ['web']], function () {
 Route::get('/new', function () {
 return view('new');
 });
});

这三条非常简单的行表示,对于 /new 端点,我们希望回复 new 视图。稍后我们将在控制器中使事情变得复杂,但现在让我们专注于视图。

Laravel 使用 Blade 作为模板引擎而不是 Twig,但它们的工作方式非常相似。它们也可以从其他模板扩展定义布局。你的布局位置在 resources/views/layouts。在这个目录下创建一个 app.blade.php 文件,内容如下:

<!DOCTYPE html>
<html lang="en">
<head>
    <title>PHP Blog</title>
 <link rel="stylesheet" href="{{ URL::asset('css/layout.css') }}" type="text/css">
 @yield('css')
</head>
<body>
<div class="navbar">
    <ul>
        <li><a href="/new">New article</a></li>
        <li><a href="/">Articles</a></li>
    </ul>
</div>
<div class="content">
@yield('content')
</div>
</body>
</html>

这是一个普通的布局,包含标题、一些 CSS 和在主体中的 ul 列表,这些列表将用作导航栏。除了应该已经熟悉的 HTML 代码之外,这里有两个重要的元素需要注意:

  • 要定义一个块,Blade 使用 @yield 注解后跟块名称。在我们的布局中,我们定义了两个块:csscontent

  • 有一个功能允许你在模板中构建 URL。我们想在 public/css/layout.css 中包含 CSS 文件,所以我们将使用 URL::asset 来构建这个 URL。包含 JS 文件也很有帮助。

如你所见,我们包含了 layout.css 文件。CSS 和 JS 文件存储在 public 目录下。在你的 public/css/layout.css 中创建它,内容如下:

.content {
    position: fixed;
    top: 50px;
    width: 100%
}
.navbar ul {
    position: fixed;
    top: 0;
    width: 100%;
    list-style-type: none;
    margin: 0;
    padding: 0;
    overflow: hidden;
    background-color: #333;
}
.navbar li {
    float: left;
    border-right: 1px solid #bbb;
}
.navbar li:last-child {
    border-right: none;
}
.navbar li a {
    display: block;
    color: white;
    text-align: center;
    padding: 14px 16px;
    text-decoration: none;
}
.navbar li a:hover {
    background-color: #111;
}

现在,我们可以专注于我们的视图。模板存储在 resources/views,和布局一样,它们需要 .blade.php 文件扩展名。在 resources/views/new.blade.php 中创建你的视图,内容如下:

@extends('layouts.app')

@section('css')
    <link rel="stylesheet" href="{{ URL::asset('css/new.css') }}" type="text/css">
@endsection

@section('content')
    <h2>Add new post</h2>
    <form method="post" action="/new">
        <div class="component">
            <label for="title">Title</label>
            <input type="text" name="title"/>
        </div>
        <div class="component">
            <label>Text</label>
            <textarea rows="20" name="content"></textarea>
        </div>
        <div class="component">
            <button type="submit">Save</button>
        </div>
    </form>
@endsection

语法非常直观。这个模板扩展自布局模板,并定义了两个部分或块:csscontent。包含的 CSS 文件遵循与上一个相同的格式。你可以在 public/css/new.css 中创建它,内容类似于以下:

label {
    display: block;
}
input {
    width: 80%;
}
button {
    font-size: 30px;
    float: right;
    margin-right: 20%;
}
textarea {
    width: 80%;
}
.component {
    padding: 10px;
}

模板的其他部分只是定义了一个指向相同 URL 的 POST 表单,包含标题和文本字段。一切准备就绪,可以在浏览器中测试它!尝试访问 http://localhost:8080/new 或你选择的端口号。你应该会看到以下截图类似的内容:

添加第一个端点

管理用户

如前所述,用户认证和授权是大多数框架包含的功能之一。Laravel 通过提供用户模型和注册认证控制器,使我们的生活变得非常简单。使用它们相当容易:你只需要添加指向已存在控制器的路由,并添加视图。让我们开始吧。

在这里你需要考虑五个路由。其中两个属于注册步骤,一个用于获取表单,另一个用于提交用户提供的表单信息。其余三个与认证部分相关:一个用于获取表单,一个用于提交表单,还有一个用于登出。这五个路由都包含在Auth\AuthController类中。在你的routes.php文件中添加以下路由:

// Registration routes...
Route::get('auth/register', 'Auth\AuthController@getRegister');
Route::post('auth/register', 'Auth\AuthController@postRegister');

// Authentication routes...
Route::get('/login', 'Auth\AuthController@getLogin');
Route::post('login', 'Auth\AuthController@postLogin');
Route::get('logout', 'Auth\AuthController@getLogout');

注意我们是如何定义这些路由的。与之前我们创建的不同,这些路由的第二个参数是一个字符串,它包含了控制器类名和方法名的连接。这是一种更好的创建路由的方式,因为它将逻辑分离到了一个可以稍后重用和/或单元测试的不同类中。

如果你对这个控制器感兴趣,你可以浏览其代码。你将发现一个复杂的设计,其中路由将调用的函数实际上是AuthController类使用的两个特质的一部分:RegistersUsersAuthenticatesUsers。检查这些方法将帮助你理解幕后发生了什么。

每个get路由都期望渲染一个视图。对于用户的注册,我们需要在resources/views/auth/register.blade.php中创建一个模板,对于登录视图,我们需要在resources/views/auth/login.blade.php中创建一个模板。一旦我们向正确的 URL 发送正确的 POST 参数,我们就可以添加我们认为必要的任何内容。

用户注册

让我们从注册表单开始;这个表单需要四个 POST 参数:姓名、电子邮件、密码和密码确认,正如路由所说明的,我们需要将其提交到/auth/register。模板可能看起来像以下这样:

@extends('layouts.app')

@section('css')
    <link rel="stylesheet" href="{{ URL::asset('css/register.css') }}" type="text/css">
@endsection

@section('content')
    <h2>Account registration</h2>

    <form method="post" action="/auth/register">
        {{ csrf_field() }}
        <div class="component">
            <label for="name">Name</label>
 <input type="text" name="name" 
 value="{{ old('name') }}" />
        </div>
        <div class="component">
            <label>Email</label>
 <input type="email" name="email"
 value="{{ old('email') }}"/>
        </div>
        <div class="component">
            <label>Password</label>
            <input type="password" name="password" />
        </div>
        <div class="component">
            <label>Password confirmation</label>
            <input type="password" name="password_confirmation" />
        </div>
        <div class="component">
            <button type="submit">Create</button>
        </div>
    </form>
@endsection

这个模板与新建帖子表单非常相似:它扩展了布局,添加了一个 CSS 文件,并用表单填充了内容区域。这里的新增功能是使用old函数,它会在表单无效并再次显示给用户时检索之前请求提交的值。

在我们尝试之前,我们需要添加一个register.css文件,其中包含此表单的样式。一个简单的例子可能如下:

div.content {
    text-align: center;
}
label {
    display: block;
}
input {
    width: 250px;
}
button {
    font-size: 20px;
}
.component {
    padding: 10px;
}

最后,我们应该编辑布局以在菜单中添加一个指向注册和登录页面的链接。这就像在ul标签的末尾添加以下li元素一样简单:

<li class="right"><a href="/auth/register">Sign up</a></li>
<li class="right"><a href="/login">Sign in</a></li>

还需要在layout.css的末尾添加right类的样式:

div.alert {
    color: red;
}

为了使事情更加有用,我们可以添加提交表单时出错的信息。Laravel 将错误闪存到会话中,并且可以通过errors模板变量访问它们。由于这是所有表单的共同点,而不仅仅是注册表单,我们可以将其添加到app.blade.php布局中,如下所示:

<div class="content">
 @if (count($errors) > 0)
 <div class="alert">
 <strong>Whoops! Something went wrong!</strong>
 @foreach ($errors->all() as $error)
 <p>{{ $error }}</p>
 @endforeach
 </div>
 @endif
@yield('content')

在这段代码中,我们将使用 Blade 的@if条件语句和@foreach循环。语法与 PHP 相同;唯一的区别是@前缀。

现在,我们已经准备好出发了。启动您的应用程序,点击菜单右侧的注册链接。尝试提交表单,但留一些字段为空,这样我们就可以注意错误是如何显示的。结果应该类似于以下内容:

用户注册

我们应该自定义的一件事是注册成功后用户将被重定向到哪个位置。在这种情况下,我们可以将他们重定向到登录页面。为了实现这一点,您需要更改AuthController$redirectTo属性的值。到目前为止,我们只有新帖子页面,但稍后,您可以通过以下方式添加任何您想要的路径:

protected $redirectPath= '/new;

用户登录

除了注册之外,用户的登录还有一些变化。我们不仅需要添加登录视图,还应该修改布局中的菜单,以便确认已认证的用户,移除注册链接,并添加一个注销链接。正如之前提到的,模板必须保存在resources/views/auth/login.blade.php。表单需要一个电子邮件和密码,以及可选的用于记住我功能的复选框。以下是一个示例:

@extends('layouts.app')

@section('css')
    <link rel="stylesheet" href="{{ URL::asset('css/register.css') }}" type="text/css">
@endsection

@section('content')
    <h2>Login</h2>

    <form method="POST" action="/login">
        {!! csrf_field() !!}
        <div class="component">
            <label>Email</label>
            <input type="email" name="email"
                   value="{{ old('email') }}">
        </div>
        <div class="component">
            <label>Password</label>
            <input type="password" name="password">
        </div>
        <div class="component">
            <input class="checkbox" type="checkbox" name="remember">                
            Remember Me
        </div>
        <div class="component">
            <button type="submit">Login</button>
        </div>
    </form>
@endsection

布局需要稍作修改。在我们显示注册和登录用户链接的地方,现在我们需要检查是否已经有一个用户已经认证;如果是这样,我们最好显示一个注销链接。您可以通过Auth::user()方法从视图中获取已认证的用户。如果结果不为空,这意味着用户已成功认证。使用以下代码更改两个链接:

<ul>
    <li><a href="/new">New article</a></li>
    <li><a href="/">Articles</a></li>
 @if (Auth::user() !== null)
 <li class="right">
 <a href="/logout">Logout</a>
 </li>
 @else
 <li class="right">
 <a href="/auth/register">Sign up</a>
 </li>
 <li class="right">
 <a href="/login">Sign in</a>
 </li>
 @endif
</ul>

受保护的路由

用户管理会话的最后一部分可能是最重要的。在验证用户时,主要目标之一是授权他们访问某些内容——也就是说,允许他们访问未经认证的用户无法访问的某些页面。在 Laravel 中,您可以通过仅添加auth中间件来定义以这种方式受保护的哪些路由。使用以下代码更新新帖子路由:

Route::get('/new', ['middleware' => 'auth', function () {
    return view('new');
}]);

一切准备就绪!在注销后尝试访问新帖子页面;您将被自动重定向到登录页面。你能感受到框架有多强大吗?

在模型中设置关系

正如我们之前提到的,Laravel 附带了一个 ORM,即 Eloquent ORM,这使得处理模型变得非常容易。在我们的简单数据库中,我们为帖子定义了一个表,并且已经为用户定义了另一个表。帖子包含拥有它的用户的 ID——即user_id。使用表名的单数形式后跟_id是一个好习惯,这样 Eloquent 就会知道在哪里查找。这就是我们关于外键所做的一切。

我们还应该在模型侧提及此关系。根据关系的类型(一对一、一对多或多对多),代码会有所不同。在我们的情况下,我们有一个一对多关系,因为一个用户可以有多个帖子。在 Laravel 中,我们需要更新PostUser模型。User模型需要指定它有多个帖子,因此您需要添加一个posts方法,内容如下:

public function posts() {
    return $this->hasMany('App\Post');
}

此方法表示用户模型有多个帖子。在Post中需要做的另一个更改类似:我们需要添加一个user方法来定义关系。该方法应类似于以下内容:

public function user() {
    return $this->belongsTo('App\User');
}

它看起来很少,但这正是我们需要的全部配置。在下一节中,您将看到使用这两个模型保存和查询是多么容易。

创建复杂控制器

尽管本节标题提到了复杂控制器,但请注意,我们可以用很少的代码创建完整且强大的控制器。让我们先添加管理帖子创建的代码。这个控制器需要链接到以下路由:

Route::post('/new', 'Post\PostController@createPost');

如您所想象,现在我们需要创建包含createPost方法的Post\PostController类。控制器应存储在app/Http/Controllers中,如果可以按文件夹组织,那就更好了。将以下类保存到app/Http/Controllers/Post/PostController.php

<?php

namespace App\Http\Controllers\Post;

use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Validator;
use App\Post;

class PostController extends Controller {

    public function createPost(Request $request) {

    }
}

到目前为止,从这个类中我们可以注意到的只有两件事:

  • 控制器从App\Http\Controllers\Controller类扩展,该类包含所有控制器的一些通用辅助函数。

  • 控制器的方法定义了用户请求的Illuminate\Http\Request参数。此对象将包含诸如已提交的参数、cookie 等信息。这与我们在自己的应用程序中创建的类似。

在这类控制器中,我们首先需要检查提交的参数是否正确。为此,我们将使用以下代码:

public function createPost(Request $request) {
 $validator = Validator::make($request->all(), [
 'title' => 'required|max:255',
 'content' => 'required|min:20',
 ]);

 if ($validator->fails()) {
 return redirect()->back()
 ->withInput()
 ->withErrors($validator);
    }
}

我们首先创建了一个验证器。为此,我们使用了Validator::make函数,并传递了两个参数:第一个参数包含请求中的所有参数,第二个参数是一个包含预期字段及其约束的数组。请注意,我们期望有两个必填字段:titlecontent。在这里,第一个字段最长可达 255 个字符,第二个字段至少需要 20 个字符长。

一旦创建了validator对象,我们可以使用fails方法检查用户提交的数据是否符合要求。如果它返回true——即验证失败——我们将使用redirect()->back()将用户重定向回上一页。为此调用,我们将添加两个额外的调用:withInput将发送提交的值以便我们可以再次显示它们,而withErrors将以与AuthController相同的方式发送错误。

在这一点上,如果帖子无效,显示之前提交的标题和文本将有助于用户。为此,使用视图中的已知old方法:

{{--...--}}
    <input type="text" name="title" 
 value="{{ old('title') }}"/>
</div>
<div class="component">
    <label>Text</label>
    <textarea rows="20" name="content">
 {{ old('content') }}
    </textarea>
{{--...--}}

在这一点上,我们已经开始测试控制器在帖子不匹配所需验证时的行为。如果你遗漏了任何参数或它们的长度不正确,你将得到一个类似于以下错误页面的错误:

创建复杂控制器

现在我们来添加保存帖子的逻辑,以防它是有效的。如果你还记得我们之前应用中与模型的交互,你将会很高兴地发现在这里与他们一起工作是多么容易。看看下面的内容:

public function createPost(Request $request) {
    $validator = Validator::make($request->all(), [
        'title' => 'required|max:255',
        'content' => 'required|min:20',
    ]);

    if ($validator->fails()) {
        return redirect()->back()
            ->withInput()
            ->withErrors($validator);
    }

 $post = new Post();
 $post->title = $request->title;
 $post->content = $request->content;

 Auth::user()->posts()->save($post);

 return redirect('/new');
}

我们首先将创建一个post对象,设置标题和内容来自请求值。然后,给定Auth::user()的结果,它给我们当前认证的用户模型实例,我们将通过posts()->save($post)保存我们刚刚创建的帖子。如果我们想不包含用户信息保存帖子,我们可以使用$post->save()。实际上,就是这样。

让我们快速添加另一个端点来检索给定用户的帖子列表,这样我们就可以看看 Eloquent ORM 如何使我们轻松地获取数据。添加以下路由:

Route::get('/', ['middleware' => 'auth', function () {
    $posts = Auth::user()
        ->posts()
        ->orderBy('created_at')
        ->get();
    return view('posts', ['posts' => $posts]);
}]);

我们检索数据的方式与我们保存数据的方式非常相似。我们需要一个模型的实例——在这个例子中,是经过身份验证的用户——然后我们将添加一系列方法调用,这些调用将内部生成要执行的查询。在这种情况下,我们将按创建日期排序请求帖子。为了向视图发送信息,我们需要传递第二个参数,它将是一个参数名称和值的数组。

将以下模板添加为resources/views/posts.blade.php,它将显示经过身份验证的用户作为表格的帖子列表。注意我们将在以下代码中使用$post对象,它是一个模型的实例:

@extends('layouts.app')

@section('css')
    <link rel="stylesheet" href="{{ URL::asset('css/posts.css') }}" type="text/css">
@endsection

@section('content')
    <h2>Your posts</h2>

    <table>
    @foreach ($posts as $post)
        <tr>
 <td>{{ $post->title }}</td>
 <td>{{ $post->created_at }}</td>
 <td>{{ str_limit($post->content, 100) }}</td>
        </tr>
    @endforeach
    </table>
@endsection

帖子列表最终显示出来。结果应该类似于以下屏幕截图:

创建复杂控制器

添加测试

在很短的时间内,我们创建了一个允许你从头开始注册、登录、创建和列出帖子的应用程序。我们将通过讨论如何使用 PHPUnit 测试你的 Laravel 应用程序来结束本节。

在 Laravel 中编写测试非常容易,因为它与 PHPUnit 有非常好的集成。已经有一个phpunit.xml文件,一个定制的TestCase类,定制的断言,以及许多辅助器来测试数据库。它还允许你测试路由,通过模拟 HTTP 请求而不是测试控制器来测试。我们将在测试创建新帖子时访问所有这些功能。

首先,我们需要删除tests/ExampleTest.php,因为它测试了主页,由于我们对其进行了修改,它将失败。不要担心;这是一个帮助开发者开始测试的示例测试,让它失败根本不是问题。

现在,我们需要创建我们的新测试。为此,我们可以手动添加文件或使用命令行并运行以下命令:

$ php artisan make:test NewPostTest

此命令创建了一个tests/NewPostTest.php文件,它继承自TestCase。如果你打开它,你会注意到其中已经有一个虚拟测试,你也可以将其删除。无论如何,你可以运行 PHPUnit 来确保一切通过。你可以像我们之前做的那样做,如下所示:

$ ./vendor/bin/phpunit

我们可以添加的第一个测试是尝试添加新帖子,但通过 POST 参数传递的数据无效的情况。在这种情况下,我们应该期望响应包含错误和旧数据,以便用户可以编辑它而不是重写一切。将以下测试添加到NewPostTest类中:

<?php

class NewPostTest extends TestCase
{
    public function testWrongParams() {
        $user = factory(App\User::class)
            ->make(['email' => 'test@user.laravel']);

        $this->be($user);

        $this->call(
            'POST',
            '/new',
            ['title' => 'the title', 'content' => 'ojhkjhg']
        );

        $this->assertSessionHasErrors('content');
        $this->assertHasOldInput();
    }
}

在测试中,我们首先可以注意到使用工厂创建了一个user实例。你可以在make调用中传递一个包含任何你想要设置的参数的数组;否则,将使用默认值。在获取到user实例后,我们将将其发送到be方法,让 Laravel 知道我们希望该用户成为本次测试的授权用户。

一旦我们为测试设置了前提条件,我们将使用call辅助方法来模拟真实的 HTTP 请求。我们必须向此方法发送 HTTP 方法(在这种情况下,POST)、请求的路由,以及可选的参数。请注意,call方法返回响应对象,以防你需要它。

我们将发送一个标题和内容,但第二个内容不够长,因此我们预计会出现一些错误。Laravel 自带了几个定制的断言,尤其是在测试这类响应时。在这种情况下,我们可以使用其中两个:assertSessionHasErrors,它检查会话中是否存在任何闪存错误(特别是针对内容参数的错误),以及assertHasOldInput,它检查响应中是否包含旧数据以便将其显示给用户。

我们想要添加的第二个测试是用户提交有效数据的情况,以便我们可以将帖子保存到数据库中。这个测试比较复杂,因为我们需要与数据库交互,这通常不是一个愉快的体验。然而,Laravel 为我们提供了足够的工具来帮助我们完成这项任务。首先也是最重要的是让 PHPUnit 知道我们希望在每次测试中使用数据库事务。然后,我们需要将认证用户持久化到数据库中,因为帖子有一个指向它的外键。最后,我们应该断言帖子已正确保存到数据库中。将以下代码添加到NewPostTest类中:

use DatabaseTransactions;

//...

public function testNewPost() {
    $postParams = [
        'title' => 'the title',
        'content' => 'In a place far far away.'
    ];

    $user = factory(App\User::class)
        ->make(['email' => 'test@user.laravel']);
 $user->save();

    $this->be($user);

    $this->call('POST', '/new', $postParams);

 $this->assertRedirectedTo('http://localhost/new');
 $this->seeInDatabase('posts', $postParams);
}

DatabaseTransactions 特性将使测试在开始时启动一个事务,然后在测试完成后回滚,这样我们就不会在数据库中留下测试数据。将认证用户保存到数据库也是一个简单的任务,因为工厂的结果是用户模型的一个实例,我们只需调用它的 save 方法。

assertRedirectedTo 断言将确保响应包含有效的头信息,将用户重定向到指定的 URL。更有趣的是,seeInDatabase 将验证在 posts 表中存在一个实体,这是第一个参数,具有数组中提供的数据,这是第二个参数。

有很多断言,但正如你可以注意到的,它们非常有用,将可能是一个长测试的代码减少到非常少的行数。我们建议你访问官方文档以获取完整的列表。

Silex 微框架

在尝试了 Laravel 可以提供给你的东西之后,你很可能不想再听到关于极简微框架的消息。尽管如此,我们认为了解多个框架是好的。你可以了解不同的方法,更加灵活,每个人都会希望你在他们的团队中。

我们选择 Silex 是因为它是一个微框架,这与 Laravel 非常不同,而且它也是 Symfony 家族的一部分。通过这个 Silex 介绍,你将学习如何使用你的第二个框架,这是一个完全不同类型的框架,你将更接近了解 Symfony,它是大玩家之一。

微框架的好处是什么?嗯,它们提供了最基本的东西——也就是说,一个路由器、一个简单的依赖注入器、请求助手等等,但这就是全部了。你有足够的空间去选择和构建你真正需要的东西,包括外部库甚至你自己的库。这意味着你可以为每个不同的项目定制一个框架。实际上,Silex 提供了一系列内置的服务提供者,你可以非常容易地集成它们,从模板引擎到日志记录或安全性。

安装

这里没有新闻。Composer 会为你做所有的事情,就像它在 Laravel 中做的那样。在你的新项目根目录下,在命令行中执行以下命令,以便将 Silex 包含到你的 composer.json 文件中:

$ composer require silex/silex

你可能需要更多的依赖项,但让我们在需要的时候再添加它们。

项目设置

Silex 的最重要的类是 Silex\Application。这个类从 Pimple(一个轻量级的依赖注入器)扩展而来,管理几乎所有的事情。你可以像使用数组一样使用它,因为它实现了 ArrayAccess 接口,或者你可以调用它的方法来添加依赖项、注册服务等等。首先要做的是在你的 public/index.php 文件中实例化它,如下所示:

<?php

use Silex\Application;

require_once __DIR__ . '/../vendor/autoload.php';

$app = new Application();

管理配置

我们喜欢做的第一件事是加载配置。我们可以做一些非常简单的事情,比如包含一个包含 PHP 或 JSON 内容的文件,但让我们利用一个服务提供商,ConfigServiceProvider。让我们通过以下行使用 Composer 添加它:

$ composer require igorw/config-service-provider

这个服务允许我们拥有多个配置文件,每个环境一个。想象一下,如果我们想要有两个环境,proddev,这意味着我们需要两个文件:一个在config/prod.json中,一个在config/dev.json中。config/dev.json文件看起来会像这样:

{
  "debug": true,
  "cache": false,
  "database": {
    "user": "dev",
    "password": ""
  }
}

config/prod.json文件看起来会像这样:

{
  "debug": false,
  "cache": true,
  "database ": {
    "user": "root",
    "password": "fsd98na9nc"
  }
}

为了在开发环境中工作,你需要通过运行以下命令来设置环境变量的正确值:

export APP_ENV=dev

APP_ENV环境变量将告诉我们我们处于哪个环境。现在,是时候使用这个服务提供商了。为了通过读取当前环境的配置文件来注册它,请将以下行添加到你的index.php文件中:

$env = getenv('APP_ENV') ?: 'prod';
$app->register(
    new Igorw\Silex\ConfigServiceProvider(
        __DIR__ . "/../config/$env.json"
    )
);

我们在这里做的第一件事是从环境变量中获取环境。默认情况下,我们将其设置为prod。然后,我们通过传递正确的配置文件路径从$app对象中调用register来添加ConfigServiceProvider的实例。从现在起,$app“数组”将包含三个条目:debugcachedb,它们包含配置文件的内容。我们将能够在访问$app时访问它们,这将在大多数地方发生。

设置模板引擎

另一个方便的服务提供商是 Twig。你可能还记得,Twig 是我们自己框架中使用的模板引擎,实际上它是由开发 Symfony 和 Silex 的人开发的。你也已经知道如何使用 Composer 添加依赖项;只需运行以下命令:

$ composer require twig/twig

为了注册服务,我们需要在我们的public/index.php文件中添加以下行:

$app->register(
    new Silex\Provider\TwigServiceProvider(),
    ['twig.path' => __DIR__ . '/../views']
);

此外,创建views/目录,我们将稍后存储我们的模板。现在,你可以通过访问$app['twig']来获取Twig_Environment实例。

添加日志记录器

我们现在要注册的最后一个是日志记录器。这次,我们将使用的是Monolog库,你可以通过以下方式来包含它:

$ composer require monolog/monolog

注册服务的最快方式是只提供日志文件的路径,可以按照以下方式操作:

$app->register(
    new Silex\Provider\MonologServiceProvider(),
    ['monolog.logfile' => __DIR__ . '/../app.log']
);

如果你想要向这个服务提供商添加更多信息,比如你想要保存的日志级别、日志名称等,你可以将它们与日志文件一起添加到数组中。查看silex.sensiolabs.org/doc/providers/monolog.html以获取可用参数的完整列表。

就像模板引擎一样,从现在起,你可以通过访问$app['monolog']Application对象中访问Monolog\Logger实例。

添加第一个端点

是时候看看 Silex 中的路由器是如何工作的了。我们希望为主页添加一个简单的端点。正如我们之前提到的,$app 实例可以管理几乎所有事情,包括路由。在 public/index.php 文件末尾添加以下代码:

$app->get('/', function(Application $app) {
    return $app['twig']->render('home.twig');
});

这是一种类似于 Laravel 添加路由的方式。我们调用了 get 方法,因为它是一个 GET 端点,我们传递了路由字符串和 Application 实例。正如我们在这里提到的,$app 也是一个依赖注入器——实际上,它扩展自一个:Pimple——因此您几乎可以在任何地方看到 Application 实例。匿名函数的结果将是我们将发送给用户的响应——在这种情况下,是一个渲染的 Twig 模板。

目前,这还不能解决问题。为了让 Silex 知道您已经完成了应用程序的设置,您需要在 public/index.php 文件的最后调用 run 方法。请记住,如果您需要在此文件中添加其他内容,它必须在此行之前:

$app->run();

您已经使用过 Twig,所以我们不会在这方面花费太多时间。首先需要添加的是 views/home.twig 模板:

{% extends "layout.twig" %}

{% block content %}
    <h1>Hi visitor!</h1>
{% endblock %}

现在,正如您可能已经猜到的,我们将添加 views/layout.twig 模板,如下所示:

<html>
<head>
    <title>Silex Example</title>
</head>
<body>
{% block content %}
{% endblock %}
</body>
</html>

尝试访问您应用程序的主页;您应该得到以下结果:

添加第一个端点

访问数据库

对于本节,我们将编写一个端点来为我们的菜谱创建菜谱。按照以下顺序运行以下 MySQL 查询以设置 cookbook 数据库并创建空的 recipes 表:

mysql> CREATE SCHEMA cookbook;
Query OK, 1 row affected (0.00 sec)
mysql> USE cookbook;
Database changed
mysql> CREATE TABLE recipes(
 -> id INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
 -> name VARCHAR(255) NOT NULL,
 -> ingredients TEXT NOT NULL,
 -> instructions TEXT NOT NULL,
 -> time INT UNSIGNED NOT NULL);
Query OK, 0 rows affected (0.01 sec)

Silex 没有提供任何 ORM 集成,因此您需要手动编写 SQL 查询。然而,有一个 Doctrine 服务提供者,它提供了一个比 PDO 更简单的接口,所以让我们尝试将其集成。要安装此服务,请运行以下命令:

$ composer require "doctrine/dbal:~2.2"

现在,我们已经准备好注册服务提供者。与其他服务一样,在路由定义之前将以下代码添加到您的 public/index.php 文件中:

$app->register(new Silex\Provider\DoctrineServiceProvider(), [
    'dbs.options' => [
        [
            'driver'    => 'pdo_mysql',
            'host'      => '127.0.0.1',
            'dbname'    => 'cookbook',
            'user'      => $app['database']['user'],
            'password'  => $app['database']['password']
        ]
    ]
]);

在注册时,您需要提供数据库连接的选项。其中一些选项将与环境无关,例如驱动程序或甚至主机,但一些选项将来自配置文件,例如 $app['database']['user']。从现在开始,您可以通过 $app['db'] 访问数据库连接。

数据库设置完成后,让我们添加允许我们添加和检索菜谱的路由。与 Laravel 一样,您可以指定匿名函数,就像我们已经做的那样,或者一个控制器和要执行的方法。用以下三个路由替换当前的路由:

$app->get(
    '/',
    'CookBook\\Controllers\\RecipesController::getAll'
);
$app->post(
    '/recipes',
    'CookBook\\Controllers\\RecipesController::create'
);
$app->get(
    '/recipes',
    'CookBook\\Controllers\\RecipesController::getNewForm'
);

如您所观察到的,将会有一个新的控制器,CookBook\Controllers\RecipesController,它将被放置在 src/Controllers/RecipesController.php 中。这意味着您需要更改 Composer 中的自动加载器。使用以下内容编辑您的 composer.json 文件:

"autoload": {
    "psr-4": {"CookBook\\": "src/"}
}

现在,让我们添加控制器类,如下所示:

<?php

namespace CookBook\Controllers;

class Recipes {

}

我们将添加的第一个方法是getNewForm方法,它将只渲染添加新食谱的页面。这个方法看起来类似这样:

public function getNewForm(Application $app): string {
    return $app['twig']->render('new_recipe.twig');
}

这个方法将只渲染new_recipe.twig模板。这个模板的一个例子可能如下所示:

{% extends "layout.twig" %}

{% block content %}
    <h1>Add recipe</h1>
    <form method="post">
        <div>
            <label for="name">Name</label>
            <input type="text" name="name"
                   value="{{ name is defined ? name : "" }}" />
        </div>
        <div>
            <label for="ingredients">Ingredients</label>
            <textarea name="ingredients">
                {{ ingredients is defined ? ingredients : "" }}
            </textarea>
        </div>
        <div>
            <label for="instructions">Instructions</label>
            <textarea name="instructions">
                {{ instructions is defined ? instructions : "" }}
            </textarea>
        </div>
        <div>
            <label for="time">Time (minutes)</label>
            <input type="number" name="time"
                   value="{{ time is defined ? time : "" }}" />
        </div>
        <div>
            <button type="submit">Save</button>
        </div>
    </form>
{% endblock %}

这个模板发送菜名、配料、说明以及准备这道菜所需的时间。获取这个表单的端点需要获取响应对象以提取这些信息。同样,我们可以通过在方法定义中指定它来获取Application实例作为参数,我们也可以获取Request实例。访问 POST 参数就像通过发送参数名称调用get方法一样简单,或者调用$request->request->all()以获取所有参数作为数组。添加以下方法,该方法检查所有数据是否有效,如果无效则重新渲染表单,并发送提交的数据和错误:

public function create(Application $app, Request $request): string {
    $params = $request->request->all();
    $errors = [];

    if (empty($params['name'])) {
        $errors[] = 'Name cannot be empty.';
    }
    if (empty($params['ingredients'])) {
        $errors[] = 'Ingredients cannot be empty.';
    }
    if (empty($params['instructions'])) {
        $errors[] = 'Instructions cannot be empty.';
    }
    if ($params['time'] <= 0) {
        $errors[] = 'Time has to be a positive number.';
    }

    if (!empty($errors)) {
        $params = array_merge($params, ['errors' => $errors]);
        return $app['twig']->render('new_recipe.twig', $params);
    }
}

layout.twig模板也需要进行编辑,以便显示返回的错误。我们可以通过执行以下操作来完成:

{# ... #}
{% if errors is defined %}
    <p>Something went wrong!</p>
    <ul>
    {% for error in errors %}
        <li>{{ error }}</li>
    {% endfor %}
    </ul>
{% endif %}
{% block content %}
{# ... #}

到目前为止,你已经开始尝试访问http://localhost/recipes,填写表单时留一些空白,提交,然后得到带有错误的表单。它应该看起来类似这样(带有一些额外的 CSS 样式):

访问数据库

控制器的后续部分应该允许我们将正确数据作为新食谱存储到数据库中。为此,创建一个单独的类,例如CookBook\Models\RecipeModel,会是一个好主意;然而,为了加快速度,让我们将以下几行代码添加到控制器中。记住,我们有 Doctrine 服务提供者,所以没有必要直接使用 PDO:

$sql = 'INSERT INTO recipes (name, ingredients, instructions, time) '
    . 'VALUES(:name, :ingredients, :instructions, :time)';
$result = $app['db']->executeUpdate($sql, $params);

if (!$result) {
    $params = array_merge($params, ['errors' => $errors]);
    return $app['twig']->render('new_recipe.twig', $params);
}

return $app['twig']->render('home.twig');

Doctrine 在获取数据时也很有帮助。要看到它的工作,请检查第三个也是最后一个方法,我们将从中获取所有食谱以供用户查看:

public function getAll(Application $app): string {
 $recipes = $app['db']->fetchAll('SELECT * FROM recipes');
    return $app['twig']->render(
        'home.twig',
        ['recipes' => $recipes]
    );
}

只用一行代码,我们就执行了一个查询。它没有 Laravel 的 Eloquent ORM 那么干净,但至少比使用原始 PDO 要简洁得多。最后,你可以使用以下内容更新你的home.twig模板,以便显示我们从数据库中检索到的食谱:

{% extends "layout.twig" %}

{% block content %}
    <h1>Hi visitor!</h1>
    <p>Check our recipes!</p>
    <table>
        <th>Name</th>
        <th>Time</th>
        <th>Ingredients</th>
        <th>Instructions</th>
    {% for recipe in recipes %}
        <tr>
            <td>{{ recipe.name }}</td>
            <td>{{ recipe.time }}</td>
            <td>{{ recipe.ingredients }}</td>
            <td>{{ recipe.instructions }}</td>
        </tr>
    {% endfor %}
    </table>
{% endblock %}

Silex 与 Laravel 对比

尽管我们在本章开始之前做了一些类似的比较,但现在是我们回顾我们说过的话,并将其与你自己注意到的进行比较的时候了。Laravel 属于那种允许你用很少的工作就能创造出伟大事物的框架类型。它包含了作为网络开发者你将需要的所有组件。它之所以能如此迅速地成为年度最受欢迎的框架,肯定有一些很好的理由!

另一方面,Silex 是一个微框架,它本身几乎不做任何事情。它只是你可以构建所需框架的骨架。它已经提供了很多服务提供者,我们甚至没有讨论到一半;我们建议你访问silex.sensiolabs.org/doc/providers.html以获取完整列表。然而,如果你愿意,你总是可以用 Composer 添加其他依赖并使用它们。如果你出于某种原因不再喜欢你所使用的 ORM 或模板引擎,或者社区中出现了一个新的、更好的版本,切换它们应该很容易。另一方面,当与 Laravel 一起工作时,你可能会坚持使用它自带的功能。

每个框架都有其适用的场合,我们鼓励你保持开放的心态,接受所有可能的选择,保持最新,并时不时地探索新的框架或技术。

摘要

在本章中,你了解到了解一些最重要的框架是多么重要。你还学习了两个著名框架的基础:Laravel 和 Silex。现在,你准备好使用你的框架,或者为你的下一个应用使用这两个框架。有了这些,你也有能力轻松地理解任何其他类似的框架。

在下一章中,我们将学习什么是 REST API 以及如何使用 Laravel 编写一个。这将扩展你的技能集,并在你需要决定在设计和应用编写时采取哪种方法时提供更多灵活性。

第九章:构建 REST API

大多数非开发者可能认为创建应用程序意味着为 PC 或 Mac 构建软件、游戏或网页,因为这是他们能看到并使用的。但一旦你加入开发者社区,无论是自己还是专业上,你最终会意识到为没有用户界面的应用程序和工具所做的多少工作。

你是否曾经想过某个人的网站是如何访问你的 Facebook 个人资料的,后来又如何在你的墙上自动发布消息?或者网站是如何在不需要刷新或提交任何表单的情况下发送/接收信息以更新页面内容的?所有这些功能,以及许多其他有趣的功能,都是由于“幕后”工作的应用程序的集成才成为可能的。了解如何使用它们将为创建更有趣和有用的网络应用程序打开大门。

在本章中,你将学习以下内容:

  • API 和 REST API 的介绍及其应用

  • REST API 的基础

  • 使用第三方 API

  • REST API 开发者的工具

  • 使用 Laravel 设计和编写 REST API

  • 测试你的 REST API 的不同方法

介绍 API

API代表应用程序程序接口。其目标是提供一个接口,以便其他程序可以发送命令来触发应用程序内部的某些过程,可能返回一些输出。这个概念可能看起来有点抽象,但实际上,几乎与计算机有关的一切都有 API。让我们看看一些现实生活中的例子:

  • 操作系统或 OS,如 Windows 或 Linux,是允许你使用计算机的程序。当你使用计算机上的任何应用程序时,它很可能会以某种方式与操作系统进行通信,例如请求某个文件,向扬声器发送一些音频等。所有这些应用程序与操作系统之间的交互都是由于操作系统提供的 API 才成为可能。这样,应用程序无需直接与硬件交互,这是一个非常繁琐的任务。

  • 为了与用户交互,移动应用程序提供了一个 GUI。该界面捕获用户触发的事件,如点击或输入,以便将它们发送到服务器。GUI 使用 API 与服务器通信,就像程序之前解释的那样与操作系统通信。

  • 当你创建一个需要显示用户 Twitter 账户推文的网站时,你需要与 Twitter 进行通信。他们提供了一个可以通过 HTTP 访问的 API。一旦认证通过,通过发送正确的 HTTP 请求,你可以更新和/或从他们的应用程序中检索数据。

如您所见,APIs 在不同的地方都有用。一般来说,当您有一个需要外部访问的系统时,您需要为潜在用户提供一个 API。当我们说外部时,我们指的是来自另一个应用程序或库,但也可以是在同一台机器内部。

介绍 REST API

REST API 是 API 的一种特定类型。它们使用 HTTP 作为与它们通信的协议,所以您可以想象它们将是 Web 应用程序中最常用的。事实上,它们与您已经构建的网站并没有太大的不同,因为客户端发送 HTTP 请求,服务器以 HTTP 响应回复。这里的区别在于 REST API 大量使用 HTTP 状态码来理解响应的内容,并且不是返回带有 CSS 和 JS 的 HTML 资源,而是使用仅包含信息的 JSON、XML 或其他文档格式,而不是图形用户界面。

让我们举一个例子。一旦认证,Twitter API 允许开发者通过向 https://api.twitter.com/1.1/statuses/user_timeline.json 发送 HTTP GET 请求来获取特定用户的推文。对这个请求的响应是一个包含推文 JSON 映射的 HTTP 消息,状态码为 200。我们已经在第二章中提到了状态码,使用 PHP 的 Web 应用程序,但我们很快会回顾它们。

REST API 还允许开发者代表用户发布推文。如果您已经在之前的例子中进行了认证,那么您只需要向 https://api.twitter.com/1.1/statuses/update.json 发送一个 POST 请求,并在正文中包含适当的 POST 参数,比如您想要推文的文本。尽管这个请求不是 GET 请求,因此您不是请求数据而是发送数据,但这个请求的响应同样很重要。服务器将使用响应的状态码来通知请求者推文是否成功发布,或者如果请求无法理解,发生了内部服务器错误,认证无效,等等。每种情况都有不同的状态码,这在所有应用程序中都是相同的。这使得与不同的 API 通信变得非常容易,因为您不需要每次都学习新的状态码列表。服务器还可以在正文中添加一些额外信息,以便阐明错误发生的原因,但这将取决于应用程序。

您可以想象这些 REST API 是为开发者提供的,以便他们可以将它们集成到他们的应用程序中。它们对用户不友好,但对 HTTP 友好。

REST API 的基础

尽管 REST API 没有官方标准,但大多数开发者都同意相同的基础。这得益于 HTTP,这是该技术用于通信的协议,确实有一个标准。在本节中,我们将尝试描述 REST API 应该如何工作。

HTTP 请求方法

我们已经在第二章中介绍了 HTTP 方法的概念,即“使用 PHP 的 Web 应用”。我们解释说,HTTP 方法只是请求的动词,它定义了请求试图执行的动作。当我们使用 HTML 表单时,我们已经定义了这种方法:form标签可以有一个可选的属性method,这将使表单使用该特定的 HTTP 方法提交。

在使用 REST API 时,您不会使用表单,但您仍然可以指定请求的方法。实际上,两个请求可以针对同一个端点,使用相同的参数、头部信息等,但由于它们的方法不同,因此表现出完全不同的行为,这使得方法成为请求中非常重要的部分。

由于我们非常重视 HTTP 方法来识别请求试图做什么,因此自然需要一系列的方法。到目前为止,我们已经介绍了 GET 和 POST,但实际上有八种不同的方法:GET、POST、PUT、DELETE、OPTIONS、HEAD、TRACE 和 CONNECT。您通常只需要使用其中的四种。让我们详细看看它们。

GET

当请求使用 GET 方法时,这意味着它正在请求有关某个实体的信息。端点应该包含有关该实体的信息,例如一本书的 ID。GET 也可以用来查询对象列表,可以是全部、过滤或分页的。

当需要时,GET 请求可以添加额外的信息到请求中。例如,如果我们试图检索包含字符串“rings”的所有书籍,或者如果我们想要获取书籍完整列表的第 2 页。如您所知,这些额外信息作为 GET 参数添加到查询字符串中,这是一个由和号(&)连接的键值对列表。因此,这意味着请求http://bookstore.com/books?year=2001&page3可能是用来获取 2001 年出版书籍列表的第 2 页。

REST API 提供了关于可用端点和参数的详细文档,因此您应该能够轻松地学习如何正确地进行查询。尽管如此,即使这些参数会被记录下来,您也应该期待看到具有直观名称的参数,就像示例中展示的那样。

POST 和 PUT

POST 是您已经了解的 HTTP 方法的第二种类型。您在表单中使用它,目的是“发布”数据,即尝试更新服务器端的一个资源。当您想要添加或更新一本新书时,您会发送一个包含书籍数据的 POST 请求。

POST 参数的发送格式与 GET 参数类似,但它们不是作为查询字符串的一部分,而是作为请求体的一部分。HTML 表单已经为你做了这件事,但当你需要与 REST API 通信时,你应该知道如何自己完成这个操作。在下一节中,我们将向您展示如何使用除表单之外的工具执行 POST 操作。此外,请注意,您可以将任何数据添加到请求体中;在请求体中发送 JSON 而不是 POST 参数是很常见的。

PUT 方法与 POST 方法非常相似。这也试图在服务器端添加或更新数据,为此,它也在请求体上添加了额外的信息。为什么我们要有两个执行相同操作的不同方法呢?实际上,这两种方法之间有两个主要区别:

  • PUT 请求要么创建一个资源,要么更新它,但受影响的是由端点定义的资源,没有其他。这意味着,如果我们想更新一本书,端点应该声明资源是一本书,并指定它,例如,http://bookstore.com/books/8734。另一方面,如果您没有在端点中标识要创建或更新的资源,或者同时影响了其他资源,您应该使用 POST 请求。

  • Idempotent 是一个复杂的词,用来描述一个简单的概念。一个幂等的 HTTP 方法是可以多次调用的,并且结果总是相同的。例如,如果您试图将一本书的标题更新为 "Don Quixote",您调用它的次数并不重要,结果总是相同的:资源将具有标题 "Don Quixote"。另一方面,非幂等的方法在执行相同的请求时可能会返回不同的结果。一个例子可能是一个增加某些书籍库存的端点。每次您调用它时,库存都会增加更多,因此,结果并不相同。PUT 请求是幂等的,而 POST 请求则不是。

即使考虑到这个解释,滥用 POST 和 PUT 在开发者中仍然是一个相当常见的错误,尤其是在他们缺乏足够的 REST API 开发经验时。由于 HTML 表单只发送 POST 而不是 PUT 的数据,因此第一个更受欢迎。您可能会发现 REST API 中所有更新数据的端点都是 POST,尽管其中一些应该是 PUT。

DELETE

DELETE HTTP 方法相当直观。当您想要在服务器上删除一个资源时使用它。与 PUT 请求一样,DELETE 端点应该标识要删除的特定资源。一个例子是我们想要从数据库中删除一本书。我们可以向一个类似于 http://bookstore.com/books/23942 的端点发送 DELETE 请求。

DELETE 请求仅用于删除资源,并且它们已经由 URL 确定。尽管如此,如果你需要向服务器发送额外的信息,你可以像使用 POST 或 PUT 一样使用请求体。实际上,你始终可以在请求体中发送信息,包括 GET 请求,但这并不意味着这样做是一种好的实践。

第十一章:响应中的状态码

如果 HTTP 方法对于请求非常重要,状态码对于响应几乎是不可或缺的。仅仅一个数字,客户端就能知道请求发生了什么。这在你知道状态码是一个标准,并且在互联网上有广泛的文档记录时特别有用。

我们已经在第二章中描述了最重要的状态码,使用 PHP 的 Web 应用,但让我们再次列出它们,并添加一些对 REST API 很重要的状态码。要查看状态码的完整列表,您可以访问www.w3.org/Protocols/rfc2616/rfc2616-sec10.html

2xx – 成功

所有以 2 开头的状态码都用于请求处理成功的响应,无论它是 GET 还是 POST。以下是一些这个类别中最常用的状态码:

  • 200 OK:这是通用的“一切正常”响应。如果你请求一个资源,你将在响应体中获取它,如果你更新一个资源,这将意味着新数据已成功保存。

  • 201 created:这是在 POST 或 PUT 操作成功创建资源时使用的响应。

  • 202 accepted:这个响应意味着请求已被接受,但尚未处理。当客户端需要一个简单的响应来处理一个非常重的操作时,这可能很有用:服务器发送接受的响应,然后开始处理它。

3xx – 重定向

即使你可能认为只有一种重定向类型,但实际上还有一些细微的差别:

  • 301 moved permanently:这意味着资源已经被移动到不同的 URL,因此从那时起,你应该尝试通过响应正文中提供的 URL 来访问它。

  • 303 see other:这意味着请求已经处理,但为了看到响应,你需要访问响应正文中提供的 URL。

4xx – 客户端错误

这个类别包含描述由于客户端请求错误而导致的问题的状态码:

  • 400 bad request:这是对格式错误的请求的通用响应,即端点存在语法错误,或者没有提供一些预期的参数。

  • 401 unauthorized:这意味着客户端尚未成功认证,它试图访问的资源需要这种认证。

  • 403 forbidden:这个错误消息意味着尽管客户端已经认证,但它没有足够的权限访问该资源。

  • 404 not found:特定的资源未找到。

  • 405 method not allowed:这意味着端点存在,但它不接受请求中使用的 HTTP 方法,例如,我们试图使用 PUT,但端点只接受 POST 请求。

5xx – 服务器错误

服务器端可能有多达 11 种不同的错误,但我们只对其中一种感兴趣:500 内部服务器错误。当在处理请求时发生意外情况,如数据库错误,你可以使用这个状态码。

REST API 安全性

REST API 是一种强大的工具,因为它们允许开发者从服务器检索和/或更新数据。但权力越大,责任越大,在设计 REST API 时,你应该考虑使你的数据尽可能安全。想象一下——任何人都可以通过简单的 HTTP 请求代表你发布推文!

类似于使用网络应用程序,这里有两个概念:认证授权。认证某人就是识别他是谁,也就是说,将他的请求与数据库中的用户关联起来。另一方面,授权某人就是允许该特定用户执行某些操作。你可以把认证看作是用户的登录,而授权则是赋予权限。

REST API 需要非常小心地管理这两个概念。仅仅因为开发者已经通过认证,并不意味着他可以访问服务器上的所有数据。有时,用户只能访问他们自己的数据,而有时你可能希望实现一个角色系统,其中每个角色有不同的访问级别。这始终取决于你正在构建的应用程序类型。

虽然授权发生在服务器端,即服务器数据库将决定给定的用户是否可以访问某个资源,但认证必须由客户端触发。这意味着客户端必须知道 REST API 使用的是哪种认证系统,以便进行认证。每个 REST API 都将实现自己的认证系统,但有一些知名的实施方式。

基本访问认证

基本访问认证——简称 BA——正如其名所示,非常基础。客户端在每个请求的头部添加关于用户的信息,即用户名和密码。问题是这些信息仅使用 BASE64 编码,而没有加密,这使得入侵者可以轻易解码头部并获取明文密码。如果你必须使用它,说实话,这是一种实现某种认证的非常简单的方法,我们建议你使用 HTTPS。

为了使用这种方法,你需要将用户名和密码像 username:password 一样连接起来,使用 Base64 对结果字符串进行编码,并将授权头添加为:

Authorization: Basic <encoded-string>

OAuth 2.0

如果基本认证非常简单且不安全,OAuth 2.0 就是 REST API 用来进行认证的最安全系统,之前的 OAuth 1.0 也是如此。实际上,这个标准有多个版本,但它们都建立在相同的基础上:

  1. 没有用户名和密码。相反,REST API 的提供者会给开发者分配一对凭据——一个令牌和一个密钥。

  2. 为了进行身份验证,开发者需要向“令牌”端点发送一个 POST 请求,每个 REST API 的端点都不同,但概念相同。这个请求必须包含编码的开发者凭据。

  3. 服务器用会话令牌回复之前的请求。这个(而不是第一步中提到的凭据)需要包含在你向 REST API 发出的每个请求中。出于安全原因,会话令牌会过期,所以当这种情况发生时,你将不得不再次重复第二步。

尽管这个标准相对较新(从 2012 年开始),像谷歌或 Facebook 这样的几家大公司已经为它们的 REST API 实施了它。它可能看起来有点过于复杂,但你会很快学会使用它,甚至可以自己实现它。

使用第三方 API

关于 REST API 的理论就到这里;现在是时候深入一个真实世界的例子了。在本节中,我们将编写一个小型的 PHP 应用程序,与 Twitter 的 REST API 进行交互;这包括请求开发者凭据、进行身份验证和发送请求。目标是让你获得使用 REST API 的第一手经验,并展示它比你预期的要简单。这也有助于你更好地理解它们是如何工作的,因此以后构建自己的 API 会更容易。

获取应用程序的凭据

REST API 通常有应用程序的概念。应用程序就像它们开发网站上的一个账户,用于标识谁在使用 API。你将用来访问 API 的凭据将与该应用程序相关联,这意味着你可以有多个应用程序与同一个账户相关联。

假设你有一个 Twitter 账户,请访问apps.twitter.com以创建一个新的应用程序。点击创建新应用按钮以访问应用程序详情表单。字段非常直观——只需为应用程序提供一个名称、描述和网站 URL。回调 URL 在这里不是必需的,因为它仅用于需要访问他人账户的应用程序。同意条款和条件以继续。

一旦你被重定向到你的应用程序页面,你会看到各种可以编辑的信息。由于这是一个示例,让我们直接进入重点:凭据。点击密钥和访问令牌选项卡以查看消费者密钥(API 密钥)消费者密钥(API 密钥)的值。这里我们不需要其他任何东西。你可以将它们保存在你的文件系统中,例如~/.twitter_php7.json

{
    "key": "iTh4Mzl0EAPn9HAm98hEhAmVEXS",
    "secret": "PfoWM9yq4Bh6rGbzzJhr893j4r4sMIAeVRaPMYbkDer5N6F"
}

小贴士

保护你的凭据

保护您的 REST API 凭据应受到重视。实际上,您应该注意所有类型的凭据,如数据库凭据。但区别在于您通常会在自己的服务器上托管数据库,这使得攻击者稍微困难一些。另一方面,第三方 REST API 不是您系统的一部分,并且拥有您凭据的人可以代表您自由使用您的账户。

永远不要将您的凭据包含在代码库中,尤其是如果您在 GitHub 或其他存储库中有代码。一个解决方案是在您的服务器上创建一个文件,该文件位于您的代码之外,并包含凭据;如果该文件被加密,那就更好了。并且尝试定期刷新您的凭据,您可能可以在提供者的网站上完成此操作。

设置应用程序

我们的应用程序将非常简单。它将包含一个类,该类允许我们检索推文。这将由我们的 app.php 脚本管理。

由于我们必须进行 HTTP 请求,我们可以编写自己的函数,这些函数使用 cURL(一组 PHP 本地函数),或者使用著名的 PHP 库 Guzzle。这个库可以在 Packagist 中找到,因此我们将使用 Composer 来包含它:

$ composer require guzzlehttp/guzzle

我们将有一个 Twitter 类,它将从构造函数中获取凭据,并且有一个公共方法:fetchTwits。目前,只需创建一个框架,以便我们可以使用它;我们将在后面的章节中实现这些方法。将以下代码添加到 src/Twitter.php

<?php

namespace TwitterApp;

class Twitter {

    private $key;
    private $secret;

    public function __construct(String $key, String $secret) {
        $this->key = $key;
        $this->secret = $secret;
    }

    public function fetchTwits(string name, int $count): array {
        return [];
    }
}

由于我们设置了命名空间 TwitterApp,我们需要更新我们的 composer.json 文件,并添加以下内容。请记住运行 composer update 以更新自动加载器。

"autoload": {
    "psr-4": {"TwitterApp\\": "src"}
}

最后,我们将创建一个基本的 app.php 文件,该文件包含 Composer 自动加载器,读取凭据文件,并创建一个 Twitter 实例:

<?php

use TwitterApp\Twitter;

require __DIR__ . '/vendor/autoload.php';

$path = $_SERVER['HOME'] . '/.twitter_php7.json';
$jsonCredentials = file_get_contents($path);
$credentials = json_decode($jsonCredentials, true);

$twitter = new Twitter($credentials['key'], $credentials['secret']);

请求访问令牌

在实际应用中,您可能希望将与认证相关的代码与处理数据检索或发布等操作的代码分开。为了保持简单,我们将让 Twitter 类知道如何自行进行认证。

让我们从给类添加一个 $client 属性开始,该属性将包含 Guzzle 的 Client 类的实例。这个实例将包含 Twitter API 的基本 URI,我们可以将其作为常量 TWITTER_API_BASE_URI。在构造函数中实例化此属性,以便其他方法可以使用它。您还可以添加一个 $accessToken 属性,该属性将包含 Twitter API 在认证时返回的访问令牌。所有这些更改在此处突出显示:

<?php

namespace TwitterApp;

use Exception;
use GuzzleHttp\Client;

class Twitter {

 const TWITTER_API_BASE_URI = 'https://api.twitter.com';

    private $key;
    private $secret;
 private $accessToken;
 private $client;

    public function __construct(String $key, String $secret) {
        $this->key = $key;
        $this->secret = $secret;

 $this->client = new Client(
 ['base_uri' => self::TWITTER_API_BASE_URI]
 );
    }

    //...
}

下一步将是编写一个方法,给定密钥和秘密后,向提供者请求访问令牌。更具体地说:

  • 将密钥和秘密使用 : 连接。使用 Base64 对结果进行编码。

  • /oauth2/token发送带有编码凭证的 POST 请求作为Authorization头。还要包括Content-Type头和体(更多信息请查看代码)。

我们现在调用 Guzzle 的client实例的post方法,传递两个参数:端点字符串(/oauth2/token)和包含选项的数组。这些选项包括请求的头和体,您将很快看到。这个调用的响应是一个对象,它标识 HTTP 响应。您可以使用getBody提取响应的内容(体)。Twitter 的 API 响应是一个带有一些参数的 JSON。您最关心的参数是access_token,这是您需要在每个后续 API 请求中包含的令牌。提取它并保存。完整的方法如下:

private function requestAccessToken() {
    $encodedString = base64_encode(
        $this->key . ':' . $this->secret
    );
    $headers = [
        'Authorization' => 'Basic ' . $encodedString,
        'Content-Type' => 'application/x-www-form-urlencoded;charset=UTF-8'
    ];
    $options = [
        'headers' => $headers,
        'body' => 'grant_type=client_credentials'
    ];

    $response = $this->client->post(self:: OAUTH_ENDPOINT, $options);
    $body = json_decode($response->getBody(), true);

    $this->accessToken = $body['access_token'];
}

您可以通过在构造函数末尾添加这两行代码来尝试此代码:

$this->requestAccessToken();
var_dump($this->accessToken);

使用以下命令运行应用程序,以查看提供者提供的访问令牌。记住,为了继续本节,请删除前面的两行。

$ php app.php

请记住,尽管拥有密钥和密钥并获取访问令牌在所有 OAuth 身份验证中都是相同的,但编码方式、使用的端点和从提供者收到的响应对于 Twitter 的 API 是独有的。可能还有其他几个是相同的,但始终检查每个的文档。

获取推文

我们最终到达了实际使用 API 的部分。我们将实现fetchTwits方法,以获取给定用户的最后N条推文列表。为了执行请求,我们需要在每个请求中添加Authorization头,这次使用访问令牌。由于我们希望尽可能使这个类可重用,让我们将其提取到一个私有方法中:

private function getAccessTokenHeaders(): array {
    if (empty($this->accessToken)) {
        $this->requestAccessToken();
    }

    return ['Authorization' => 'Bearer ' . $this->accessToken];
}

如您所见,前面方法还允许我们从提供者那里获取访问令牌。这很有用,因为我们如果发出多个请求,我们只需请求一次访问令牌,并且有一个唯一的地方来做这件事。现在添加以下方法实现:

const GET_TWITS = '/1.1/statuses/user_timeline.json';
//...
public function fetchTwits(string $name, int $count): array {
    $options = [
        'headers' => $this->getAccessTokenHeaders(),
        'query' => [
            'count' => $count,
            'screen_name' => $name
        ]
    ];

    $response = $this->client->get(self::GET_TWITS, $options);
    $responseTwits = json_decode($response->getBody(), true);

    $twits = [];
    foreach ($responseTwits as $twit) {
        $twits[] = [
            'created_at' => $twit['created_at'],
            'text' => $twit['text'],
            'user' => $twit['user']['name']
        ];
    }

    return $twits;
}

前面方法的第一个部分使用访问令牌头和查询字符串参数构建options数组——在这种情况下,包括要检索的推文数量和用户。我们执行 GET 请求并将 JSON 响应解码为数组。这个数组包含大量我们可能不需要的信息,因此我们迭代它以提取我们真正想要的字段——在这个例子中,日期、文本和用户。

为了测试应用程序,只需在app.php文件的末尾调用fetchTwits方法,指定您关注的某人的 Twitter ID 或您自己的 ID。

$twits = $twitter->fetchTwits('neiltyson', 10);
var_dump($twits);

您应该得到一个类似于我们下面的响应,如以下截图所示:

获取推文

有一个需要注意的事情是,访问令牌在一段时间后会过期,返回一个带有 4xx 状态码的 HTTP 响应(通常是 401 未授权)。Guzzle 在状态码为 4xx 或 5xx 时抛出异常,因此很容易管理这些场景。你可以在执行 GET 请求时添加此代码:

try {
    $response = $this->client->get(self::GET_TWITS, $options);
} catch (ClientException $e) {
 if ($e->getCode() == 401) {
        $this->requestAccessToken();
        $response = $this->client->get(self::GET_TWITS, $options);
    } else {
        throw $e;
    }
}

REST API 开发者的工具集

当你正在开发自己的 REST API,或者为第三方 API 编写集成时,你可能想在开始编写代码之前测试它。有一些工具可以帮助你完成这项任务,无论你是想使用浏览器,还是你是命令行的粉丝。

使用浏览器测试 API

实际上,有几个插件允许你从浏览器执行 HTTP 请求,具体取决于你使用的是哪一个。一些著名的名字是 Chrome 上的Advanced Rest Client和 Firefox 上的RESTClient。最终,所有这些客户端都允许你执行相同的 HTTP 请求,你可以指定 URL、方法、头部、主体等。这些客户端还会显示你想象得到的所有响应细节,包括状态码、耗时和主体。以下截图显示了使用 Chrome 的Advanced Rest Client的一个请求示例:

使用浏览器测试 API

如果你想要使用自己的 API 测试 GET 请求,而你需要的只是 URL,也就是说,你不需要发送任何头部,你只需像访问任何其他网站一样使用你的浏览器。如果你这样做,并且如果你正在处理 JSON 响应,你可以安装另一个浏览器插件,这将帮助你以更“美观”的方式查看你的 JSON。在任何浏览器上查找JSONView,以获得一个真正方便的工具。

使用命令行测试 API

有些人觉得使用命令行更舒服;幸运的是,对于他们来说,有一些工具允许他们从控制台执行任何 HTTP 请求。我们将简要介绍其中最著名的一个:cURL。这个工具有很多功能,但我们只会关注你更常使用的那些:HTTP 方法、POST 参数和头部:

  • -X <method>:这指定了要使用的 HTTP 方法

  • --data:这添加了指定的参数,可以是键值对、JSON、纯文本等

  • --header:这会给请求添加一个头部

以下是一个使用 cURL 发送 POST 请求的示例:

curl -X POST --data "text=This is sparta!" \
> --header "Authorization: Bearer 8s8d7bf8asdbf8sbdf8bsa" \
>  https://api.twitter.com/1.1/statuses/update.json
{"errors":[{"code":89,"message":"Invalid or expired token."}]}

如果你使用的是 Unix 系统,你可能会通过附加| python -m json.tool来格式化生成的 JSON,这样它就更容易阅读了:

$ curl -X POST --data "text=This is sparta!" \
> --header "Authorization: Bearer 8s8d7bf8asdbf8sbdf8bsa" \
>  https://api.twitter.com/1.1/statuses/update.json \
> | python -m json.tool
{
 "errors": [
 {
 "code": 89,
 "message": "Invalid or expired token."
 }
 ]
}

cURL 是一个非常强大的工具,它让你可以做很多技巧。如果你感兴趣,请继续查看文档或一些教程,了解如何使用所有这些功能。

使用 REST API 的最佳实践

我们已经讨论了一些编写 REST API 的最佳实践,比如正确使用 HTTP 方法,或者为你的响应选择正确的状态码。我们还描述了两种最常用的认证系统。但关于创建合适的 REST API 还有很多东西要学习。记住,它们是为了像你这样的开发者而设计的,所以如果你做得正确,会使他们的生活变得更轻松。准备好了吗?

端点的一致性

在决定如何命名你的端点时,尽量保持一致性。尽管你可以自由选择,但有一套口语化的规则可以使你的端点更加直观和易于理解。让我们列举一些:

  • 首先,一个端点应该指向一个特定的资源(例如,书籍或推文),你应该在你的端点中清楚地表明这一点。如果你有一个返回所有书籍列表的端点,不要命名为/library,因为它不明显会返回什么。相反,命名为/books/books/all

  • 资源名称可以是复数也可以是单数,但请保持一致性。如果你有时使用/books,有时使用/user,可能会造成混淆,人们可能会犯错误。我们个人更喜欢使用复数形式,但这完全取决于你。

  • 当你想检索特定的资源时,如果可能的话,通过指定 ID 来执行。ID 必须在你的系统中是唯一的,任何其他参数可能指向两个不同的实体。在资源名称旁边指定 ID,例如/books/249234-234-23-42

  • 如果你仅通过 HTTP 方法就能理解端点的作用,就没有必要将其作为端点的一部分添加信息。例如,如果你想获取一本书,或者删除它,使用/books/249234-234-23-42以及 HTTP 方法 GET 和 DELETE 就足够了。如果不明显,可以在端点末尾用动词表示,如/employee/9218379182/promote

尽可能多地编写文档

标题应该包含所有信息。你很可能不会是使用 REST API 的人,其他人会。显然,即使你设计了一套非常直观的端点,开发者仍然需要知道所有可用的端点,每个端点的作用,可用的可选参数等等。

尽可能多地编写文档,并保持其更新。看看其他已记录的 API,以获取如何展示信息的想法。有许多模板和工具可以帮助你提供一份展示良好的文档,但你必须保持一致性和条理性。开发者特别讨厌编写文档,但当我们需要使用他人的 API 时,我们也喜欢找到清晰且美观展示的文档。

过滤和分页

API 的一种常见用途是列出资源并通过某些标准进行筛选。当我们构建自己的书店时,我们已经看到了一个例子;我们想要获取包含特定字符串在标题或作者中的书籍列表。

一些开发者试图拥有美观的端点,从先验的角度来看,这是一件好事。想象一下,如果你想仅通过标题进行筛选,你可能会得到一个像 /books/title/<string> 这样的端点。我们还增加了通过作者进行筛选的能力,现在我们得到了两个额外的端点:/books/title/<string>/author/<string>/books/author/<string>。现在让我们也添加描述——你看到我们想要去哪里了吗?

尽管一些开发者不喜欢使用查询字符串作为参数,但这并没有什么问题。事实上,如果你正确使用它们,你最终会得到更干净的端点。你想获取书籍?好的,只需使用 /books,并使用查询字符串添加你需要的任何过滤器。

当你一次需要检索大量同类型资源时,就会发生分页。你应该将分页视为另一个可选的过滤器,作为 GET 参数指定。你应该有默认大小的页面,比如 10 本书,但给开发者定义他们自己的大小是一个好主意。在这种情况下,开发者可以指定要检索的长度和页数。

API 版本控制

你的 API 是你应用程序能做什么的反映。很可能会发生你的代码会进化,改进现有的功能或添加新的功能。你的 API 也应该更新,以暴露这些新功能,更新现有端点,甚至删除其中的一些。

现在想象一下,有人正在使用你的 REST API,并且他们的整个网站都依赖于它。如果你更改现有的端点,他们的网站将停止工作!他们不会感到高兴,并试图找到其他人来做你之前做的事情。这不是一个好的场景,但那么,你如何改进你的 API 呢?

解决方案是使用版本控制。当你发布 API 的新版本时,不要删除现有的版本;你应该给用户一些时间来升级他们的集成。那么,两个不同的 API 版本如何共存呢?你已经看到了一个选项——我们推荐你使用的那个:通过在端点中指定要使用的 API 版本。你还记得 Twitter API 的端点 /1.1/statuses/user_timeline.json 吗?其中的 1.1 指的是我们想要使用的版本。

使用 HTTP 缓存

如果 REST API 的主要功能是大量使用 HTTP,为什么不利用 HTTP 缓存呢?好吧,不使用它的实际原因有很多,但大多数都是由于缺乏正确使用它的知识。本书的范围不包括解释其实施的每一个细节,但让我们尝试对这个主题进行简要介绍。互联网上有大量的资源可以帮助你理解你更感兴趣的部分。

HTTP 响应可以分为公开和私有。公开响应在 API 的所有用户之间共享,而私有响应则针对每个用户是唯一的。你可以使用 Cache-Control 头来指定你的响应类型,如果请求的方法是 GET,则允许缓存响应。此头还可以暴露缓存的过期时间,即你可以指定你的响应将保持相同的时间长度,因此可以被缓存。

其他系统依赖于生成资源的表示的哈希值,并将其作为 ETag(实体标签)头添加,以便知道资源是否已更改。以类似的方式,你可以设置 Last-Modified 头,让客户端知道给定资源最后一次更改的时间。这些系统背后的想法是确定客户端是否已经包含有效数据。如果是这样,提供者不会处理请求,而是返回一个状态码为 304(未修改)的空响应。当客户端收到这个响应时,它将使用其缓存的文件内容。

使用 Laravel 创建 REST API

在本节中,我们将从头开始使用 Laravel 构建一个 REST API。这个 REST API 将允许你管理书店中的不同客户,不仅可以通过浏览器,还可以通过用户界面。你将能够执行与之前几乎相同的行为,即列出书籍、购买它们、免费借用,等等。

一旦 REST API 完成,你应该从之前章节中构建的书店中移除所有业务逻辑。原因是你应该有一个唯一的地方来实际操作你的数据库和 REST API,而其他应用程序,如网站,应该能够与 REST API 通信来管理数据。这样做,你将能够为不同的平台创建其他应用程序,例如移动应用程序,它们也将使用 REST API,并且网站和移动应用程序将始终保持同步,因为它们将使用相同的来源。

就像我们之前的 Laravel 示例一样,为了创建一个新的项目,你只需要运行以下命令:

$ laravel new bookstore_api

设置 OAuth2 认证

我们将要实现的第一件事是认证层。我们将使用 OAuth2 来使我们的应用程序比基本认证更安全。Laravel 并没有提供开箱即用的 OAuth2 支持,但有一个服务提供者为我们做了这件事。

安装 OAuth2Server

要安装 OAuth2,请使用 Composer 将其作为项目依赖项添加:

$ composer require "lucadegasperi/oauth2-server-laravel:5.1.*"

此服务提供者需要进行相当多的更改。我们将简要介绍它们,而不会过多地深入到具体的工作原理。如果你对这个主题更感兴趣,或者如果你想为 Laravel 创建自己的服务提供者,我们建议你查阅详尽的官方文档。

首先,我们需要将新的 OAuth2Server 服务提供者添加到config/app.php文件中的提供者数组中。在providers数组的末尾添加以下几行:

/*
 * OAuth2 Server Service Providers...
 */
        LucaDegasperi\OAuth2Server\Storage\FluentStorageServiceProvider::class,       LucaDegasperi\OAuth2Server\OAuth2ServerServiceProvider::class,

同样地,你需要在同一文件中的aliases数组中添加一个新的别名:

'Authorizer' => LucaDegasperi\OAuth2Server\Facades\Authorizer::class,

让我们转到app/Http/Kernel.php文件,在那里我们还需要做一些更改。将以下条目添加到Kernel类的$middleware数组属性中:

\LucaDegasperi\OAuth2Server\Middleware\OAuthExceptionHandlerMiddleware::class,

将以下键值对添加到同一类的$routeMiddleware数组属性中:

'oauth' => \LucaDegasperi\OAuth2Server\Middleware\OAuthMiddleware::class,
'oauth-user' => \LucaDegasperi\OAuth2Server\Middleware\OAuthUserOwnerMiddleware::class,
'oauth-client' => \LucaDegasperi\OAuth2Server\Middleware\OAuthClientOwnerMiddleware::class,
'check-authorization-params' => \LucaDegasperi\OAuth2Server\Middleware\CheckAuthCodeRequestMiddleware::class,
'csrf' => \App\Http\Middleware\VerifyCsrfToken::class,

我们在之前的步骤中添加了 CSRF 令牌验证器到$routeMiddleware,因此我们需要从$middlewareGroups中移除已经定义的,因为它们是不兼容的。使用以下行来完成此操作:

\App\Http\Middleware\VerifyCsrfToken::class,

设置数据库

现在让我们设置数据库。在本节中,我们假设你已经在你的环境中有了书店数据库。如果你没有,请回到第五章,使用数据库,以创建它以便继续此设置。

首先要做的事情是在.env文件中更新数据库凭证。它们应该看起来类似于以下几行,但需要使用你的用户名和密码:

DB_HOST=localhost
DB_DATABASE=bookstore
DB_USERNAME=root
DB_PASSWORD=

为了准备 OAuth2Server 服务提供者的配置和数据库迁移文件,我们需要发布它。在 Laravel 中,你可以通过执行以下命令来完成:

$ php artisan vendor:publish

现在的database/migrations目录包含了所有必要的迁移文件,这些文件将在我们的数据库中创建与 OAuth2 相关的必要表。要执行它们,我们运行以下命令:

$ php artisan migrate

我们需要至少向oauth_clients表添加一个客户端,这是存储所有想要连接到我们的 REST API 的客户端的密钥和秘密的表。这个新的客户端将是你将在开发过程中用来测试你所做事情的一个。我们可以设置一个随机的 ID——密钥——以及秘密如下:

mysql> INSERT INTO oauth_clients(id, secret, name)
 -> VALUES('iTh4Mzl0EAPn90sK4EhAmVEXS',
 -> 'PfoWM9yq4Bh6rGbzzJhr8oDDsNZwGlsMIAeVRaPM',
 -> 'Toni');
Query OK, 1 row affected, 1 warning (0.00 sec)

启用客户端凭证认证

由于我们在上一步中发布了vendor中的插件,现在我们有了 OAuth2Server 的配置文件。此插件允许我们根据需要使用不同的认证系统(所有这些系统都使用 OAuth2)。我们感兴趣的是我们的项目中的client_credentials类型。为了让 Laravel 知道,请在config/oauth2.php文件中的数组末尾添加以下几行:

'grant_types' => [
     'client_credentials' => [
        'class' => 
            '\League\OAuth2\Server\Grant\ClientCredentialsGrant',
        'access_token_ttl' => 3600
    ]
]

前面的这些行授予了 client_credentials 类型的访问权限,这些权限由 ClientCredentialsGrant 类管理。access_token_ttl 值指的是访问令牌的时间段,即某人可以使用它多长时间。在这种情况下,它被设置为 1 小时,即 3,600 秒。

最后,我们需要启用一个路由,以便我们可以通过提交凭证来交换访问令牌。将以下路由添加到 app/Http/routes.php 文件中的路由文件:

Route::post('oauth/access_token', function() {
    return Response::json(Authorizer::issueAccessToken());
});

请求访问令牌

是时候测试我们到目前为止所做的工作了。为此,我们需要向刚才启用的 /oauth/access_token 端点发送一个 POST 请求。此请求需要以下 POST 参数:

  • 使用数据库中的密钥来指定 client_id

  • 使用数据库中的密钥来指定 client_secret

  • 使用 grant_type 来指定我们尝试执行的认证类型,在这种情况下是 client_credentials

使用 Chrome 的 Advanced REST Client 插件发出的请求如下所示:

请求访问令牌

你应该得到的响应应该与这个格式相同:

{
    "access_token": "MPCovQda354d10zzUXpZVOFzqe491E7ZHQAhSAax"
    "token_type": "Bearer"
    "expires_in": 3600
}

注意,这与 Twitter API 请求访问令牌的方式不同,但理念仍然是相同的:给定一个密钥和一个密钥,提供者会给我们一个访问令牌,这将允许我们在一段时间内使用该 API。

准备数据库

尽管我们在上一章中已经做了同样的事情,你可能会想:“为什么我们要先准备数据库?”我们可以争论说,你首先需要知道你想要在 REST API 中公开的端点类型,然后你才能开始考虑你的数据库应该是什么样子。但你也可能会认为,因为我们正在使用 API,每个端点应该管理一个资源,所以首先你需要定义你正在处理的数据。这种 代码优先与数据库/模型优先 是互联网上持续进行的战争。但无论你认为哪种方式更好,事实是,我们已经知道用户将如何使用我们的 REST API,因为我们之前已经构建了 UI;所以这并不真正重要。

我们需要创建四个表:bookssalessales_booksborrowed_books。请记住,Laravel 已经提供了一个 users 表,我们可以将其用作我们的客户。运行以下四个命令来创建迁移文件:

$ php artisan make:migration create_books_table --create=books
$ php artisan make:migration create_sales_table --create=sales
$ php artisan make:migration create_borrowed_books_table \
--create=borrowed_books
$ php artisan make:migration create_sales_books_table \
--create=sales_books

现在,我们需要逐个文件地定义每个表应该是什么样子。我们将尽可能复制 第五章 中的数据结构,即 使用数据库。请记住,迁移文件可以在 database/migrations 目录中找到。我们可以编辑的第一个文件是 create_books_table.php。用以下方法替换现有的空 up 方法:

public function up()
{
    Schema::create('books', function (Blueprint $table) {
        $table->increments('id');
        $table->string('isbn')->unique();
        $table->string('title');
        $table->string('author');
        $table->smallInteger('stock')->unsigned();
        $table->float('price')->unsigned();
    });
}

列表中的下一个是 create_sales_table.php。请记住,这个文件有一个外键指向 users 表。您可以使用 references(field)->on(tablename) 来定义这个约束。

public function up()
{
    Schema::create('sales', function (Blueprint $table) {
        $table->increments('id');
        $table->string('user_id')->references('id')->on('users');
        $table->timestamps();
    });
}

create_sales_books_table.php 文件包含两个外键:一个指向销售记录的 ID,另一个指向书籍的 ID。将现有的 up 方法替换为以下内容:

public function up()
{
    Schema::create('sales_books', function (Blueprint $table) {
        $table->increments('id');
        $table->integer('sale_id')->references('id')->on('sales');
        $table->integer('book_id')->references('id')->on('books');
        $table->smallInteger('amount')->unsigned();
    });
}

最后,编辑 create_borrowed_books_table.php 文件,该文件包含 book_id 外键以及 startend 时间戳:

public function up()
{
    Schema::create('borrowed_books', function (Blueprint $table) {
        $table->increments('id');
        $table->integer('book_id')->references('id')->on('books');
        $table->string('user_id')->references('id')->on('users');
        $table->timestamp('start');
        $table->timestamp('end');
    });
}

迁移文件已经准备好了,所以我们只需按顺序迁移它们以创建数据库表。运行以下命令:

$ php artisan migrate

此外,手动添加一些书籍到数据库中,以便您可以稍后进行测试。例如:

mysql> INSERT INTO books (isbn,title,author,stock,price) VALUES
 -> ("9780882339726","1984","George Orwell",12,7.50),
 -> ("9789724621081","1Q84","Haruki Murakami",9,9.75),
 -> ("9780736692427","Animal Farm","George Orwell",8,3.50),
 -> ("9780307350169","Dracula","Bram Stoker",30,10.15),
 -> ("9780753179246","19 minutes","Jodi Picoult",0,10);
Query OK, 5 rows affected (0.01 sec)
Records: 5  Duplicates: 0  Warnings: 0

设置模型

列表中的下一件事是添加我们的数据具有的关系,即从数据库将外键转换为模型。首先,我们需要创建这些模型,为此我们只需运行以下命令:

$ php artisan make:model Book
$ php artisan make:model Sale
$ php artisan make:model BorrowedBook
$ php artisan make:model SalesBook

现在我们必须逐个模型进行操作,并添加一对一和多对一关系,就像我们在上一章中做的那样。对于 BookModel,我们只需指定该模型没有时间戳,因为它们默认就有。为此,将以下高亮行添加到您的 app/Book.php 文件中:

<?php

namespace App;

use Illuminate\Database\Eloquent\Model;

class Book extends Model
{
 public $timestamps = false;
}

对于 BorrowedBook 模型,我们需要指定它有一个书籍,并且属于一个用户。我们还需要指定在创建对象时需要填充的字段——在这种情况下,book_idstart。在 app/BorrowedBook.php 中添加以下两个方法:

<?php

namespace App;

use Illuminate\Database\Eloquent\Model;

class BorrowedBook extends Model
{
 protected $fillable = ['user_id', 'book_id', 'start'];
 public $timestamps = false;

 public function user() {
 return $this->belongsTo('App\User');
 }

 public function book() {
 return $this->hasOne('App\Book');
 }
}

销售记录可以有多个“销售书籍”(我们知道这可能听起来有点别扭),它们也只属于一个用户。将以下内容添加到您的 app/Sale.php 文件中:

<?php

namespace App;

use Illuminate\Database\Eloquent\Model;

class Sale extends Model
{
 protected $fillable = ['user_id'];

 public function books() {
 return $this->hasMany('App\SalesBook');
 }

 public function user() {
 return $this->belongsTo('App\User');
 }
}

与借阅书籍一样,销售书籍可以有一个书籍和一个销售记录,而不是一个用户。以下行应添加到 app/SalesBook.php 文件中:

<?php

namespace App;

use Illuminate\Database\Eloquent\Model;

class SaleBook extends Model
{
 public $timestamps = false;
 protected $fillable = ['book_id', 'sale_id', 'amount'];

 public function sale() {
 return $this->belongsTo('App\Sale');
 }

 public function books() {
 return $this->hasOne('App\Book');
 }
}

最后,我们需要更新的最后一个模型是 User 模型。我们需要添加与之前在 SaleBorrowedBook 中使用的 belongs 相反的关系。添加这两个函数,并保持类的其余部分不变:

<?php

namespace App;

use Illuminate\Foundation\Auth\User as Authenticatable;

class User extends Authenticatable
{
    //...

 public function sales() {
 return $this->hasMany('App\Sale');
 }

 public function borrowedBooks() {
 return $this->hasMany('App\BorrowedBook');
 }
}

设计端点

在本节中,我们需要想出我们想要向 REST API 客户端公开的端点列表。请记住在“REST API 最佳实践”部分中解释的“规则”。简而言之,请记住以下规则:

  • 一个端点与一个资源交互

  • 可能的架构可以是 <API 版本>/<资源名称>/<可选 ID>/<可选操作>

  • 使用 GET 参数进行过滤和分页

那么,用户需要做什么呢?由于我们已经创建了 UI,所以我们已经有了很好的想法。简要总结如下:

  • 列出所有可用的书籍,并按标题和作者进行过滤,必要时进行分页。也可以根据 ID 检索特定书籍的信息。

  • 如果有可用,允许用户借阅特定的书籍。同样,用户应该能够归还书籍,并列出借阅书籍的历史记录(按日期过滤并分页)。

  • 允许用户购买一系列书籍。这可以改进,但现在让我们强制用户通过一个请求购买书籍,包括书籍的完整列表。同样,列出用户的销售记录也遵循与借阅书籍相同的规则。

我们将立即开始我们的端点列表,指定路径、HTTP 方法和可选参数。它还将给你一个如何记录 REST API 的想法。

  • GET /books

    • title: 可选,按标题过滤

    • author: 可选,按作者过滤

    • page: 可选,默认为 1,指定要返回的页面

    • page-size: 可选,默认为 50,指定返回的页面大小

  • GET /books/<book id>

  • POST /borrowed-books

    • book-id: 必须指定,用于借阅书籍的 ID
  • GET /borrowed-books

    • from: 可选,从指定的日期返回借阅的书籍

    • page: 可选,默认为 1,指定要返回的页面

    • page-size: 可选,默认为 50,指定每页的借阅书籍数量

  • PUT /borrowed-books/<borrowed book id>/return

  • POST /sales

    • books: 必须指定,它是一个数组,列出要购买的书籍 ID 及其数量,即{"book-id-1": 数量, "book-id-2": 数量, ...}
  • GET /sales

    • from: 可选,从指定的日期返回借阅的书籍

    • page: 可选,默认为 1,指定要返回的页面

    • page-size: 可选,默认为 50,指定每页的销售数量

  • GET /sales/<sales id>

我们在创建销售和借阅书籍时使用 POST 请求,因为我们事先不知道要创建的资源 ID,发送相同的请求将创建多个资源。另一方面,在归还书籍时,我们知道借阅书籍的 ID,发送相同的请求多次将使数据库保持相同的状态。让我们将这些端点翻译为app/Http/routes.php中的路由:

/*
 * Books endpoints.
 */
Route::get('books', ['middleware' => 'oauth',
    'uses' => 'BookController@getAll']);
Route::get('books/{id}', ['middleware' => 'oauth',
    'uses' => 'BookController@get']);
/*
 * Borrowed books endpoints.
 */
Route::post('borrowed-books', ['middleware' => 'oauth',
    'uses' => 'BorrowedBookController@borrow']);
Route::get('borrowed-books', ['middleware' => 'oauth',
    'uses' => 'BorrowedBookController@get']);
Route::put('borrowed-books/{id}/return', ['middleware' => 'oauth',
    'uses' => 'BorrowedBookController@returnBook']);
/*
 * Sales endpoints.
 */
Route::post('sales', ['middleware' => 'oauth',
    'uses' => 'SalesController@buy]);
Route::get('sales', ['middleware' => 'oauth',
    'uses' => 'SalesController@getAll']);
Route::get('sales/{id}', ['middleware' => 'oauth',
    'uses' => 'SalesController@get']);

在前面的代码中,注意我们如何向所有端点添加了中间件oauth。这将要求用户提供有效的访问令牌才能访问它们。

添加控制器

从前面的部分,你可以想象我们需要创建三个控制器:BookControllerBorrowedBookControllerSalesController。让我们从最简单的一个开始:根据 ID 返回书籍的信息。创建文件app/Http/Controllers/BookController.php,并添加以下代码:

<?php

namespace App\Http\Controllers;

use App\Book;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Response;

class BookController extends Controller {

    public function get(string $id): JsonResponse {
        $book = Book::find($id);

        if (empty($book)) {
            return new JsonResponse (
                null,
                JsonResponse::HTTP_NOT_FOUND
            );
        }

        return response()->json(['book' => $book]);
    }
}

尽管前面的例子很简单,但它包含了我们接下来需要的大部分内容。我们尝试根据 URL 中的 ID 获取一本书,如果没有找到,我们回复一个 404(未找到)的空响应——常量Response::HTTP_NOT_FOUND是 404。如果我们有这本书,我们用response->json()将其作为 JSON 返回。注意我们添加了看似不必要的键book;确实我们没有返回其他任何内容,并且由于我们请求了书籍,用户会知道我们在说什么,但既然这并不会造成伤害,最好尽可能明确。

让我们来测试一下!你已经知道如何获取访问令牌——查看“请求访问令牌”部分。所以获取一个,并尝试访问以下 URL:

  • http://localhost/books/0?access_token=12345

  • http://localhost/books/1?access_token=12345

假设12345是你的访问令牌,你有一个 ID 为1的书籍在数据库中,你没有 ID 为0的书籍,第一个 URL 应该返回一个 404 响应,第二个 URL,返回的响应类似于以下内容:

{
    "book": {
        "id": 1
        "isbn": "9780882339726"
        "title": "1984"
        "author": "George Orwell"
        "stock": 12
        "price": 7.5
    }
}

现在我们添加一个方法来获取带有过滤器和分页的所有书籍。这看起来相当冗长,但我们使用的逻辑相当简单:

public function getAll(Request $request): JsonResponse {
    $title = $request->get('title', '');
    $author = $request->get('author', '');
    $page = $request->get('page', 1);
    $pageSize = $request->get('page-size', 50);

    $books = Book::where('title', 'like', "%$title%")
        ->where('author', 'like', "%$author%")
        ->take($pageSize)
        ->skip(($page - 1) * $pageSize)
        ->get();

    return response()->json(['books' => $books]);
}

我们获取所有可能从请求中来的参数,并在用户没有包含它们的情况下设置每个参数的默认值(因为它们是可选的)。然后,我们使用 Eloquent ORM 通过where()过滤标题和作者,并使用take()->skip()限制结果。我们以与之前相同的方式返回 JSON。不过,在这个方法中,我们不需要任何额外的检查;如果查询没有返回任何书籍,这并不是真正的问题。

你现在可以玩转你的 REST API 了,发送带有不同过滤器的不同请求。以下是一些示例:

  • http://localhost/books?access_token=12345

  • http://localhost/books?access_token=12345&title=19&page-size=1

  • http://localhost/books?access_token=12345&page=2

列表中的下一个控制器是BorrowedBookController。我们需要添加三个方法:borrowgetreturnBook。既然你已经知道如何处理请求、响应、状态码和 Eloquent ORM,我们将直接编写整个类:

<?php

namespace App\Http\Controllers;

use App\Book;
use App\BorrowedBook;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use LucaDegasperi\OAuth2Server\Facades\Authorizer;

class BorrowedBookController extends Controller {

    public function get(): JsonResponse {
        $borrowedBooks = BorrowedBook::where(
            'user_id', '=', Authorizer::getResourceOwnerId()
        )->get();

        return response()->json(
            ['borrowed-books' => $borrowedBooks]
        );
    }

    public function borrow(Request $request): JsonResponse {
        $id = $request->get('book-id');

        if (empty($id)) {
            return new JsonResponse(
                ['error' => 'Expecting book-id parameter.'],
                JsonResponse::HTTP_BAD_REQUEST
            );
        }

        $book = Book::find($id);

        if (empty($book)) {
            return new JsonResponse(
                ['error' => 'Book not found.'],
                JsonResponse::HTTP_BAD_REQUEST
            );
        } else if ($book->stock < 1) {
            return new JsonResponse(
                ['error' => 'Not enough stock.'],
                JsonResponse::HTTP_BAD_REQUEST
            );
        }

 $book->stock--;
 $book->save();

 $borrowedBook = BorrowedBook::create(
 [
 'book_id' => $book->id,
 'start' => date('Y-m-d H:i:s'),
 'user_id' => Authorizer::getResourceOwnerId()
 ]
 );

        return response()->json(['borrowed-book' => $borrowedBook]);
    }

    public function returnBook(string $id): JsonResponse {
        $borrowedBook = BorrowedBook::find($id);

        if (empty($borrowedBook)) {
            return new JsonResponse(
                ['error' => 'Borrowed book not found.'],
                JsonResponse::HTTP_BAD_REQUEST
            );
        }

        $book = Book::find($borrowedBook->book_id);
        $book->stock++;
        $book->save();

        $borrowedBook->end = date('Y-m-d H:m:s');
        $borrowedBook->save();

        return response()->json(['borrowed-book' => $borrowedBook]);
    }
}

在前面的代码中需要注意的唯一一点是我们如何更新书籍的库存(增加或减少库存),并调用save方法来保存数据库中的更改。在借阅书籍时,我们还返回借阅的书籍对象作为响应,以便用户可以知道借阅的书籍 ID,并在查询或归还书籍时使用它。

你可以使用以下用例来测试这组端点的工作方式:

  • 借一本书。确认你得到了有效的响应。

  • 获取借阅书籍的列表。你刚刚创建的书籍应该在那里,有一个有效的开始日期和一个空结束日期。

  • 获取你借阅的书籍信息。库存应该少一本。

  • 归还书籍。获取借阅书籍列表以检查结束日期和归还的书籍以检查库存。

当然,你总是可以尝试欺骗 API,请求没有库存的书、不存在的借阅书籍等。所有这些边缘情况都应该返回正确的状态码和错误信息。

我们通过创建SalesController来完成本节和 REST API。这个控制器包含更多的逻辑,因为创建销售意味着在检查每个书的库存是否足够之前,先向销售账本表添加条目。将以下代码添加到app/Http/SalesController.php

<?php

namespace App\Http\Controllers;

use App\Book;
use App\Sale;
use App\SalesBook;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use LucaDegasperi\OAuth2Server\Facades\Authorizer;

class SalesController extends Controller {

    public function get(string $id): JsonResponse {
        $sale = Sale::find($id);

        if (empty($sale)) {
            return new JsonResponse(
                null,
                JsonResponse::HTTP_NOT_FOUND
            );
        }

        $sale->books = $sale->books()->getResults();
        return response()->json(['sale' => $sale]);
    }

    public function buy(Request $request): JsonResponse {
        $books = json_decode($request->get('books'), true);

        if (empty($books) || !is_array($books)) {
            return new JsonResponse(
                ['error' => 'Books array is malformed.'],
                JsonResponse::HTTP_BAD_REQUEST
            );
        }

        $saleBooks = [];
        $bookObjects = [];
        foreach ($books as $bookId => $amount) {
            $book = Book::find($bookId);
            if (empty($book) || $book->stock < $amount) {
                return new JsonResponse(
                    ['error' => "Book $bookId not valid."],
                    JsonResponse::HTTP_BAD_REQUEST
                );
            }

            $bookObjects[] = $book;
            $saleBooks[] = [
                'book_id' => $bookId,
                'amount' => $amount
            ];
        }

        $sale = Sale::create(
            ['user_id' => Authorizer::getResourceOwnerId()]
        );
        foreach ($bookObjects as $key => $book) {
            $book->stock -= $saleBooks[$key]['amount'];

            $saleBooks[$key]['sale_id'] = $sale->id;
            SalesBook::create($saleBooks[$key]);
        }

        $sale->books = $sale->books()->getResults();
        return response()->json(['sale' => $sale]);
    }

    public function getAll(Request $request): JsonResponse {
        $page = $request->get('page', 1);
        $pageSize = $request->get('page-size', 50);

        $sales = Sale::where(
                'user_id', '=', Authorizer::getResourceOwnerId()
             )
            ->take($pageSize)
            ->skip(($page - 1) * $pageSize)
            ->get();

        foreach ($sales as $sale) {
            $sale->books = $sale->books()->getResults();
        }

        return response()->json(['sales' => $sales]);
    }
}

在前面的代码中,注意我们首先检查所有书的可用性,然后再创建销售条目。这样,我们确保在向用户返回错误时,数据库中没有未完成的销售记录。你可以改变这一点,使用事务代替,如果一本书无效,只需回滚事务。

为了测试这一点,我们可以遵循与借阅书籍类似的步骤。记住,在发布销售时,books参数是一个 JSON 映射;例如,{"1": 2, "4": 1}表示我正在尝试购买 ID 为1的两本书和一本 ID 为4的书。

测试你的 REST API

你已经在完成每个控制器后通过发起一些请求并期望得到响应来测试你的 REST API。正如你可能想象的那样,这有时可能很有用,但肯定不是最佳做法。测试应该是自动化的,并且应该尽可能覆盖更多内容。我们必须考虑一个类似于单元测试的解决方案。

在第十章中,行为测试,你将学习更多关于端到端测试应用程序的方法和工具,这包括 REST API。然而,由于我们的 REST API 很简单,我们可以使用 Laravel 为我们提供的功能添加一些相当不错的测试。实际上,这个想法与我们在第八章中写的测试非常相似,使用现有的 PHP 框架,我们在某个端点发起请求,并期望得到响应。唯一的区别将是我们使用的断言类型(可以检查 JSON 响应是否正常),以及我们执行请求的方式。

让我们在与书籍相关的端点集合中添加一些测试。我们需要在数据库中有一些书籍以便查询,因此我们将在每个测试之前填充数据库,即使用setUp方法。记住,为了在测试后保持数据库干净,我们需要使用DatabaseTransactions特质。将以下代码添加到tests/BooksTest.php

<?php

use Illuminate\Foundation\Testing\DatabaseTransactions;
use App\Book;

class BooksTest extends TestCase {

    use DatabaseTransactions;

 private $books = [];

    public function setUp() {
        parent::setUp();

 $this->addBooks();
    }

    private function addBooks() {
        $this->books[0] = Book::create(
            [
                'isbn' => '293842983648273',
                'title' => 'Iliad',
                'author' => 'Homer',
                'stock' => 12,
                'price' => 7.40
            ]
        );
        $this->books[0]->save();
        $this->books[0] = $this->books[0]->fresh();

        $this->books[1] = Book::create(
            [
                'isbn' => '9879287342342',
                'title' => 'Odyssey',
                'author' => 'Homer',
                'stock' => 8,
                'price' => 10.60
            ]
        );
        $this->books[1]->save();
        $this->books[1] = $this->books[1]->fresh();

        $this->books[2] = Book::create(
            [
                'isbn' => '312312314235324',
                'title' => 'The Illuminati',
                'author' => 'Larry Burkett',
                'stock' => 22,
                'price' => 5.10
            ]
        );
        $this->books[2]->save();
        $this->books[2] = $this->books[2]->fresh();
    }
}

如您在前面的代码中看到的,我们向数据库中添加了三本书,同时也添加到了类属性 $books 中。当我们想要断言一个响应是有效的时,我们将需要这些书。此外,请注意 fresh 方法的使用;这个方法将我们拥有的模型与数据库中的内容同步。我们需要这样做是为了获取数据库中插入的 ID,因为我们事先并不知道它。

在我们运行每个测试之前,我们还需要做另一件事:验证我们的客户端。我们需要向访问令牌生成端点发送有效的凭据,并存储我们收到的访问令牌,以便可以在剩余的请求中使用它。您可以选择如何提供凭据,因为有不同的方法可以做到这一点。在我们的情况下,我们只是提供了一个已知存在于数据库中的客户端测试的凭据,但您可能更喜欢每次都将该客户端插入数据库。使用以下代码更新测试:

<?php

use Illuminate\Foundation\Testing\DatabaseTransactions;
use App\Book;

class BooksTest extends TestCase {

    use DatabaseTransactions;

    private $books = [];
 private $accessToken;

    public function setUp() {
        parent::setUp();

        $this->addBooks();
 $this->authenticate();
    }

    //...

 private function authenticate() {
 $this->post(
 'oauth/access_token',
 [
 'client_id' => 'iTh4Mzl0EAPn90sK4EhAmVEXS',
 'client_secret' => 'PfoWM9yq4Bh6rhr8oDDsNZM',
 'grant_type' => 'client_credentials'
 ]
 );
 $response = json_decode(
 $this->response->getContent(), true
 );
 $this->accessToken = $response['access_token'];
 }
}

在前面的代码中,我们使用 post 方法来发送 POST 请求。此方法接受一个包含端点的字符串和一个包含要包含的参数的数组。在发出请求后,Laravel 将响应对象保存到 $response 属性中。我们可以对其进行 JSON 解码,并提取所需的访问令牌。

是时候添加一些测试了。让我们从一个简单的测试开始:根据 ID 请求一本书。ID 用于使用书籍的 ID 进行 GET 请求(不要忘记访问令牌),并检查响应是否与预期的匹配。记住,我们已经有 $books 数组了,所以执行这些检查将会非常简单。

我们将使用两个断言:seeJson,它比较接收到的 JSON 响应与我们提供的响应,以及您已经从之前的测试中了解到的 assertResponseOk——它只是检查响应是否有 200 状态码。将以下测试添加到类中:

public function testGetBook() {
    $expectedResponse = [
        'book' => json_decode($this->books[1], true)
    ];
    $url = 'books/' . $this->books[1]->id
        . '?' . $this->getCredentials();

    $this->get($url)
        ->seeJson($expectedResponse)
        ->assertResponseOk();
}

private function getCredentials(): string {
    return 'grant_access=client_credentials&access_token='
        . $this->accessToken;
}

我们使用 get 方法而不是 post,因为这是一个 GET 请求。此外,请注意我们使用了 getCredentials 辅助函数,因为我们将在每个测试中使用它。为了查看另一个示例,让我们添加一个测试来检查请求包含给定标题的书籍时的响应:

public function testGetBooksByTitle() {
    $expectedResponse = [
        'books' => [
            json_decode($this->books[0], true),
            json_decode($this->books[2], true)
        ]
    ];

    $url = 'books/?title=Il&' . $this->getCredentials();
    $this->get($url)
        ->seeJson($expectedResponse)
        ->assertResponseOk();
}

前面的测试几乎与之前的测试相同,不是吗?唯一的变化是端点和预期的响应。嗯,剩余的测试都将遵循相同的模式,因为到目前为止,我们只能获取书籍并对其进行筛选。

为了看到一些不同之处,让我们来看看如何测试创建资源的端点。有几种不同的选择,其中之一是首先发出请求,然后去数据库检查资源是否已创建。另一个选择,我们更倾向于使用的方法,是首先发送创建资源的请求,然后,使用响应中的信息,发送一个请求来获取新创建的资源。这是更可取的,因为我们只测试 REST API,我们不需要知道数据库使用的具体模式。此外,如果 REST API 更改其数据库,测试将保持通过——它们应该保持通过——因为我们只通过接口进行测试。

一个很好的例子可能是借一本书。测试应该首先发送一个 POST 请求来借书,指定书籍 ID,然后从响应中提取借出的书籍 ID,最后发送一个 GET 请求请求那本借出的书。为了节省时间,你可以将以下测试添加到现有的tests/BooksTest.php中:

public function testBorrowBook() {
    $params = ['book-id' => $this->books[1]->id];
    $params = array_merge($params, $this->postCredentials());

    $this->post('borrowed-books', $params)
        ->seeJsonContains(['book_id' => $this->books[1]->id])
        ->assertResponseOk();

    $response = json_decode($this->response->getContent(), true);

    $url = 'borrowed-books' . '?' . $this->getCredentials();
    $this->get($url)
        ->seeJsonContains(['id' => $response['borrowed-book']['id']])
        ->assertResponseOk();
}

private function postCredentials(): array {
    return [
        'grant_access' => 'client_credentials',
        'access_token' => $this->accessToken
    ];
}

摘要

在本章中,你学习了 REST API 在 Web 世界中的重要性。现在你不仅能够使用它们,还能够编写自己的 REST API,这使得你成为一个更加多才多艺的开发者。你还可以将你的应用程序与第三方 API 集成,为用户提供更多功能,并使你的网站更加有趣和有用。

在下一章和最后一章中,我们将通过发现一种除单元测试之外的其他测试类型来结束这本书:行为测试,这可以提高你 Web 应用程序的质量和可靠性。

第十章. 行为测试

在第七章中,测试网络应用,你学习了如何编写单元测试以独立方式测试代码的小片段。尽管这是必须的,但仅靠它并不能确保你的应用按预期工作。你的测试范围可能非常小,即使你测试的算法是有意义的,它也可能不是业务要求你创建的内容。

为了给业务方面增加这一级别的安全性,接受测试应运而生,以补充已经存在的单元测试。同样,BDD(行为驱动开发)起源于 TDD(测试驱动开发),目的是基于这些接受测试编写代码,试图让业务和经理们参与到开发过程中。由于 PHP 是网络开发者的最爱之一,因此找到强大的工具来实现 BDD 项目是顺理成章的。你将会对BehatMink这两个目前最受欢迎的 BDD 框架所能做到的事情感到惊喜。

在本章中,你将了解:

  • 接受测试和 BDD

  • 使用 Gherkin 编写特性

  • 使用 Behat 实现和运行测试

  • 使用 Mink 针对浏览器编写测试

行为驱动开发

我们已经在第七章中,测试网络应用,介绍了我们可以用来使我们的应用无 bug 的工具,例如自动化测试。我们描述了单元测试是什么以及它们如何帮助我们实现目标,但这远远不够。在本节中,我们将描述创建真实世界应用的过程,单元测试为何不足,以及我们可以在这一生命周期中包含哪些其他技术来成功完成任务——在这种情况下,行为测试。

介绍持续集成

自己开发一个小型网络应用和成为由开发者、经理、市场营销人员等组成的大团队的一员,共同围绕同一个大型网络应用工作,这两者之间存在着巨大的差异。为成千上万的或数百万用户使用的应用工作具有明显的风险:如果你搞砸了,会有大量的不满意的受影响用户,这可能导致销售额下降、合作伙伴关系终止等。

从这个场景中,你可以想象,当人们不得不在生产环境中进行任何更改时,他们会感到害怕。在这样做之前,他们会确保一切运行得非常完美。因此,所有影响生产中网络应用的变化都伴随着一个繁重的过程,包括大量的各种测试。

有些人认为,通过减少他们部署到生产环境的次数,可以降低失败的风险,最终导致他们每隔几个月发布一次,其中包含无数的变化。

现在,想象一下一次性发布两三个月的代码更改结果,但在生产中神秘地失败了:你知道从哪里开始寻找问题的原因吗?如果你的团队能够做出完美的发布,但最终结果并不是市场需要的,你可能会浪费几个月的工作!

尽管有不同方法,并不是所有公司都使用它们,但让我们尝试描述过去几年中最著名的一种:持续集成CI)。这个想法是经常集成小块工作,而不是偶尔集成大块工作。当然,发布仍然是系统中的约束,这意味着它需要大量的时间和资源。CI 试图尽可能自动化这个过程,减少你需要投入的时间和资源。这种方法有很多好处,如下所述:

  • 发布并不需要很长时间,而且没有整个团队专注于发布,因为这是自动完成的。

  • 你可以逐个发布更改。如果有什么失败,你知道确切的变化是什么以及从哪里开始查找错误。如果你需要,甚至可以轻松地撤销更改。

  • 由于你发布得如此频繁,你可以从每个人那里快速获得反馈。如果你需要,你可以及时更改你的计划,而不是等待几个月才能获得任何反馈,浪费你在这个发布上所付出的所有努力。

这个想法看起来很完美,但我们如何实施它呢?首先,让我们关注这个过程的手动部分:使用版本控制系统VCS)开发功能。以下图表展示了一种非常常见的方法:

介绍持续集成

如我们之前提到的,版本控制系统(VCS)允许开发者对同一代码库进行工作,跟踪每个人所做的所有更改,并帮助解决冲突。VCS 通常允许你拥有不同的分支;也就是说,你可以从主开发线分叉出来,继续工作而不会干扰它。之前的图表展示了如何使用分支来编写新功能,可以解释如下:

  • A:一个团队需要开始工作在功能 A 上。他们从 master 创建一个新的分支,在这个分支中,他们将添加这个功能的全部更改。

  • B:另一个团队也需要开始工作在一个功能上。他们从 master 创建一个新的分支,就像之前一样。此时,他们并没有意识到第一个团队正在做什么,因为他们是在自己的分支上做的。

  • C:第二个团队完成了他们的工作。没有其他人更改 master,所以他们可以立即合并他们的更改。此时,CI 过程将启动发布过程。

  • D: 第一个团队完成了功能。为了将其合并到主分支,他们需要首先将他们的分支与主分支的新更改进行 rebase,并解决可能出现的任何冲突。分支越老,你遇到冲突的机会就越大,所以你可以想象,更小、更快的功能更受欢迎。

现在,让我们看看这个过程自动化的部分是如何的。以下图表显示了从合并到主分支到生产部署的所有步骤:

介绍持续集成

在你将代码合并到主分支之前,你处于开发环境。CI 工具将监听你项目主分支上的所有更改,并对每个更改触发一个作业。这个作业将负责在必要时构建项目,然后运行所有测试。如果有任何错误或测试失败,它将通知每个人,触发此作业的团队应该负责修复它。此时,主分支被认为是不可靠的。

如果所有测试都通过,CI 工具将部署你的代码到预发布环境。预发布环境尽可能地模拟生产环境;也就是说,它有相同的服务器配置、数据库结构等等。一旦应用程序在这里,你就可以运行所有需要的测试,直到你确信可以继续部署到生产环境。随着你进行小的更改,你不需要手动测试所有内容。相反,你可以测试你的更改和应用程序的主要用例。

单元测试与验收测试

我们说过,CI 的目标是尽可能自动化流程。然而,我们仍然需要在预发布环境中手动测试应用程序,对吧?验收测试来拯救!

编写单元测试很好,也是必须的,但它们只是以隔离的方式测试代码的小部分。即使你的整个单元测试套件都通过了,你也不能确定你的应用程序是否真的工作,因为你可能没有正确地整合所有部分,因为你缺少功能,或者你构建的功能并不是业务需要的。验收测试测试特定用例的整个流程。

如果你的应用程序是一个网站,验收测试可能会启动一个浏览器并模拟用户操作,例如点击和输入,以断言页面返回预期的结果。是的,从几行代码中,你可以以自动化的方式执行之前手动进行的所有测试。

现在,假设你为应用程序的所有功能编写了验收测试。一旦代码进入预发布环境,CI 工具可以自动运行所有这些测试,并确保新代码不会破坏任何现有功能。你甚至可以使用你需要的任何数量的不同浏览器来运行它们,以确保你的应用程序在所有浏览器中都能正常工作。如果测试失败,CI 工具将通知负责的团队,他们必须修复它。如果所有测试都通过,CI 工具可以自动将你的代码部署到生产环境。

如果验收测试测试的是业务真正关心的内容,那么我们为什么还需要编写单元测试呢?保持验收测试和单元测试都有几个原因;实际上,你应该有比验收测试多得多的单元测试。

  • 单元测试检查代码的小片段,这使得它们比验收测试快几个数量级,验收测试是对浏览器进行整个流程的测试。这意味着你可以用几秒钟或几分钟的时间运行所有的单元测试,但运行所有的验收测试将需要更长的时间。

  • 编写覆盖所有可能用例组合的验收测试几乎是不可能的。编写覆盖特定方法或代码片段的高比例用例的单元测试相对容易。你应该有大量的单元测试,尽可能多地测试边缘情况,但只有一些验收测试测试主要用例。

那么何时应该运行每种类型的测试呢?由于单元测试运行速度更快,它们应该在部署的第一阶段执行。只有当我们知道它们都通过后,我们才愿意花费时间部署到预发布环境并运行验收测试。

TDD 与 BDD

在第七章《测试 Web 应用程序》中,你学习了 TDD 或测试驱动开发是首先编写单元测试然后编写代码的实践,目的是编写可测试和更干净的代码,并确保你的测试套件始终保持最新。随着验收测试的出现,TDD 演变为 BDD 或行为驱动开发。

BDD 与 TDD 非常相似,因为你应该先编写测试,然后编写使这些测试通过的代码。唯一的区别是,在 BDD 中,我们编写指定代码期望行为的测试,这些测试可以转化为验收测试。尽管这始终取决于具体情况,但你应该编写测试验收测试,测试应用程序的非常具体的一部分,而不是包含多个步骤的长用例。与 TDD 一样,使用 BDD,你希望得到快速的反馈,如果你编写了一个广泛的测试,你将不得不编写大量的代码才能使其通过,这不是 BDD 想要实现的目标。

编写业务测试

验收测试和 BDD 的整个目的是确保您的应用程序按预期工作,而不仅仅是您的代码。因此,验收测试不应由开发者编写,而应由业务本身编写。当然,您不能期望经理和主管学习如何编码以创建验收测试,但有一系列工具允许您将简单的英语指令或行为规范转换为验收测试的代码。当然,这些指令必须遵循某些模式。行为规范有以下部分:

  • 一个标题,简要但非常清晰地描述了行为规范覆盖的用例。

  • 一个叙事,它具体说明了谁执行测试,业务价值是什么,以及预期的结果是什么。通常叙事的格式如下:

    In order to <business value>
    As a <stakeholder>
    I want to <expected outcome>
    
  • 一组场景,它描述了我们想要覆盖的每个特定用例的描述和步骤。每个场景都有一个描述和一系列在 Given-When-Then 格式下的指令;我们将在下一节中对此进行更多讨论。一个常见的模式是:

    Scenario: <short description>
    Given <set up scenario>
    When <steps to take>
    Then <expected outcome>
    

在接下来的两个部分中,我们将发现两个 PHP 工具,您可以使用它们来理解行为场景并将它们作为验收测试运行。

使用 Behat 进行 BDD

我们将要介绍的第一款工具是 Behat。Behat 是一个 PHP 框架,可以将行为场景转换为验收测试,然后运行它们,提供类似于 PHPUnit 的反馈。其理念是将每个步骤在英语中与执行某些操作或断言某些结果的 PHP 函数中的场景相匹配。

在本节中,我们将尝试为我们应用程序添加一些验收测试。该应用程序将是一个简单的数据库迁移脚本,它将允许我们跟踪我们将添加到我们的模式中的更改。想法是每次您想要更改数据库时,您都会在迁移文件上编写更改,然后执行脚本。应用程序将检查最后一次执行的迁移是什么,并将执行新的迁移。我们将首先编写验收测试,然后按照 BDD 的建议逐步引入代码。

为了在您的开发环境中安装 Behat,您可以使用 Composer。命令如下:

$ composer require behat/behat

Behat 实际上并不附带任何一组断言函数,因此您必须通过编写条件语句和抛出异常来实现自己的函数,或者您可以集成任何提供这些函数的库。开发者通常选择 PHPUnit 来完成这项工作,因为他们已经习惯了它的断言。然后,您可以通过以下方式将其添加到项目中:

$ composer require phpunit/phpunit

与 PHPUnit 类似,Behat 需要知道你的测试套件位于何处。你可以有一个配置文件来声明这一点和其他配置选项,这与 PHPUnit 的 phpunit.xml 配置文件类似,或者你可以遵循 Behat 设置的约定并跳过配置步骤。如果你选择第二个选项,你可以使用以下命令让 Behat 为你创建文件夹结构和 PHP test 类:

$ ./vendor/bin/behat --init

运行此命令后,你应该有一个 features/bootstrap/FeatureContext.php 文件,这是你需要添加 PHP 函数匹配场景步骤的地方。关于这一点,我们稍后会详细说明,但首先,让我们了解一下如何编写行为规范,以便 Behat 能够理解它们。

介绍 Gherkin 语言

Gherkin 是行为规范必须遵循的语言,或者更准确地说,是格式。使用 Gherkin 命名,每个行为规范都是一个 特性。每个特性都添加到 features 目录中,并且应该有 .feature 扩展名。特性文件应该以 Feature 关键字开头,后面跟着标题和叙述,格式与我们之前提到的相同——即 为了(In order to)–作为(As a)–我需要(I need to) 结构。实际上,Gherkin 只会打印这些行,但保持一致性将有助于你的开发者和业务人员了解他们试图实现的目标。

我们的应用程序将有两个特性:一个用于设置我们的数据库,以便迁移工具能够工作;另一个用于向数据库添加迁移时的正确行为。将以下内容添加到 features/setup.feature 文件中:

Feature: Setup
  In order to run database migrations
  As a developer
  I need to be able to create the empty schema and migrations table.

然后,将以下特性定义添加到 features/migrations.feature 文件中:

Feature: Migrations
  In order to add changes to my database schema
  As a developer
  I need to be able to run the migrations script

定义场景

特性的标题和叙述实际上并没有做更多的事情,只是向运行测试的人提供信息。真正的工作是在场景中完成的,场景是一组特定的用例,包含一系列要执行的步骤和一些断言。只要它们代表同一特性的不同用例,你就可以在每个特性文件中添加任意多的场景。例如,对于 setup.feature,我们可以添加几个场景:一个场景是用户第一次运行脚本,因此应用程序需要设置数据库;另一个场景是用户之前已经执行过脚本,因此应用程序不需要经过设置过程。

由于 Behat 需要将用纯英语编写的场景转换为 PHP 函数,你将不得不遵循一些约定。实际上,你会发现它们与我们已经在行为规范部分提到的是非常相似的。

编写 Given-When-Then 测试用例

一个场景必须以 Scenario 关键词开始,后面跟着对该场景覆盖的用例的简要描述。然后,你需要添加步骤和断言的列表。Gherkin 允许你使用四个关键词来完成这个任务:GivenWhenThenAnd。实际上,当涉及到代码时,它们都有相同的意义,但它们为你的场景添加了很多语义价值。让我们考虑一个例子;在你的 setup.feature 文件末尾添加以下场景:

Scenario: Schema does not exist and I do not have migrations
  Given I do not have the "bdd_db_test" schema
  And I do not have migration files
  When I run the migrations script
  Then I should have an empty migrations table
  And I should get:
    """
    Latest version applied is 0.
    """

这个场景测试当我们没有任何模式信息并运行迁移脚本时会发生什么。首先,它描述了场景的状态:Given 我没有 bdd_db_test 模式 And 我没有迁移文件。这两行将翻译成每个方法,将删除模式和所有迁移文件。然后,场景描述了用户将执行的操作:When 我运行迁移脚本。最后,我们为这个场景设定期望:Then 我应该有一个空的迁移表 And 我应该得到已应用最新版本是 0.

通常情况下,相同的步骤总是以相同的关键词开始——也就是说,我运行迁移脚本 总是以 When 为前缀。And 关键词是一个特殊的词,因为它匹配所有三个关键词;它的唯一目的是使步骤尽可能英语友好;尽管如此,如果你愿意,你也可以写 Given 我没有迁移文件

在这个例子中,还有一个需要注意的事项是使用参数作为步骤的一部分。And 我应该得到 这一行后面跟着一个由 """ 包围的字符串。PHP 函数将获取这个字符串作为参数,因此你可以使用一个独特的步骤定义——即函数——来应对各种情况,只需使用不同的字符串即可。

重复使用场景的部分

对于一个特定的特性,你通常总是从同一个场景开始的情况很常见。例如,setup.feature 有一个场景,我们可以运行迁移脚本而没有任何迁移文件,但我们还会添加另一个场景,我们想要运行迁移脚本并带有一些迁移文件,以确保它会应用所有这些文件。这两个场景有一个共同点:它们都没有设置数据库。

Gherkin 允许你定义一些步骤,这些步骤将应用于该特性的所有场景。你可以使用 Background 关键词和一系列步骤,通常是 Given。在 feature 叙述和 scenario 定义之间添加这两行:

Background:
  Given I do not have the "bdd_db_test" schema

现在,你可以从现有的场景中移除第一个步骤,因为 Background 会处理它。

编写步骤定义

到目前为止,我们使用 Gherkin 语言编写了特性,但我们还没有考虑每个场景中的任何步骤是如何翻译成实际代码的。最容易的方法是让 Behat 运行验收测试;由于步骤尚未定义,Behat 会打印出你需要添加到FeatureContext类中的所有函数。要运行测试,只需执行以下命令:

$ ./vendor/bin/behat

以下截图显示了如果你没有步骤定义应该得到的输出:

编写步骤定义

正如你所注意到的,Behat 抱怨了一些缺失的步骤,然后以黄色打印出你可以用来实现它们的方法。将它们复制并粘贴到你的自动生成的features/bootstrap/FeatureContext.php文件中。以下FeatureContext类已经实现了所有这些:

<?php

use Behat\Behat\Context\Context;
use Behat\Behat\Context\SnippetAcceptingContext;
use Behat\Gherkin\Node\PyStringNode;

require_once __DIR__ . '/../../vendor/phpunit/phpunit/src/Framework/Assert/Functions.php';

class FeatureContext implements Context, SnippetAcceptingContext
{
    private $db;
    private $config;
    private $output;

    public function __construct() {
        $configFileContent = file_get_contents(
            __DIR__ . '/../../config/app.json'
        );
        $this->config = json_decode($configFileContent, true);
    }

    private function getDb(): PDO {
        if ($this->db === null) {
            $this->db = new PDO(
                "mysql:host={$this->config['host']}; "
                    . "dbname=bdd_db_test",
                $this->config['user'],
                $this->config['password']
            );
        }

        return $this->db;
    }

    /**
     * @Given I do not have the "bdd_db_test" schema
     */
    public function iDoNotHaveTheSchema()
    {
        $this->executeQuery('DROP SCHEMA IF EXISTS bdd_db_test');
    }

    /**
     * @Given I do not have migration files
     */
    public function iDoNotHaveMigrationFiles()
    {
        exec('rm db/migrations/*.sql > /dev/null 2>&1');
    }

    /**
     * @When I run the migrations script
     */
    public function iRunTheMigrationsScript()
    {
        exec('php migrate.php', $this->output);
    }

    /**
     * @Then I should have an empty migrations table
     */
    public function iShouldHaveAnEmptyMigrationsTable()
    {
        $migrations = $this->getDb()
            ->query('SELECT * FROM migrations')
            ->fetch();
        assertEmpty($migrations);
    }

    private function executeQuery(string $query)
    {
        $removeSchemaCommand = sprintf(
            'mysql -u %s %s -h %s -e "%s"',
            $this->config['user'],
            empty($this->config['password'])
                ? '' : "-p{$this->config['password']}",
            $this->config['host'],
            $query
        );

        exec($removeSchemaCommand);
    }
}

正如你所注意到的,我们从config/app.json文件中读取了配置。这是应用程序将使用的相同配置文件,它包含数据库的凭据。我们还实例化了一个PDO对象来访问数据库,以便我们可以添加或删除表,或者查看脚本做了什么。

步骤定义是一组带有注释的方法。这个注释是一个注解,因为它以@开头,基本上是一个与在特性中定义的纯英文步骤匹配的正则表达式。每个步骤都有自己的实现:要么删除数据库或迁移文件,要么执行迁移脚本,或者检查迁移表的内容。

步骤参数化

在前面的FeatureContext类中,我们故意遗漏了iShouldGet方法。正如你可能记得的,这个步骤有一个以"""包围的字符串参数。这个方法的实现如下所示:

/**
 * @Then I should get:
 */
public function iShouldGet(PyStringNode $string)
{
    assertEquals(implode("\n", $this->output), $string);
}

注意正则表达式不包含字符串。这发生在使用"""的长字符串时。此外,参数是一个PyStringNode实例,它比普通字符串复杂一些。然而,不用担心;当你与字符串比较时,PHP 会寻找__toString方法,它只是打印字符串的内容。

运行特性测试

在前面的章节中,我们使用 Behat 编写了验收测试,但我们还没有写一行代码。在运行它们之前,请添加config/app.json配置文件,包含你的数据库用户的凭据,以便FeatureContext构造函数可以找到它,如下所示:

{
  "host": "127.0.0.1",
  "schema": "bdd_db_test",
  "user": "root",
  "password": ""
}

现在,让我们运行验收测试,预期它们会失败;否则,我们的测试将完全无效。输出应该类似于以下内容:

运行特性测试

如预期的那样,Then步骤失败了。让我们实现必要的最小代码,以便使测试通过。首先,将自动加载器添加到你的composer.json文件中,并运行composer update

"autoload": {
    "psr-4": {
        "Migrations\\": "src/"
    }
}

我们希望实现一个包含设置数据库、运行迁移等必要辅助函数的Schema类。目前,这个特性只关注数据库的设置——也就是说,创建数据库、添加空的迁移表以跟踪所有添加的迁移,以及获取最新已注册为成功的迁移的能力。将以下代码添加为src/Schema.php

<?php

namespace Migrations;

use Exception;
use PDO;

class Schema {

    const SETUP_FILE = __DIR__ . '/../db/setup.sql';
    const MIGRATIONS_DIR = __DIR__ . '/../db/migrations/';

    private $config;
    private $connection;

    public function __construct(array $config)
    {
        $this->config = $config;
    }

    private function getConnection(): PDO
    {
        if ($this->connection === null) {
            $this->connection = new PDO(
                "mysql:host={$this->config['host']};"
                    . "dbname={$this->config['schema']}",
                $this->config['user'],
                $this->config['password']
            );
        }

        return $this->connection;
    }
}

尽管本章的重点是编写验收测试,但让我们回顾一下实现的不同方法:

  • 构造函数和getConnection只是读取config/app.json中的配置文件,并实例化了PDO对象。

  • createSchema执行了CREATE SCHEMA IF NOT EXISTS,所以如果模式已经存在,它将不会做任何事情。我们使用exec而不是PDO来执行命令,因为PDO总是需要使用现有的数据库。

  • getLatestMigration将首先检查迁移表是否存在;如果不存在,我们将使用setup.sql创建它,然后获取最后一个成功的迁移。

我们还需要添加migrations/setup.sql文件,其中包含创建迁移表的查询,如下所示:

CREATE TABLE IF NOT EXISTS migrations(
  version INT UNSIGNED NOT NULL,
  `time` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
  status ENUM('success', 'error'),
  PRIMARY KEY (version, status)
);

最后,我们需要添加migrate.php文件,这是用户将执行的那个文件。这个文件将获取配置,实例化Schema类,设置数据库,并检索最后一个应用的迁移。运行以下代码:

<?php

require_once __DIR__ . '/vendor/autoload.php';

$configFileContent = file_get_contents(__DIR__ . '/config/app.json');
$config = json_decode($configFileContent, true);

$schema = new Migrations\Schema($config);

$schema->createSchema();

$version = $schema->getLatestMigration();
echo "Latest version applied is $version.\n";

你现在可以再次运行测试了。这次,输出应该类似于这个截图,其中所有步骤都是绿色的:

运行特性测试

既然我们的验收测试已经通过,我们需要添加剩余的测试。为了加快速度,我们将添加所有场景,然后我们将实现必要的代码使它们通过,但最好是一次添加一个场景。setup.feature的第二个场景可能看起来如下(记住,特性包含一个Background部分,其中我们清理数据库):

Scenario: Schema does not exists and I have migrations
  Given I have migration file 1:
    """
    CREATE TABLE test1(id INT);
    """
  And I have migration file 2:
    """
    CREATE TABLE test2(id INT);
    """
  When I run the migrations script
  Then I should only have the following tables:
    | migrations |
    | test1      |
    | test2      |
  And I should have the following migrations:
    | 1 | success |
    | 2 | success |
  And I should get:
    """
    Latest version applied is 0.
    Applied migration 1 successfully.
    Applied migration 2 successfully.
    """

这个场景很重要,因为它在步骤定义中使用了参数。例如,我有迁移文件步骤被呈现了两次,每次都使用不同的迁移文件编号。这个步骤的实现如下:

/**
 * @Given I have migration file :version:
 */
public function iHaveMigrationFile(
    string $version,
    PyStringNode $file
) {
    $filePath = __DIR__ . "/../../db/migrations/$version.sql";
    file_put_contents($filePath, $file->getRaw());
}

这个方法的注释,它是一个正则表达式,使用了:version作为通配符。任何以Given I have migration file开头,后面跟其他内容的步骤都将匹配这个步骤定义,而“其他内容”部分将作为字符串接收为$version参数。

在这里,我们引入了另一种类型的参数:表。然后我应该只有以下表步骤定义了一个两行一列的表,而然后我应该有以下迁移部分发送了一个两行两列的表。新步骤的实现如下:

/**
 * @Then I should only have the following tables:
 */
public function iShouldOnlyHaveTheFollowingTables(TableNode $tables) {
    $tablesInDb = $this->getDb()
        ->query('SHOW TABLES')
        ->fetchAll(PDO::FETCH_NUM);

    assertEquals($tablesInDb, array_values($tables->getRows()));
}

/**
 * @Then I should have the following migrations:
 */
public function iShouldHaveTheFollowingMigrations(
    TableNode $migrations
) {
    $query = 'SELECT version, status FROM migrations';
    $migrationsInDb = $this->getDb()
        ->query($query)
        ->fetchAll(PDO::FETCH_NUM);

    assertEquals($migrations->getRows(), $migrationsInDb);
}

表格作为 TableNode 参数接收。此类包含一个 getRows 方法,该方法返回在功能文件中定义的行数组。

我们还希望添加的另一个功能是 features/migrations.feature。此功能将假设用户已经设置了数据库,因此我们将添加一个包含此步骤的 Background 部分。我们将添加一个场景,其中迁移文件编号不连续,在这种情况下,应用程序应停止在最后一个连续的迁移文件处。另一个场景将确保当出现错误时,应用程序不会继续迁移过程。该功能应类似于以下内容:

Feature: Migrations
  In order to add changes to my database schema
  As a developer
  I need to be able to run the migrations script

  Background:
    Given I have the bdd_db_test

  Scenario: Migrations are not consecutive
    Given I have migration 3
    And I have migration file 4:
      """
      CREATE TABLE test4(id INT);
      """
    And I have migration file 6:
      """
      CREATE TABLE test6(id INT);
      """
    When I run the migrations script
    Then I should only have the following tables:
      | migrations |
      | test4      |
    And I should have the following migrations:
      | 3 | success |
      | 4 | success |
    And I should get:
      """
      Latest version applied is 3.
      Applied migration 4 successfully.
      """

  Scenario: A migration throws an error
    Given I have migration file 1:
      """
      CREATE TABLE test1(id INT);
      """
    And I have migration file 2:
      """
      CREATE TABLE test1(id INT);
      """
    And I have migration file 3:
      """
      CREATE TABLE test3(id INT);
      """
    When I run the migrations script
    Then I should only have the following tables:
      | migrations |
      | test1      |
    And I should have the following migrations:
      | 1 | success |
      | 2 | error   |
    And I should get:
      """
      Latest version applied is 0.
      Applied migration 1 successfully.
      Error applying migration 2: Table 'test1' already exists.
      """

没有任何新的 Gherkin 功能。两个新的步骤实现如下:

/**
* @Given I have the bdd_db_test
*/
public function iHaveTheBddDbTest()
{
    $this->executeQuery('CREATE SCHEMA bdd_db_test');
}

/**
 * @Given I have migration :version
 */
public function iHaveMigration(string $version)
{
    $this->getDb()->exec(
        file_get_contents(__DIR__ . '/../../db/setup.sql')
    );

    $query = <<<SQL
INSERT INTO migrations (version, status)
VALUES(:version, 'success')
SQL;
    $this->getDb()
        ->prepare($query)
        ->execute(['version' => $version]);
}

现在,是时候添加必要的实现来使测试通过。只需要两个更改。第一个是在 Schema 类中的 applyMigrationsFrom 方法,它将尝试应用给定版本号的迁移文件。如果迁移成功,它将在迁移表中添加一行,其中包含成功添加的新版本。如果迁移失败,我们将在迁移表中添加一条失败记录,然后抛出异常,以便脚本知道这一点。最后,如果迁移文件不存在,返回值将是 false。将以下代码添加到 Schema 类中:

public function applyMigrationsFrom(int $version): bool
{
    $filePath = self::MIGRATIONS_DIR . "$version.sql";

    if (!file_exists($filePath)) {
        return false;
    }

    $connection = $this->getConnection();
    if ($connection->exec(file_get_contents($filePath)) === false) {
        $error = $connection->errorInfo()[2];
        $this->registerMigration($version, 'error');
        throw new Exception($error);
    }

    $this->registerMigration($version, 'success');
    return true;
}

private function registerMigration(int $version, string $status)
{
    $query = <<<SQL
INSERT INTO migrations (version, status)
VALUES(:version, :status)
SQL;
    $params = ['version' => $version, 'status' => $status];

    $this->getConnection()->prepare($query)->execute($params);
}

另一个缺失的部分在 migrate.php 脚本中。我们需要从最新版本开始调用新创建的 applyMigrationsFrom 方法,直到我们得到一个 false 值或异常。我们还想打印出有关正在发生的事情的信息,以便用户了解添加了哪些迁移。在 migrate.php 脚本的末尾添加以下代码:

do {
    $version++;

    try {
        $result = $schema->applyMigrationsFrom($version);
        if ($result) {
            echo "Applied migration $version successfully.\n";
        }
    } catch (Exception $e) {
        $error = $e->getMessage();
        echo "Error applying migration $version: $error.\n";
        exit(1);
    }
} while ($result);

现在,运行测试,voilà!它们都通过了。您现在有一个管理数据库迁移的库,您有 100% 的把握它工作正常,多亏了您的验收测试。

使用 Mink 在浏览器中进行测试

到目前为止,我们已经能够为脚本编写验收测试,但你们大多数人阅读这本书是为了编写漂亮且闪亮的网络应用程序。那么,如何利用验收测试呢?现在是时候介绍本章的第二个 PHP 工具:Mink。

Mink 实际上是 Behat 的一个扩展,它添加了与网络浏览器测试相关的几个步骤的实现。例如,如果您将 Mink 添加到您的应用程序中,您将能够添加 Mink 将启动浏览器并按请求进行点击或输入的场景,这将为您节省大量手动测试的时间和精力。然而,首先,让我们看看 Mink 如何实现这一点。

网络驱动程序类型

Mink 使用网络驱动程序——即具有 API 的库,允许你与浏览器交互。你可以发送命令,例如 转到这个页面点击这个链接用这个文本填充这个输入字段,等等,网络驱动程序会将这些转换为针对你的浏览器的正确指令。有几个网络驱动程序,每个都采用不同的方法实现。这就是为什么根据网络驱动程序,你将有一些功能或另一些功能。

根据它们的工作方式,网络驱动程序可以分为两组:

  • 无头浏览器:这些驱动程序实际上并没有启动浏览器;它们只是尝试模拟一个。它们实际上请求网页并渲染 HTML 和 JavaScript 代码,因此它们知道页面看起来如何,但它们不会显示它。它们有一个巨大的好处:它们易于安装和管理,并且由于它们不需要构建图形表示,它们非常快。缺点是它们在 CSS 和一些 JavaScript 功能方面有严重的限制,尤其是 AJAX。

  • 启动真实浏览器的网络驱动程序:这些网络驱动程序可以做到几乎任何事情,比无头浏览器强大得多。问题是它们可能有点难以安装,并且非常、非常慢——就像一个真实用户试图通过场景一样慢。

那么,你应该选择哪一个呢?像往常一样,这取决于你的应用程序。如果你有一个不大量使用 CSS 和 JavaScript 的应用程序,并且它对你的业务不是至关重要的,你可以使用无头浏览器。相反,如果该应用程序是你的业务基石,你需要绝对确信所有 UI 功能都能按预期工作,你可能想要选择启动浏览器的网络驱动程序。

使用 Goutte 安装 Mink

在本章中,我们将使用 Goutte,这是一个由参与开发 Symfony 的同一批人编写的无头网络驱动程序,为 GitHub 的仓库页面添加一些验收测试。你的项目所需的组件将是 Behat、Mink 和 Goutte 驱动程序。使用以下命令通过 Composer 添加它们:

$ composer require behat/behat
$ composer require behat/mink-extension
$ composer require behat/mink-goutte-driver

现在,执行以下命令以让 Behat 创建基本目录结构:

$ ./vendor/bin/behat –init

我们将对 FeatureContext 类所做的唯一更改是它扩展的地方。这次,我们将使用 MinkContext 以获取所有与网络测试相关的步骤定义。FeatureContext 类应该看起来像这样:

<?php

use Behat\MinkExtension\Context\MinkContext;

require __DIR__ . '/../../vendor/autoload.php';

class FeatureContext extends MinkContext {
}

Mink 还需要一些配置,以便让 Behat 知道我们想要使用哪个网络驱动程序或我们的测试的基础 URL 是什么。将以下信息添加到 behat.yml 文件中:

default:
  extensions:
    Behat\MinkExtension:
      base_url: "https://github.com"
      sessions:
        default_session:
          goutte: ~

使用这个配置,我们让 Behat 知道我们正在使用 Mink 扩展,Mink 将在所有会话中使用 Goutte(如果需要,实际上可以定义具有不同 Web 驱动的不同会话),并且这些测试的基础 URL 是 GitHub 的。Behat 已经被指示在执行它的同一目录中查找 behat.yml 文件,所以我们不需要做任何事情。

与浏览器的交互

现在,让我们看看魔法。如果你知道如何使用步骤,用 Mink 编写验收测试就像玩游戏一样。首先,在 feature/search.feature 中添加以下功能:

Feature: Search
  In order to find repositories
  As a website user
  I need to be able to search repositories by name

  Background:
    Given I am on "/picahielos"
    And I follow "Repositories"

  Scenario: Searching existing repository
    When I fill in "zap" for "q"
    And I press "Search"
    Then I should see "picahielos/zap"

  Scenario: Searching non-existing repository
    When I fill in "yolo" for "q"
    And I press "Search"
    Then I should not see "picahielos/yolo"

首先要注意的是,我们有一个 Background 部分。这个部分假设用户访问了 github.com/picahielos 页面并点击了 Repositories 链接。使用 我跟随 加上一些字符串相当于尝试找到这个字符串的链接并点击它。

第一个场景使用了 当我用 填充 步骤,这基本上是尝试在页面上找到输入字段(你可以指定 ID 或名称),并为你输入值。在这种情况下,q 字段是搜索栏,我们输入了 zap。然后,类似于点击链接,我按下

posted @ 2025-09-07 09:14  绝不原创的飞龙  阅读(0)  评论(0)    收藏  举报