PHP8-MVC-高级教程-全-

PHP8 MVC 高级教程(全)

原文:Pro PHP 8 MVC

协议:CC BY-NC-SA 4.0

一、PHP 的使用方法

让我们来谈谈 PHP 的几种不同的使用方法。您可能会遇到的主要问题有

  • 在网络服务器中运行脚本,制作网站

  • 从命令行/终端运行脚本

让我们来看看这些的一些变体。除了在旅途中编码之外,我不打算深入介绍设置。等你拿到这本书的时候,那样的说明可能已经过时了。

你最好搜索类似“我如何在[你的操作系统上安装 PHP]”…

在终端中运行脚本

你看过电影里的电脑黑客吗?他们通常弓着背坐在键盘和屏幕前,疯狂地打字。有时他们穿着连帽衫。

事实是,编程和使用终端是正常的事情。如果你用的是苹果公司生产的电脑或者运行的是 Linux 系统,你可能比用 Windows 系统的电脑更习惯看到终端窗口。

终端窗口,有时也称为命令提示符或控制台,只是与计算机内部的直接通信。你可以用它们来安装新程序或运行你自己编写的脚本。

您不必使用终端窗口来运行您的脚本。如果你更喜欢视觉界面,直接跳到前面。

安装和使用 PHP 的步骤因您使用的操作系统而异。如果你使用的是 macOS,你可以使用 Homebrew 来安装 PHP 和一个数据库。

如果你使用的是 Linux,你可以使用一个内置的包管理器来安装同样的东西。

在 Windows 上,你可以尝试使用 Linux 的 Windows 子系统,它将提供与你在 Linux 计算机上找到的相同的终端界面。或者你可以选择像巧克力公司这样的包装经理。

官方 PHP 文档提供了如何在最常见的操作系统上安装 PHP 的最新说明列表。

题外话,我用的终端 app 叫超级终端。我喜欢它,因为我可以使用 JavaScript 来配置它,并且我还可以使用与我在代码编辑器中使用的相同的主题。

img/299823_2_En_1_Figa_HTML.jpg

超级终端应用,在 macOS 上运行

通过图形用户界面运行网站

有些人喜欢用更直观的方式来运营他们的网站。有很多好的选项可供选择,但我推荐一款名为 XAMPP 的应用。

你可以在 XAMPP 网站上找到通用操作系统的可下载安装程序,以及如何使用安装程序的说明。与在终端窗口运行 PHP 脚本不同,XAMPP 会给你一个地方来存放由网络服务器运行的 PHP 文件。区别是微妙而重要的。

当我们直接运行脚本时,它们会一直运行,直到完成。如果它们没有完成,通常是因为脚本有问题,比如无限循环。

一些框架和库引入了长期运行脚本或服务器脚本的概念。我说的不是这种情况下的那些。我说的是我们可能为了一个简单的目的想要执行的脚本,或者作为服务器维护的一部分想要频繁运行的脚本。

常见的例子有重命名一堆文件的脚本、删除旧日志文件的脚本,甚至是运行一些预定任务的脚本。

当我们使用 web 服务器时,web 服务器获取请求的细节(标题、参数等)。),它执行脚本。在一天结束的时候,同样的代码运行,但是 web 服务器从我们手里拿走了一些工作。在某种程度上,它也让我们不用再处理输出和错误日志了。

稍后,当我要求您运行一个脚本时,您可能需要在您的 web 服务器的上下文中进行解释。如果我告诉你运行一个脚本,这可能意味着把它放在一个 web 服务器可以服务的文件中。我会告诉你什么时候做什么…

通过虚拟计算机运行网站

假设你想在你自己的计算机上运行你的代码,但是你不希望它阻塞你的文件系统或者导致各种各样的新东西被安装。那样的话,你可以用 VirtualBox 之类的。

VirtualBox 是您安装的一个程序,它允许您创建在您的计算机上运行的“虚拟计算机”。您可以决定允许他们访问多少资源。当它们被挂起时,它们不使用任何资源,除了它们需要的硬盘空间来记住它们之前在做什么。

设置和使用它们的过程与设置一台新的物理计算机没有什么不同。您需要首选操作系统的安装文件,然后您需要在这些文件上安装 PHP(和其他工具),就像它们是一台物理计算机一样。

这比在你的实际电脑上安装要多一点工作,但通常也要干净得多。

你可以使用基本的 VirtualBox 应用,或者你可以更进一步,使用一个叫做vagger的软件提供的自动设置帮助。这是一个工具,让您使用脚本来设置和维护 VirtualBox(和其他)虚拟计算机。你甚至可以使用别人做的食谱,这样你就不用自己做任何繁重的工作了。

当我了解更多关于流浪者的信息时,我推荐这些资源:

  • 游民食谱会解释游民做什么,怎么用。

  • Phansible 会询问你想要安装什么,然后为你创建漫游脚本。

img/299823_2_En_1_Figb_HTML.jpg

显见的。com 流浪者提供工具

在远程服务器上运行网站

在某些时候,你会希望其他人看到并使用你制作的网站。有一些方法可以让他们看到在你本地电脑上运行的网站,但这并不是一个永久的解决方案。

相反,许多公司提供他们喜欢称之为“云托管”或“虚拟服务器托管”的服务。有一些大公司,像亚马逊网络服务公司和谷歌云公司。还有一些更小的名字,如数字海洋Vultr 。我更喜欢小公司,因为他们的管理控制台更容易理解。

一旦您拥有 DigitalOcean 的帐户,您就可以登录并创建虚拟服务器。它类似于 VirtualBox 服务器,因为它不是物理机器。你仍然可以在上面运行流行的操作系统,比如 Ubuntu Linux。

img/299823_2_En_1_Figc_HTML.jpg

在数字海洋上创建虚拟服务器

事实上,我提到的所有公司都允许你建立某种形式的虚拟服务器,运行 Linux。从那时起,你只需要按照你原本在个人电脑的终端窗口中所做的指示去做。

如果你喜欢别人为你做繁重的工作——就像我一样——你可以使用像 Laravel Forge 这样的服务来安装运行 PHP 应用所需的一切。

在本书中,我们会经常提到拉勒维尔。虽然 Laravel Forge 面向支持 Laravel 应用,但它可以托管为与其他框架一起工作而构建的网站,甚至是用其他语言编写的网站。

我在我的 Forge 服务器上托管了许多 NodeJS 网站,因为我仍然可以从这些网站使用 Forge 中获得所有的安全性和自动化。

Forge 的创始人 Taylor Otwell 慷慨地提供了 Forge 第一年 35%的优惠券。在添加付款方式后(但在订购前),您可以使用添加到您的账单资料中的优惠券代码 lHz71w7Z

img/299823_2_En_1_Figd_HTML.jpg

在 Laravel Forge 上供应虚拟服务器

“在云端”托管不是免费的。其中一些公司会给你慷慨的试用账户,但你迟早会开始为他们的服务付费。幸运的是,你不需要支付任何费用就可以开始 PHP MVC 开发,只要你在你的个人电脑上做…

在沙盒中编码

当你想测试一些代码,但是你不在一台熟悉的计算机旁边时,你可以在沙箱中编码。沙盒网站允许你运行 PHP 代码和共享链接,这样你就可以向别人展示一些东西。

有两个我推荐你试试:

  1. Laravel Playground 是为测试 Laravel 代码量身定制的,但是你可以在其中执行任何 PHP 代码。您还可以在另一个站点上嵌入一个操场(带有自定义代码),这对于在 wiki 或文档站点中记录您的 PHP 代码非常有用。

  2. 3v4l 是了解相同的代码如何在不同版本的 PHP 中运行的最佳场所。有一只奇怪的虫子?把代码放在那里,并在 Twitter 上分享它的链接。

移动编码

作为最后一点乐趣,我想谈谈在 iPad 上编码。我接触的许多开发人员都有 iPad,但他们并不知道它可以成为一个强大的移动编码工具。

如果你想探索这个话题,我推荐你试试以下几个应用…

第一个应用叫做 DraftCode。这是一个 PHP 代码编辑器,允许执行本地 PHP 代码,就像你在 XAMPP 这样的 GUI 中运行代码一样。在撰写本文时,它的价格是 4.99 美元。

img/299823_2_En_1_Fige_HTML.jpg

代码编辑器

这是我能找到的为数不多的几个甚至试图在没有互联网连接的情况下执行代码的应用之一,这意味着你可以在火车或飞机上使用它。它对 WordPress 应用有很好的支持,过去我甚至让它运行过 Laravel 应用。

不幸的是,似乎维护者已经决定提供受支持的 PHP 版本(7.2 和 7.3)作为额外的应用内购买。你可以使用基础应用运行 PHP 5.6 代码,但你必须额外支付 3.99 美元或 5.99 美元才能解锁新版本。

或者,你可以试试一个叫 winphp 的应用。我没有太多使用它的经验,但它似乎提供了与 DraftCode 相同的功能,甚至更多。你可以免费下载,但你也可以花 4.99 美元在应用内购买,解锁大量额外的功能(并隐藏广告)。

img/299823_2_En_1_Figf_HTML.jpg

winphp 代码编辑器

这两款应用都支持外置键盘和鼠标/触控板,前提是你能让它们与你的 iPad 兼容。我发现即使只有一个键盘盖也对我的编码有很大的帮助。毕竟,没有人喜欢在屏幕上敲一大堆文字…

接下来,有一个叫做工作副本的应用。这是一个很容易与 GitHub 集成的 Git 客户端。这个想法是,你可以使用工作副本来克隆你正在做的回购,然后在一个可以执行代码的应用中编辑它。虽然您可以在工作副本中编辑文本文件,但没有内置的功能来本地执行这些文件。

img/299823_2_En_1_Figg_HTML.jpg

Git 客户端工作副本

自从我第一次尝试在 iPad 上编码以来,iOS(尤其是 iPadOS)已经有了很大的进步。除了苹果已经开始制作的伟大的新键盘和触控板外壳,文件应用使处理项目文件变得更加容易。

我要提到的最后一个 app 叫做 Termius 。这是一个用于 iPad 的 SSH 客户端。有许多这样的应用,但 Termius 很有趣,因为它有可以在桌面上使用的配套应用,所以你可以在它们之间共享设置。

如果您在 iPad 上完成了本地开发,并希望将您的网站部署到远程虚拟服务器,您将需要一种与该服务器通信的方法。宋承宪是方法。当然,您需要访问互联网来完成这一部分,但是如果您习惯于在 iPad 上工作,那么从 iPad 上部署可能适合您。

img/299823_2_En_1_Figh_HTML.jpg

ssh 客户端终端

与大多数其他应用一样,应用内购买将解锁额外功能并移除广告。我用 Termius 的次数还不够多,不需要那些功能,所以目前我还在用免费账号。

当然,随着 GitHub 在其应用中内置代码编辑器,在 iPad 上编码将变得更加容易。你显然需要互联网接入和一个付费的 GitHub 账户来使用这个选项,但我个人认为它的移动性是值得的。

关于 Docker 的一个注记

你可能听说过 Docker 的名字,尤其是当提到托管网站的时候。这是管理服务器的一种很好的方式,但是学习和使用起来会很棘手。我已经列出了许多关于如何运行 PHP 代码的很好的选项,我看不出再增加一个选项有什么价值。

欢迎你尝试一下,但是我不打算把它提到这一点以外。

从这里去哪里?

在下一章,我们将开始运行一些 PHP 代码。如果您想从终端运行该代码,您需要将终端打开到与您想运行的脚本相同的位置,并且

  • 直接运行文件(用类似php script.php的东西)。

  • 或者使用 PHP 开发服务器(用类似php -S的东西)。

不要担心——当我们需要运行代码时,我会更详细地解释如何使用它们。

另一方面,如果您更喜欢使用 web 服务器,那么您需要安装它并将您的脚本放在特殊的“web root”文件夹中。每个 web 服务器都是不同的,所以您需要参考文档来选择要安装的服务器。

二、编写我们的第一段代码

是时候开始编写代码了!Whoosh 需要一个网站,我们将为他们建立一个网站。因为这是我们第一次编码,至少在本书的上下文中,我将花一点时间谈论我们如何以及在哪里写代码。

我给你看的所有代码都会在 GitHub 上。如果这是你第一次使用 Git 这样的东西,不要紧张。这是一个存储代码并跟踪代码变化的系统。有点像一个事件数据库,这些事件是由应用代码中发生的事情定义的。有了它,你可以回到代码中的任何一点,看看你之前有什么。

这也是与其他开发人员合作的好方法。他们可以创建您的代码的副本,并使用这些副本来建议您进行更改。GitHub 提供了一个易于使用的界面来检查和接受这些变更,这样您就可以随时了解项目的进展情况。

我将让 a =神奇的 GitHub guide to Git 来介绍基础知识。在阅读本书中的代码时,您只需要几个命令:

  1. git clone git@github.com:assertchris/pro-php-mvc.git

  2. cd pro-php-mvc

  3. git branch -a

    如果你熟悉 GitHub 和 Git,请随意跳到本章中我们处理请求的部分。

这些命令在终端窗口当前所在的文件夹中创建源代码存储库的本地副本。然后,他们导航到包含这些文件的文件夹,并列出存储库中可用的分支。

分支就像房子里的不同房间,每个房间都松散地建立在前一个房间的基础上。假设我有一个包含三个文件的应用。我想添加第四个文件,但是在我处理它的时候,我不希望这个文件在存储库的主分支(或者房间)中。

我可以偏离主分支,在新的分支中处理我的第四个文件。我可以将这个变化合并回主分支,或者它可以永远存在于它的新分支中。我可以再一次偏离,或者离开主树枝,或者我做的这个新树枝。

这是我用来存储每章源代码的模式。每个分支都有前面所有章节的代码,而且还有以该分支命名的章节的代码。如果你在第五章中,你可以切换到名为chapter-5的分支,你会在那一章结束时看到代码是什么样的。

img/299823_2_En_2_Figa_HTML.jpg

章节的分支

要切换到您想要查看的分支,使用命令git switch chapter-5,其中chapter-5是您想要切换到的分支的名称。如果你喜欢更直观的界面, GitHub 也有一个简洁的应用你可以试试。

img/299823_2_En_2_Figb_HTML.jpg

可视化检查来自 GitHub 库的代码

如果你找不到你要找的代码,请随时在 Twitter 上联系我。

处理请求

本章的代码可以在 GitHub 上找到。正如你可能已经收集到的,这本书关注的是 PHP 8,这意味着你需要有那个版本,否则你可能会看到弹出的错误消息。除了学习如何制作我们自己的框架,我们还学习了所有可以用 PHP 8 编写的新代码…

你可以克隆回购协议,看看我是如何写的东西,或者如果你有一个讨厌的错误,你只是不能过去。我建议你开始一个单独的“新”项目,在那里你写你在本书中看到的代码,作为帮助你学习和记忆的一种方式。

打开您的代码编辑器,为您的 Whoosh 网站版本创建一个新的项目/空间。我个人最喜欢的是 Visual Studio 代码,但是你可以使用任何你喜欢的编辑器。

我提到过我喜欢在我的终端和代码编辑器中使用相同的主题。超级终端和 VS 代码都允许大量的定制。我使用的主题来自 rainglow.io。在那里,你可以找到如何在代码编辑器和终端中安装主题的说明链接。

每个 PHP 网站都以一个文件开始。甚至最流行的框架也是这样工作的。在深处的某个地方,有一个 web 服务器正在向其发送请求的index.php文件。

img/299823_2_En_2_Figc_HTML.jpg

创建我们的第一个框架文件

马上,我们需要确保 PHP 处于工作状态。如果你要通过终端运行你的代码,你可以从同一个文件夹中使用类似于php -S 0.0.0.0:8000的命令,然后你应该能够在你的网络浏览器中打开http://0.0.0.0:8000

img/299823_2_En_2_Figd_HTML.jpg

检查 PHP 信息

你的版本号可能和我的不同,因为你可能在我写完几个月后才读到这篇文章。不管怎样,你用 Homebrew 安装的 PHP 版本应该和你在这个网页上看到的版本一样。

如果你通过图形用户界面、网络服务器或 iPad 运行你的网站,你需要把这个index.php文件放在网络服务器指向的网络根目录(有时也称为“root”)。

在 XAMPP 上,这意味着打开 GUI,启动服务器,单击 volumes,然后在/opt/lampp上单击 mount。这将挂载一个网络共享,您可以从代码编辑器中打开它。

无论是通过本地开发服务器还是通过 web 服务器运行该文件,结果应该是一样的。您正在寻找这个略带紫色的浅灰色页面,它显示了 PHP 的最新版本。

更具体地说,我们看到一个页面,显示了这个版本的 PHP 安装的所有设置和模块。一旦我们知道这个代码在工作,我们就可以开始响应不同的请求。每次 PHP 脚本以这种方式运行时,我们都可以访问请求周围的一堆上下文。让我告诉你我的意思:

var_dump(getenv('PHP_ENV'), $_SERVER, $_REQUEST);

如果我们用“变量转储”替换phpinfo()函数调用,我们可以看到正常请求提供了什么上下文。

如果很难看到所有的细节,右键单击网页,然后单击“查看源代码”这将显示浏览器用来呈现页面的底层 HTML。有时候格式会稍微好一点…

如果我们用类似于export PHP_ENV=prod && php -S 127.0.0.1:8000的命令重启服务器并刷新浏览器页面,我们会看到响应发生了变化。

您可能需要在操作系统中以不同的方式导出环境变量。稍后,我们将创建一个适用于任何机器的系统。

每个框架都使用这些全局变量来决定应该触发哪些功能,应该显示哪些页面。通常,根据代码运行的环境,会有一组不同的变量。

例如,当代码在您的家庭计算机上运行时,您可能不希望它访问生产数据库。您将希望使用与您的家庭计算机相匹配的数据库凭据。

不同的框架(甚至 web 服务器)都有办法将这些变量放入 PHP。有些人更喜欢在启动服务器时导出变量的方式。有些使用特殊命名的文件(如.env.env.production),他们使用这些不同的文件名来确定在当前环境中应该加载哪个文件。

我们将在第十一章探索这些加载环境变量的方法。

PHP 还提供了对变量(或上下文)的访问,这些变量与通过脚本的请求有关。特别有启发性的变量是那些告诉我们使用了哪个请求方法以及请求了什么路径或 URL 的变量:

$requestMethod = $_SERVER['REQUEST_METHOD'] ?? 'GET';
$requestPath = $_SERVER['REQUEST_URI'] ?? '/';

if ($requestMethod === 'GET' and $requestPath === '/') {
    print 'hello world';
} else {
    print '404 not found';
}

如果您在浏览器中打开http://0.0.0.0:8000,您应该会看到“hello world”消息。将地址更改为其他地址(如http://0.0.0.0:8000/missing),您应该会看到“404 not found”消息。

当然,我们可以退回到 web 服务器通常提供的基于文件的路由,但它没有这种方法有用或具体。我们可以根据触发反应的具体情况来定制反应。

这里,我使用的是and关键字,而不是常用的&&操作符。在这种情况下,它在语义上没有什么不同,但对我来说,它读起来更清楚。

用 HTML 响应

在回应的方式中,我们能表现出什么?

我们想要什么都行,真的。通常,网站会为简单的请求返回 HTML。它们也可以返回 JSON 或 XML 或可下载的文件。我们可以直接返回 HTML,也可以通过包含文件的方式返回:

$requestMethod = $_SERVER['REQUEST_METHOD'] ?? 'GET';
$requestPath = $_SERVER['REQUEST_URI'] ?? '/';

if ($requestMethod === 'GET' and $requestPath === '/') {
    print <<<HTML
        <!doctype html>
        <html lang="en">
            <body>
                hello world
            </body>
        </html>
    HTML;
} else {
    include(__DIR__ . '/includes/404.php');
}

我在这里使用的多行字符串称为 Heredoc。它们对 PHP 来说并不陌生,但是新的是你可以像我在这里做的那样缩进它们。直到最近,除了第一行之外,所有的 Heredoc 字符串都需要紧靠文件的左侧。

??语法意味着如果左边的东西是null或未定义的,那么使用右边的东西。

我选择使用单引号和三重等号。如果你熟悉 PHP,你应该知道这些意味着什么。我认为它们是很好的风格选择。如果我们需要插值类型强制,我们可以将它们作为代码库一般规则的例外。

虽然可以在 HTML 之间混合 PHP 代码块,但这是非常不规则的,会导致混乱的代码库。稍后,我们将学习如何制作我们自己的模板引擎,它会做到这一点,但以一种我们不需要看到或使用的方式。

目前,最好的方法是直接输出数据(HTML、JSON 等)。)或者像我在这里所做那样包含输出。不用担心这个是否优雅。这只是第一步!

重定向到另一个 URL

有时候,一个成功的响应并不意味着向浏览器发送一点 HTML。有时候重点是重定向到其他地方。假设你决定将一个网址从/info改为/contact,但是你不想断开所有人已经加了书签的/info的链接。

在这种情况下,您可以更改 URL,但保持通常所说的重定向 URL 指向新的 URL。这样,当人们转到旧网址时,他们将被转到新网址。

这通常使用一个特殊的 HTTP 头来完成,浏览器应该正确地将它解释为“该页面已经移动”:

$requestMethod = $_SERVER['REQUEST_METHOD'] ?? 'GET';
$requestPath = $_SERVER['REQUEST_URI'] ?? '/';

if ($requestMethod === 'GET' and $requestPath === '/') {
    print <<<HTML
        <!doctype html>
        <html lang="en">
            <body>
                hello world
            </body>
        </html>
    HTML;
} else if ($requestPath === '/old-home') {
    header('Location: /', $replace = true, $code = 301);
    exit;
} else {
    include(__DIR__ . '/includes/404.php');
}

通过这段代码,当用户访问/old-home路径时,他们将被重定向到/路径。301意味着浏览器应该记住这是一个永久的重定向。如果重定向只是暂时的,您可以使用302

[我需要等待命名参数 roc 投票结束,然后再处理下一章的反馈。PHP 可能突然支持命名参数…]

我写过$replace = true, $code = 301。PHP 不会突然支持命名参数——它只是一种注释这些值的含义的简洁方式。如果我告诉你第二个参数值应该是true,如果不去查阅文档,你不会知道这个值意味着什么。

这是赋值,所以你绝对不应该重复使用你已经拥有或者打算使用的变量的名字。或者,创建您自己的函数,这些函数清楚它们的参数是什么意思,或者提供好的缺省值。

不使用header函数,我们可以创建自己的重定向函数:

$requestMethod = $_SERVER['REQUEST_METHOD'] ?? 'GET';
$requestPath = $_SERVER['REQUEST_URI'] ?? '/';

function redirectForeverTo($path) {
    header("Location: {$path}", $replace = true, $code = 301);
    exit;
}

if ($requestMethod === 'GET' and $requestPath === '/') {
    print <<<HTML
        <!doctype html>
        <html lang="en">
            <body>
                hello world
            </body>
        </html>
    HTML;
} else if ($requestPath === '/old-home') {
    redirectForeverTo('/');
} else {
    include(__DIR__ . '/includes/404.php');
}

终止脚本执行也很重要(在本例中是通过调用exit函数),因为仅仅是header函数并不能完成交易。它仍然可以在设置后输出内容,甚至用另一个Location标题替换它。当您计划重定向时,请确保这是脚本停止运行前您做的最后一件事。

您还可以使用die函数来终止脚本执行。

显示错误页面

信不信由你,我们已经处理过一个常见的错误场景:找不到页面。实际上,知道用户是否应该看到 404 错误页面的唯一方法是首先检查他们是否应该看到站点中的其他页面。

但是如果错误不是因为他们寻找的页面丢失了呢?如果是代码库出了问题怎么办?

还有另外三种常见的错误:

  1. URL 是对的,但是请求方法是错的。

  2. URL 和请求方法是正确的,但是代码中有一个错误。

  3. URL 和请求方法是正确的,但是在其他一些请求参数中有错误,比如表单输入值。

为了处理第一种情况,我们需要跟踪所有可能的 URL 以及它们所允许的请求方法:

$routes = [
    'GET' => [
        '/' => fn() => print
            <<<HTML
                <!doctype html>
                <html lang="en">
                    <body>
                        hello world
                    </body>
                </html>
            HTML,
        '/old-home' => fn() => redirectForeverTo('/'),
    ],
    'POST' => [],
    'PATCH' => [],
    'PUT' => [],
    'DELETE' => [],
    'HEAD' => [],
    '404' => fn() => include(__DIR__ . '/includes/404.php'),
    '400' => fn() => include(__DIR__ . '/includes/400.php'),
];

// this combines all the paths (for all request methods)
// into a single array, so we can quickly see if a path
// exists in any of them
$paths = array_merge(
    array_keys($routes['GET']),
    array_keys($routes['POST']),
    array_keys($routes['PATCH']),
    array_keys($routes['PUT']),
    array_keys($routes['DELETE']),
    array_keys($routes['HEAD']),
);

if (isset(
    $routes[$requestMethod],
    $routes[$requestMethod][$requestPath],
)) {
    $routes[$requestMethod][$requestPath]();
} else if (in_array($requestPath, $paths)) {
    // the path is defined, but not for this request method;
    // so we show a 400 error (which means "Bad Request")
    $routes['400']();
} else {
    // the path isn't allowed for any request method
    // which probably means they tried a url that the
    // application doesn't support
    $routes['404']();
}

这里发生了很多事情。

浏览器可以使用不同的方法与 web 服务器交互。对于“只是阅读”网站信息,他们通常会发送一个GET请求。对于向网站“发送”信息(比如填表),他们通常会发送一个POST方法。

现在理解其中的机制并不是非常重要,但是您应该知道您的应用将需要处理这些不同的请求方法。随着我们开始以不同的方式与 web 服务器进行通信,我们将会更加了解这些方法是如何工作的(以及它们的不同之处)。第五章和第六章处理发送不同种类的请求。

我们没有在不断扩展的 if 语句中声明可能的路径,而是预先定义了路径。我们没有为从POSTHEAD的请求定义任何路由,但是我们可能确实希望允许这些请求方法。

我们还需要生成一个可能路径的列表,这样我们就可以判断某人是否使用了正确的路径(或 URL)但使用了错误的请求方法。如果 URL 和请求方法都不正确,我们可以退回到旧的“404”行为。

我们可以用类似于curl -X POST http://0.0.0.0:8000/的命令来测试400错误。cURL 是一个系统实用程序,常见于 Unix 和 Linux 系统,可以向远程服务器发出请求。这里,我们要求它请求 home URL,但是使用了一个POST请求方法,我们知道这个方法会触发400错误。

PHP 语言最近增加的一项功能是我们可以在函数调用中使用尾随逗号,就像我对array_merge的调用一样。在 7.4 之前,尾随逗号会导致致命错误。

另一个最近增加的是我们用来定义路由的短闭包语法。短闭包隐式地返回它们表达式的值,但是因为我们不使用这些返回值,所以我们可以忽略这种行为。

我们还使用throw new Exception作为一个简短闭包的单一表达式。这是 PHP 8.0 的一个新特性。throw关键字现在可以在表达式可以使用的任何地方使用,这对于需要抛出的短闭包非常有用。

我们可以更进一步,通过定义一个 abort 方法来重定向到错误页面,并在框架代码中出现错误的用户数据或错误时使用它:

$routes = [
    'GET' => [
        '/' => fn() => print
            <<<HTML
                <!doctype html>
                <html lang="en">
                    <body>
                        hello world
                    </body>
                </html>
            HTML,
        '/old-home' => fn() => redirectForeverTo('/'),
        '/has-server-error' => fn() => throw new Exception(),
        '/has-validation-error' => fn() => abort(400),
    ],
    'POST' => [],
    'PATCH' => [],
    'PUT' => [],
    'DELETE' => [],
    'HEAD' => [],
    '404' => fn() => include(__DIR__ . '/includes/404.php'),
    '400' => fn() => include(__DIR__ . '/includes/400.php'),
    '500' => fn() => include(__DIR__ . '/includes/500.php'),
];

$paths = array_merge(
    array_keys($routes['GET']),
    array_keys($routes['POST']),
    array_keys($routes['PATCH']),
    array_keys($routes['PUT']),
    array_keys($routes['DELETE']),
    array_keys($routes['HEAD']),
);

function abort($code) {
    global $routes;
    $routes[$code]();
}

set_error_handler(function() {
    abort(500);
});

set_exception_handler(function() {
    abort(500);
});

if (isset(
    $routes[$requestMethod],
    $routes[$requestMethod][$requestPath],
)) {
    $routes[$requestMethod][$requestPath]();
} else if (in_array($requestPath, $paths)) {
    abort(400);
} else {
    abort(404);
}

随着新代码的加入,为了处理服务器和验证错误,我们现在可以应对我们可能遇到的所有最常见的网站错误。

起初,在请求方法旁边有错误代码可能看起来很奇怪;但是你很快就会开始看到(特别是在下一章),在请求方法旁边定义它们是多么的方便…

set_error_handlerset_exception_handler的调用确保了我们的500错误被显示出来,即使发生了我们没有准备好的错误。

PHP 脚本通常会在两种情况下失败。一个是由异常抛出的。异常应该被捕获,以一种可以恢复的方式。

如果没有捕获到异常,那么除了可以在浏览器中显示的默认错误消息之外,set_exception_handler还提供了一种通知方式。

另一方面,错误通常是不可恢复的。set_error_handler是一种类似的通知机制。我们启用了这两种方式,因此我们可以为每种情况显示定制的 HTML 页面。

设置自定义错误处理程序或异常处理程序会禁用默认的错误标题。这可能不是您想要的,但是您可以通过将它们添加回您的abort函数来重新启用这些头:

function abort($code) {
    global $routes;
    header('HTTP/1.1 500 Internal Server Error');
    $routes[$code]();
}

摘要

没有真正尝试,我们已经做了一个相当健壮的路由代码。路由实际上是我们下一章的主题。

我们将把所有这些代码打包到一个类中,这个类将记住我们的路由,并根据请求方法和路径决定匹配和执行哪个路由。

在后面的章节中,我们还将看看如何改进我们在本章开始的模板化。

我对我们所取得的成就感到非常高兴,我期待着这个代码库的发展以及我们对 PHP 8 中 MVC 的理解。

三、构建路由

在最后一章中,我们整理了基本路由的代码。是时候以一种我们可以重用和扩展的方式包装这些代码了。

路由的用途是什么

在我们能够构建一个好的路由之前,我们需要尝试并理解这个问题。当 PHP 最初出现时,应用通常严重依赖 web 服务器提供的基于文件的路由。

基于文件的路由是指网站响应的每个 URL 都有不同的文件。假设你有一个webroot/pages/edit-page.php文件;基于文件的路由会将其公开为 http://your-website.com/pages/edit-page.php

换句话说,应用的结构(这反映在它们的 URL 中)与文件系统的布局相匹配。

这限制了您在设置网站 URL 时的灵活性:

  • 如果不在文件系统中移动文件,就不能更改 URL。

  • 你不能将 URL 的一部分存储在数据库中,比如某篇博文或某个产品的标识符。

  • 在将 URL 从一种形式更改为另一种形式时,您会受到 web 服务器配置系统的约束。

  • 因此,在更大的公司里,你改变 URL 的能力将需要另一个部门的输入。

路由库将这一职责转移到代码库中,在那里我们可以决定网站拥有什么样的 URL:它们何时以及如何响应请求。

一些我们可以建立的功能

我希望我们关注几个核心特性:

  1. 将请求方法和路径与特定路由匹配

  2. 处理我们目前的所有错误

  3. 允许在路线中使用命名参数

  4. 从命名的路由和参数构建 URL

我们已经有了第 1 点和第 2 点的基础,所以挑战是以优雅的方式组织现有的代码。之后,我们可以考虑添加为路由定义必需和可选参数的功能,然后根据一个名称和一组参数重建路由。

把它放在一起

我们写的代码越多,放入的文件越多,就越难找到我们想要改变的东西。每一个流行的 PHP 框架都将文件按照它们的功能或类型组织到文件夹中,这是有原因的。

当你在一个有成百上千个文件的代码库中工作时,一点点的结构会有很大的帮助。

我希望我们为这个框架和应用设计出一个更好的文件夹结构。让我们分离框架和应用代码,并使用名称空间和 Composer 自动加载来加载它们:

{
  "name": "whoosh/website",
  "scripts": {
    "serve": "php -S 127.0.0.1:8000 -t public"
  },
  "autoload": {
    "psr-4": {
      "App\\": "app",
      "Framework\\": "framework"
    }
  },
  "config": {
    "process-timeout": 0
  }
}

这是来自composer.json

在创建这个文件之后,我们需要运行composer dump-autoload,这样 Composer 就会创建包含文件来自动加载我们的类。

除非我们设置了config.process-timeout属性,否则我们的编写器脚本将在 300 秒后终止。更长的超时或者根本没有超时对我们有利,因为只要我们需要它运行,我们的开发服务器就会继续运行。我们运行的所有东西,使用composer run x,都必须服从这个超时。

这意味着我们可以将文件放在appframework文件夹中,并让它们的名称空间反映它们的加载位置。让我们通过创建几个反映主要概念的类来开始我们的路由:

namespace Framework\Routing;

class Router
{
    protected array $routes = [];

    public function add(
        string $method,
        string $path,
        callable $handler
    ): Route
    {
        $route = $this->routes[] = new Route(
            $method, $path, $handler
        );

        return $route;
    }
}

这是来自framework/Routing/Router.php

内部的$routes数组存储了我们使用addRoute方法定义的所有路线。PHP 还不支持类型化数组,但是这种强类型方法是朝着知道数组中有什么的正确方向迈出的一步。

我们也可以构建add方法来接收Route的单个实例,但是这意味着将来使用我们框架的人(包括我们)要做更多的工作。

namespace Framework\Routing;

class Route
{
    protected string $method;
    protected string $path;
    protected $handler;

    public function __construct(
        string $method,
        string $path,
        callable $handler
    )
    {
        $this->method = $method;
        $this->path = $path;
        $this->handler = $handler;
    }
}

这是来自framework/Routing/Route.php

我们将使用一个路由实例作为保存所有路由的对象,而不是向全局数组添加路由。我们正在使用 PHP 7.4 中引入的类型化属性,这样我们的属性应该保存什么类型的数据就很清楚了。

不幸的是,callable 不是属性的有效类型,但是我们可以使用mixed,这意味着没有定义类型。PHP 8.0 中加入了mixed类型。添加mixed并不能让事情变得更好,但是我们可以使用@var callable,这样静态分析工具至少可以在检测到变量/属性类型的问题时警告我们。

当我们了解更多关于测试和工具的知识时,我们将在第九章中看看这些工具。

除了新的appframework文件夹,我还想使用一个public文件夹来存放可公开访问的文件,比如最初的index.php文件:

require_once __DIR__ . '/../vendor/autoload.php';

$router = new Framework\Routing\Router();

// we expect the routes file to return a callable
// or else this code would break
$routes = require_once __DIR__ . '/../app/routes.php';
$routes($router);

print $router->dispatch();

这是来自public/index.php

在这个文件正确运行之前,我们需要做一些事情。首先,我们需要创建一个 routes 文件,并用我们到目前为止创建的路由填充它:

use Framework\Routing\Router;

return function(Router $router) {
    $router->add(
        'GET', '/',
        fn() => 'hello world',
    );

    $router->add(
        'GET', '/old-home',
        fn() => $router->redirect('/'),
    );

    $router->add(
        'GET', '/has-server-error',
        fn() => throw new Exception(),
    );

    $router->add(
        'GET', '/has-validation-error',
        fn() => $router->dispatchNotAllowed(),
    );
};

这是来自app/routes.php

PHP 包含文件可以返回任何东西,包括闭包,这实际上是打包代码的一种简洁方式,否则就需要使用全局变量或服务位置。

服务地点完全是另外一个问题。我们将在第十章中了解更多。

我们经常看到配置返回数组,但很少返回闭包,至少在流行的框架中…

我们还需要给路由添加一个dispatch方法:

public function dispatch()
{
    $paths = $this->paths();

    $requestMethod = $_SERVER['REQUEST_METHOD'] ?? 'GET';
    $requestPath = $_SERVER['REQUEST_URI'] ?? '/';

    // this looks through the defined routes and returns
    // the first that matches the requested method and path
    $matching = $this->match($requestMethod, $requestPath);

    if ($matching) {
        try {
            // this action could throw and exception
            // so we catch it and display the global error
            // page that we will define in the routes file
            return $matching->dispatch();
        }
        catch (Throwable $e) {
            return $this->dispatchError();
        }
    }

    // if the path is defined for a different method
    // we can show a unique error page for it
    if (in_array($requestPath, $paths)) {
        return $this->dispatchNotAllowed();
    }

    return $this->dispatchNotFound();
}

private function paths(): array
{
    $paths = [];

    foreach ($this->routes as $route) {
        $paths[] = $route->path();
    }

    return $paths;
}

private function match(string $method, string $path): ?Route
{
    foreach ($this->routes as $route) {
        if ($route->matches($method, $path)) {
            return $route;
        }
    }

    return null;
}

这是来自framework/Routing/Router.php

分派方法类似于我们之前在index.php中的命令式代码。我们得到所有可能路径的列表,不管它们的方法。

我们做的一件不同的事情是允许路由对象告诉我们它们是否匹配一个方法和路径。这个方法看起来是这样的:

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

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

public function matches(string $method, string $path): bool
{
    return $this->method === $method
        && $this->path === $path;
}

public function dispatch()
{
    return call_user_func($this->handler);
}

这是来自framework/Routing/Route.php

这是一种有趣的、有时有争议的定义 getters 的方式。有些人更喜欢更明确的 setter 和 getter 名称,比如getPathsetPath

我对这两种方式都没有强烈的感觉,但重要的是选择一种方法并坚持到底。路由的dispatch方法还提到了处理每个错误情况的单独方法:

protected array $errorHandler = [];

public function errorHandler(int $code, callable $handler)
{
    $this->errorHandlers[$code] = $handler;
}

public function dispatchNotAllowed()
{
    $this->errorHandlers[400] ??= fn() => "not allowed";
    return $this->errorHandlers[400]();
}

public function dispatchNotFound()
{
    $this->errorHandlers[404] ??= fn() => "not found";
    return $this->errorHandlers[404]();
}

public function dispatchError()
{
    $this->errorHandlers[500] ??= fn() => "server error";
    return $this->errorHandlers[500]();
}

public function redirect($path)
{
    header(
        "Location: {$path}", $replace = true, $code = 301
    );
    exit;
}

这是来自framework/Routing/Router.php

或者零合并赋值操作符,类似于我们在上一章学到的操作符。它说,如果左侧为空或未定义,则左侧应设置为等于右侧。

这不仅允许我们为错误状态定义定制的“路线”,而且创建了一组缺省值,这些缺省值在没有任何配置的情况下都是有用的。我们可以覆盖 404 页面的错误处理程序,例如:

$router->errorHandler(404, fn() => 'whoops!');

这是来自app/routes.php

将命名的路线参数添加到组合中

我们已经有了一套不错的功能,可以处理所有类型的 URL 和我们上一章处理的方法。现在,是时候更上一层楼了。

web 应用响应用动态数据构建的 URL 是很常见的。当你进入一个社交媒体网站并点击一张个人资料图片时,你可能会被带到该用户的公共个人资料页面——在 URL 中有他们唯一的名称。

例如,我的 Twitter 个人资料有一个 URL https://twitter.com/assertchrisassertchris是 URL 的动态部分,因为它对其他用户是不同的。

不同的框架对这种 URL 段有不同的称呼,但是我们将满足于称它为命名路由参数。“命名”是因为我们想在我们的应用中获取数据,我们通过引用它的名字来实现。

我们将从命名路由参数的简单实现开始,并随着 Whoosh 网站的发展需要对其进行改进。我们命名的路由参数有两种形式:

  • /products/{product}:路由期望给product一个值

  • /services/{service?}:路由将接受service的值,但这是可选的,没有它 URL 仍然可以工作

    大多数路由允许命名路由参数的模式匹配,使用正则表达式或 DSL 我们的路由将允许任何非正斜杠字符。把扩展我们编写的代码来处理模式看作是一个有趣的挑战。

以下是我们如何定义和处理这些路由的示例:

$router->add(
    'GET', '/products/view/{product}',
    function () use ($router) {
        $parameters = $router->current()->parameters();
        return "product is {$parameters['product']}";
    },
);

$router->add(
    'GET', '/services/view/{service?}',
    function () use ($router) {
        $parameters = $router->current()->parameters();

        if (empty($parameters['service'])) {
            return 'all services';
        }

        return "service is {$parameters['service']}";
    },
);

这是来自app/routes.php

假设我们在匹配路由的处理程序中(我将开始调用这些操作),我们应该能够访问当前路由的详细信息。这些细节中的一些可能是与路线匹配的命名路线参数。

这意味着我们需要在我们的Router中定义新的属性和方法:

protected Route $current;

public function current(): ?Route
{
    return $this->current;
}

这是来自framework/Routing/Router.php

这与我们的其他 getters 和 setters 遵循相同的命名方案,只是我们限制了当前路由的设置方式。外界的东西能够选择当前的路线是没有意义的。

路由匹配代码变化很大:

protected array $parameters = [];

public function parameters(): array
{
    return $this->parameters;
}

public function matches(string $method, string $path): bool
{
    // if there's a literal match then don't waste
    // any more time trying to match with
    // a regular expression
    if (
        $this->method === $method
        && $this->path === $path
    ) {
        return true;
    }

    $parameterNames = [];

    // the normalisePath method ensures there's a '/'
    // before and after the path, while also
    // removing duplicate '/' characters
    //
    // examples:
    // → '' becomes '/'
    // → 'home' becomes '/home/'
    // → 'product/{id}' becomes '/product/{id}/'
    $pattern = $this->normalisePath($this->path);

    // get all the parameter names and replace them with
    // regular expression syntax, to match optional or
    // required parameters
    //
    // examples:
    // → '/home/' remains '/home/'
    // → '/product/{id}/' becomes '/product/([^/]+)/'
    // → '/blog/{slug?}/' becomes '/blog/([^/]*)(?:/?)'
    $pattern = preg_replace_callback(
        '#{([^}]+)}/#',
        function (array $found) use (&$parameterNames) {
            array_push(
                $parameterNames, rtrim($found[1], '?')
            );

            // if it's an optional parameter, we make the
            // following slash optional as well
            if (str_ends_with($found[1], '?')) {
                return '([^/]*)(?:/?)';
            }

            return '([^/]+)/';
        },
        $pattern,
    );

    // if there are no route parameters, and it
    // wasn't a literal match, then this route
    // will never match the requested path
    if (
        !str_contains($pattern, '+')
        && !str_contains($pattern, '*')
    ) {
        return false;
    }

    preg_match_all(
        "#{$pattern}#", $this->normalisePath($path), $matches
    );

    $parameterValues = [];

    if (count($matches[1]) > 0) {
        // if the route matches the request path then
        // we need to assemble the parameters before
        // we can return true for the match
        foreach ($matches[1] as $value) {
            array_push($parameterValues, $value);
        }

        // make an empty array so that we can still
        // call array_combine with optional parameters
        // which may not have been provided
        $emptyValues = array_fill(
            0, count($parameterNames), null
        );

        // += syntax for arrays means: take values from the
        // right-hand side and only add them to the left-hand
        // side if the same key doesn't already exist.
        //
        // you'll usually want to use array_merge to combine
        // arrays, but this is an interesting use for +=
        $parameterValues += $emptyValues;

        $this->parameters = array_combine(
            $parameterNames,
            $parameterValues,
        );

        return true;
    }

    return false;
}

private function normalisePath(string $path): string
{
    $path = trim($path, '/');
    $path = "/{$path}/";

    // remove multiple '/' in a row
    $path = preg_replace('/[\/]{2,}/', '/', $path);

    return $path;
}

这是来自framework/Routing/Route.php

Route类仍然支持我们之前的文字匹配。如果有文字匹配(例如,'/home/' === '/home/'),那么我们就不再浪费时间去匹配正则表达式。

我们添加了一个normalisePath路径方法,将单个/添加到路径的开头和结尾。这使得像''这样的路径有效,因为它们变成了'/'normalisePath方法还确保一行中没有多个/字符。

我们总是试图将一条已知的路径——我们在$_REQUEST全局数组中找到的路径——与一组未知的路径相匹配。我们可以使用以下规则来判断我们是否在处理可能的匹配:

  1. 如果路由有一个简单的路径(如home),并且我们有一个与请求路径匹配的字符串,那么路由就是一个匹配。

  2. 如果路由没有任何参数(我们在路由路径中检查*?的那一点),那么它不可能是匹配的。请记住,我们知道,如果它达到这一点,它不是一个字面上的匹配。

  3. 如果路由路径模式与请求路径匹配(在名称被替换为无名的正则表达式位之后),那么我们可以假设它是匹配的。

    我们可以使用 PHP 8.0 中添加的str_ends_with函数,因为它比其他任何查找最后一个字符的方法都简单。

让我们看几个例子:

  1. 我们可以用路径products/{id}/view定义一条路线。

  2. products/1/view发出请求。

  3. 因为products/{id}/view不是products/1/view的字面匹配,所以我们不会提前退出match

  4. normalisePathproducts/{id}/view变成/products/{id}/view/preg_replace_callback再变成/products/([^/]+)/view/

  5. preg_match_all认为这是与/products/1/view/的匹配。

  6. id参数被赋值为'1'match函数返回true

对于可选参数…

  1. 我们可以定义一条路径为blog/{slug?}的路线。

  2. blog/hello-world发出请求。

  3. normalisePathblog/{slug?}变成/blog/{slug?}/preg_replace_callback再变成/blog/([^/]*)(?:/?)

  4. preg_match_all认为这是与/blog/hello-world/的匹配。

  5. slug参数被赋值为'hello-world'match函数返回true

  6. /blog/的请求也将匹配,但是slug参数将包含一个值null

从命名路由构建 URL

在处理大型应用时,通过名称引用应用的其他部分通常很有用。当我们需要向用户显示 URL 时尤其如此。

假设我们正在构建一个页面,列出 Whoosh 销售的产品。如果我们在一个页面上放不下太多的内容,我们可能需要链接到下一页和上一页。

我们可以对这些 URL 进行硬编码(同时仍然有动态值来表示那些上一页和下一页应该是什么),但这将是大量重复的代码,并且如果我们想要更改 URL,将会增加我们需要更改的地方的数量。

重复代码并不总是不好的。这不是您应该使用命名路由的唯一原因,但这是拥有命名路由的一个好处。

命名的路由可能如下所示:

$router->add(
    'GET', '/products/{page?}',
    function () use ($router) {
        $parameters = $router->current()->parameters();
        $parameters['page'] ??= 1;

        return "products for page {$parameters['page']}";
    },
)->name('product-list');

这是来自app/routes.php

如果我们可以用下面的代码请求此路由的 URL,那就太方便了:

$router->route('product-list', ['page' => 2])

我们必须给Router添加一些代码:

use Exception;

// ...later

public function route(
    string $name,
    array $parameters = [],
): string
{
    foreach ($this->routes as $route) {
        if ($route->name() === $name) {
            $finds = [];
            $replaces = [];

            foreach ($parameters as $key => $value) {
                // one set for required parameters
                array_push($finds, "{{$key}}");
                array_push($replaces, $value);

                // ...and another for optional parameters
                array_push($finds, "{{$key}?}");
                array_push($replaces, $value);
            }

            $path = $route->path();
            $path = str_replace($finds, $replaces, $path);

            // remove any optional parameters not provided
            $path = preg_replace('#{[^}]+}#', '', $path);

            // we should think about warning if a required
            // parameter hasn't been provided...

            return $path;
        }
    }

    throw new Exception('no route with that name');
}

这是来自framework/Routing/Router.php

我们可以使用str_replace函数用提供给这个新的route方法的相应参数替换掉所有命名的路由参数(可选的和必需的)。

如果没有同名的路由,我们可以抛出一个异常让用户知道。我们通常的异常处理将会发生,但是我们将在下一章中尝试更有用的方法来显示异常。

我们仍然需要将那个name方法添加到我们的Route类中:

protected ?string $name = null;

public function name(string $name = null): mixed
{
    if ($name) {
        $this->name = $name;
        return $this;
    }

    return $this->name;
}

这是来自framework/Routing/Route.php

我觉得我们添加到Router中的route方法也可以存在于Route类中,但这对我没有太大的影响。无论如何,这为我们提供了一种方法,只需知道路由名称并提供构建路由所需的任何参数,就可以创建 URL。

专家是如何做到的

理解问题以及我们如何解决它只是构建平衡路由库的一部分。我们将看看几个流行的路由库。

展望未来,我们将回顾我们构建的框架部分的流行开源替代方案。这对于了解我们代码中的边缘情况以及我们需要或不需要的特性是必不可少的。

Symfony 路由

Symfony 是一个流行的 MVC 框架,许多其他框架和项目都使用它的组件。Symfony 有一个很棒的路由,让我们看看它提供的功能。

Symfony 提供了一个类似于我们提出的 PHP 配置:

use App\Controller\BlogController;
use Symfony\Component\Routing\Loader\Configurator\RoutingConfigurator;

return function (RoutingConfigurator $routes) {
    $routes
        ->add('blog_list', '/blog')
        ->controller([BlogController::class, 'list']);
};

他们提到了控制器(我们将在第五章中讲到),但是他们和我们目前使用的闭包相似。他们的add方法需要一个名称作为第一个参数,所以你可以打赌他们提供了从路由名称构建 URL 的方法。

它们还支持将注释作为定义路线的一种方式:

namespace App\Controller;

use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\Routing\Annotation\Route;

class BlogController extends AbstractController
{
    /**
     * @Route("/blog", name="blog_list")
     */
    public function list()
    {
        // ...
    }
}

在它工作之前,还需要做一些额外的设置,但是它绝对是中央路径文件的替代方案。我认为这是一个好主意,可以让路线和与之相关的行为共存。

PHP 8 支持真正的注释(与这些基于注释的注释相反)。我们还没有在路由中添加注释支持,但是这将是我们了解更多注释的一个很好的方式。当我们构建一个对象关系映射器(ORM)时,我们会在第七章中看到更多的注释。

Symfony 的路由还支持命名路由参数,您可以在路由旁边定义默认参数:

$routes
    ->add('blog_list', '/blog/{page}')
    ->controller([BlogController::class, 'list'])
    ->defaults(['page' => 1])
    ->requirements(['page' => '\d+']);

它们允许您定义参数必须遵循的模式(它取代了我们的无所不包的正则表达式段)。

Symfony 也支持路由优先级的概念。我们的路由总是返回它匹配的第一条路由,这可能会导致意想不到的结果。考虑以下定义:

$router->add(
    'GET', '/products/{product}',
    function () use ($router) {
        // ...
    },
)->name('product-view');

$router->add(
    'GET', '/products/{page?}',
    function () use ($router) {
        // ...
    },
)->name('product-list');

按照这个顺序,即使我们试图显示产品列表,产品视图也会首先匹配。此外,我们可能会得到一个错误,因为产品列表的可选参数是页码(不是产品标识符)。

Symfony 的优先级系统有助于解决这个问题,因为它让您给路线更高的优先级,这样当有多条路线匹配时,它可以选择首选路线。

我想说的最后一个特性是路由组。组是定义多条路线共有属性的一种方式。这可能是适用于他们的子域或路径前缀。群超级有用!

关于 Symfony 的路由还有很多需要了解的。您可以在 https://symfony.com/doc/current/routing.html 查看官方文档,在 https://github.com/symfony/routing 查看源代码。

快速路线

FastRoute 是一种较小的独立替代方案。我想更深入地了解它是如何工作的,因为我认为它可以教给我们一些巧妙的技巧。从高层次来说,它支持 Symfony 路由的许多相同功能:

  • 命名路线参数

  • 参数模式匹配

  • 路由组

在表面之下,这是一个非常不同的野兽。它允许自定义解析器实现、缓存实现,甚至不同的正则表达式匹配器实现(取决于匹配的路由类型,是否在一个组中,等等。).

如果我们实现了 FastRoute 所做的那种缓存,我们的路由可以快得多:路由所做的所有正则表达式工作都被缓存到一种中间格式(或文件)中。

如果我们在将路由添加到缓存中时(而不是在请求 URL 时)计算出必需的和可选的参数,我们还可以加快从命名路由创建 URL 的过程。

尽管有这些灵活性,使用 FastRoute 的代码还是相当简洁的:

$dispatcher = FastRoute\simpleDispatcher(
    function(FastRoute\RouteCollector $collector) {
        $collector->addRoute(
            'GET', '/products/{page:\d+}', 'listProducts'
        );
    }
);

$method = $_SERVER['REQUEST_METHOD'];
$path = $_SERVER['REQUEST_URI'];

// we should remove the query string from the URI
// or the match won't work...
if (false !== $pos = strpos($path, '?')) {
    $path = substr($uri, 0, $pos);
}

$path = rawurldecode($path);

$result = $dispatcher->dispatch($method, $path);

switch ($result[0]) {
    case FastRoute\Dispatcher::NOT_FOUND:
        // ...show "not found" page
        break;
    case FastRoute\Dispatcher::METHOD_NOT_ALLOWED:
        // ...show "wrong request method" page
        break;
    case FastRoute\Dispatcher::FOUND:
        $handler = $result[1];
        $params = $result[2];
        // ...call $handler with $params
        break;
}

这提醒我,我们需要一种更好的方法来处理路由中的查询字符串。让我们留到第五章来讨论,当我们看到对 Whoosh 网站的不同请求的验证时。

拉勒维尔和卢蒙

Laravel 是另一个流行的 PHP MVC 框架。在这本书的整个过程中,我们都会提到它。Laravel 背后的人已经创建了一个分支“微”框架,称为 Lumen。

我想提到他们是因为 Laravel 使用 Symfony 的路由,而 Lumen 使用 FastRoute。开发人员的体验——至少就路由而言——在两个框架中几乎是相同的。

这表明使用别人的库是完全合理的(并且有很多好的理由这样做),同时仍然让开发人员体验到自己的体验。

四、构建模板引擎

在上一章中,我们通过将第一部分路由代码重组为一组可重用的类和模式,增加了 Whoosh 的路由功能。现在,是时候把注意力转向呈现更好的界面了。

有许多不同的方法来创建模板。这是一个两极分化的话题,分歧通常归结于呈现 HTML 的代码和其余代码之间的界限。

我不想讨论哪种方法是最好的,因为最终“哪种是最好的?”通常只能用“看情况”来回答相反,我想让你轻松地进入奇妙复杂的方法,花一点时间讨论每种方法的优点。

模板引擎是做什么的?

我想在我们研究如何构建一个模板引擎之前,我们应该先讨论一下模板引擎是做什么的。它们有许多形状和大小。在最高层次上,模板引擎接受 HTML、PHP 或定制语言的片段;他们制作的静态 HTML 可以在任何浏览器中工作。

基本变量字符串模板

最简单的模板是期望简单变量替换的模板。它们类似于这样的代码:

Welcome to Whoosh! You are visitor number {numberOfVisitors}.

我们已经见过这种模板。回想一下我们的路由,当时我们添加了对命名路由参数的支持。这些看起来像这样:

$router->add(
    'GET', '/products/{product}',
    function () use ($router) {
        // ...
    },
)->name('product-view');

这种模板经常被使用,因为用占位符交换变量相对容易。通常情况下,你只需要使用str_replace方法。

HTML 中的 PHP

PHP 从一种简单得多的编写模板的方式开始——这种方式在现代 PHP 中仍然有效:

Welcome to Whoosh!
<?php if($numberOfVisitors > 0): ?>
    You are visitor number <?php print $numberOfVisitors; ?>.
<?php endif; ?>

这种模板允许 PHP 在普通 HTML 之间使用。它比基本的可变字符串模板灵活得多,因为您可以使用 PHP 控制流结构来生成 HTML 的复杂排列。

循环是模板中的常见要求,例如在列出所有可供购买的产品的情况下:

Products:
<ol>
    <?php foreach($products as $product): ?>
        <li><?php print $product->name; ?></li>
    <?php endforeach; ?>
</ol>

在这里,我演示了if + endifforeach + endforeach的替换形式。这些是在纯 PHP 代码中不常使用的关键字,但是它们对于模板非常有用,因为它们表达了嵌套而没有花括号。

复杂变量字符串模板

考虑到基本的可变字符串模板的刚性和 PHP-in-HTML 的灵活性,一些人提出了更高级的字符串模板语言,提供了更多的灵活性。

最古老和最流行的模板库之一叫做 Smarty :

{foreach $foo as $bar}
    <a href="{$product1.href}">{$product1.name}</a>
    <a href="{$product2.href}">{$product2.name}</a>
{foreachelse}
    There are no products.
{/foreach}

与基本的变量字符串模板和 PHP-in-HTML 相比,这些都很熟悉。其他更新的模板引擎遵循类似的模式。Laravel 有一个模板语言叫 Blade,看起来是这样的:

@forelse($products as $product)
    <a href="{{ $product1->href }}">{{ $product1->name }}</a>
    <a href="{{ $product2->href }}">{{ $product2->name }}</a>
@empty
    There are no products.
@endforelse

这些类型的模板引擎通常会将您编写的模板编译成 PHP-in-HTML,因为这是它们呈现最终 HTML 的最有效方式。

复杂编译器

所有流行的模板引擎都倾向于某种定制编译器。Blade 是模板引擎的一个很好的例子,它看起来简单,运行良好,但在幕后也做着相当高级的事情。

定制模板编译器在不同的复杂程度上起作用。最简单的形式采用字符串模板,使用字符串替换和正则表达式,用标准 PHP 和 HTML 替换非标准 PHP 或 HTML。

这种简单的方法在大多数情况下是足够的,甚至对于 HTML 超集语言来说也是如此。让我告诉你我的意思…

在幕后,Blade 正在将其@if语法转换成常规 PHP:

@if (count($records) === 1)
    I have one record!
@else
    I don't have any records!
@endif

...转换为:

<?php if(count($records) === 1): ?>
    I have one record!
<?php else: ?>
    I don't have any records!
<?php endif; ?>

最近,Blade 增加了对不同类型的模板语法的支持。它允许您将可重用的模板定义为组件,您可以像 HTML 元素一样包含它们。

假设我们在resources/views/components文件夹中创建一个模板,名为menu.blade.php:

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

我们可以在另一个模板中使用它

<x-menu />

<x-menu />被重写了很多 PHP 代码,在父模板里面:

<?php if (isset($component)) { $__componentOriginalc254754b9d5db91d5165876f9d051922ca0066f4 = $component; } ?>
<?php $component = $__env->getContainer()->make(Illuminate\View\AnonymousComponent::class, ['view' => 'components.menu','data' => []]); ?>
<?php $component->withName('menu'); ?>
<?php if ($component->shouldRender()): ?>
<?php $__env->startComponent($component->resolveView(), $component->data()); ?>
<?php $component->withAttributes([]); ?>
<?php if (isset($__componentOriginalc254754b9d5db91d5165876f9d051922ca0066f4)): ?>
<?php $component = $__componentOriginalc254754b9d5db91d5165876f9d051922ca0066f4; ?>
<?php unset($__componentOriginalc254754b9d5db91d5165876f9d051922ca0066f4); ?>
<?php endif; ?>
<?php echo $__env->renderComponent(); ?>
<?php endif; ?>

不可否认,这是看起来很可怕的代码,但是它不应该被检查。Blade 通过寻找<x-something />标签并用大量普通 PHP 代码替换它们来实现这一点。

还可以将内容嵌套在组件中,就像处理常规 HTML 一样:

<nav>
  <a href="/">home</a>
  {{ $slot }}
</nav>

…其中$slot是您传递给使用该组件的模板中的元素的任何内容:

<x-menu>
  <a href="/help">help</a>
</x-menu>

如果你最近做过一些 JavaScript 开发,你可能会看到另一种模板:HTML-in-JS。知道这个想法最初来自 PHP 可能会让你感到惊讶。

许多年前,一个叫做 XHP 的 PHP 扩展允许在 PHP 中使用 HTML。当 PHP 7 到来时,XHP 留了下来。它仍然存在于 Hack——PHP 语言的脸书分支。

XHP 电码是这样的:

use namespace Facebook\XHP\Core as x;
use type Facebook\XHP\HTML\{XHPHTMLHelpers, a, form};

final xhp class a_post extends x\element
{
    use XHPHTMLHelpers;

    attribute string href @required;
    attribute string target;

    <<__Override>>
    protected async function renderAsync(): Awaitable<x\node>
    {
        $id = $this->getID();

        $anchor = <a>{$this->getChildren()}</a>;

        $form = (
            <form
                id={$id}
                method="post"
                action={$this->:href}
                target={$this->:target}
                class="postLink">
                {$anchor}
            </form>
        );

        $anchor->setAttribute(
            'onclick',
            'document.getElementById("'.$id.'").submit(); return false;',
        );

        $anchor->setAttribute('href', '#');

        return $form;
    }
}

这直接来自于 XHP 的文档。

Hack 提供了一堆功能,实际上干扰了他们的 XHP 的例子。我想让你看到的主要一点是<a><form>不是字符串,而是字面上的 HTML-in-Hack。

我希望我们建立一个模板引擎,它(至少)支持所有这些的简单实现。我们可能不会把它们构建得功能齐全,但我们肯定会学到一两件关于编写编译器的事情…

一些我们可以建立的功能

让我们试着弄清楚每种模板引擎的主要部分,把较小的(更好理解的)细节留给你以后做。以下是我认为重要的事情:

  • 解析基本变量字符串模板

  • 编写 PHP-in-HTML 模板(包括部分模板)

  • 构建一个简单的编译器,用于字符串和正则表达式替换的控制结构

  • 为 HTML-in-PHP 模板构建一个高级编译器

  • 防止显示可能有害的(XSS、跨站点脚本)内容

最后两个肯定会有漏洞,但我会尽力指出来,不会让你不知所措。这一章有很多内容,所以让我们开始吧…

把它放在一起

如果我们看一下我们的app/routes.php文件,我们会看到我们已经在应用中定义的路线。“主页”路径返回一个普通的字符串,但是我更喜欢它显示更多的 HTML。也许是这样的:

<!DOCTYPE html>
<html lang="en">
  <head>
    <title>Whoosh!</title>
    <link
      rel="stylesheet"
      href="https://unpkg.com/tailwindcss@¹.0/dist/tailwind.min.css"
    />
    <meta charset="utf-8" />
  </head>
  <body>
    <div class="container mx-auto font-sans">
      <h1 class="text-xl font-semibold">Welcome to Whoosh!</h1>
      <p>Here, you can buy {number} rockets.</p>
    </div>
  </body>
</html>

这是来自resources/views/home.basic.php

这是一个基本的模板,你可能会想这就是为什么我给它添加了.basic.php扩展名,但是我这么做还有一个原因,我们稍后会看到。

我添加的唯一奇怪的东西是标准 Tailwind 样式表的 CDN 版本。这与您在下面看到的类名有关。我将使用 Tailwind 进行应用中的大部分样式设计,因为我不想花一分钟来讨论前端构建链。

我们可以想象能够在“主页”路径中使用它,代码如下

$router->add(
    'GET', '/',
    fn() => view('home', ['number' => 42]),
);

这是来自app/routes.php

这引出了两个我想让我们探讨的问题:

  1. 我们如何构建我们的代码,使其易于使用,同时便于以后测试和重构?

  2. 为什么要添加或者省略.basic.php扩展名?

我相信可以同时回答这两个问题。我想设计一系列的类,这些类将包含我们想要探索的所有四种模板引擎思想的解析代码,我们的模板库可以根据扩展选择正确的模板引擎类。

换句话说,我认为我们可以根据我们给文件指定的扩展名,为每个模板选择要应用的模板引擎:

  • home.basic.php将是一个基本的变量字符串模板。

  • home.php将是一个 PHP-in-HTML 模板。

  • home.advanced.php将用于高级变量字符串模板。

  • home.phpx.php将用于自定义编译器模板。

具体的扩展应该没那么重要。它只是想区分不同种类的模板,并减少预先所需的配置,同时增加灵活性——您可以同时使用多个模板引擎,而无需在代码中配置上下文。

我正在研究 Laravel 如何实现基于扩展的引擎选择。这不是做事情的唯一方法,您肯定应该探索替代方法,但是我更想关注引擎如何工作,而不是初始化引擎的不同方法。

构建基本的变量字符串模板引擎

我想我在这个副标题上作弊了,因为我们也要建造这些不同的引擎将生活在其中的结构。我希望我们从我们的全局函数返回到一个丰富的类集合。让我们定义一个新的“助手”文件,并用 Composer 自动加载它:

use Framework\View;

if (!function_exists('view')) {
    function view(string $template, array $data = []): string
    {
        static $manager;

        if (!$manager) {
            $manager = new View\Manager();

            // let's add a path for our views folder
            // so the manager knows where to look for views
            $manager->addPath(__DIR__ . '/../resources/views');

            // we'll also start adding new engine classes
            // with their expected extensions to be able to pick
            // the appropriate engine for the template
            $manager->addEngine('basic.php', new View\Engine\BasicEngine());
        }

        return $manager->render($template, $data);
    }
}

这是来自framework/helpers.php

因为这个函数在全局范围内,所以明智的做法是将view函数包装在function_exists检查中。我遇到过 autoloader 文件加载多次的情况,这个函数只需要加载一次。

如果我们把这个函数放在一个名称空间中,我们可以避免function_exists检查,但是使用这个函数会更加困难。我所做的权衡是,以搞乱全局名称空间为代价,返回一个新的“视图”会更容易。

在第十章中,我们将看到一种更好的方式来“记住”这个$manager实例。现在,我们可以使用静态变量,这只是让 PHP 在函数调用之间记住一些数据的一种方法。如果变量$manager为空,这段代码只会创建一个新的管理器,这种情况只会在第一次调用view函数时发生。

我们开始添加一个引用,指向我们将要存储前几个视图的位置——这是一条我们将在以后发现如何改进的路径——这样视图引擎就知道在哪里寻找视图文件。

我们还添加了对第一个模板引擎的引用——基本的变量字符串模板引擎。它需要以basic.php结尾的模板。我们将在创建引擎时向管理器添加更多的引擎。

你可能想知道为什么我希望我们在.php中结束所有这些文件和扩展名。这是一种习惯,它产生于允许人们查看文件中的 PHP 源代码的危险,而这些文件本不应该呈现为文本。您可以使用您喜欢的任何扩展名,只要这些文件不能通过 web 服务器直接访问。

我们告诉 Composer 通过添加到composer.json来自动加载它:

"autoload": {
  "psr-4": {
    "App\\": "app",
    "Framework\\": "framework"
  },
  "files": [
    "framework/helpers.php"
  ]
},

这是来自composer.json

快速浏览一下命令行,我们应该可以开始了:

composer du

dudump-autoload的简称。这是我们用来告诉 Composer 重新构建自动加载查找表的命令。

现在,继续构建我们刚刚设想的两个类!首先是经理:

namespace Framework\View;

use Exception;
use Framework\View\Engine\Engine;

class Manager
{
    protected array $paths = [];
    protected array $engines = [];

    public function addPath(string $path): static
    {
        array_push($this->paths, $path);
        return $this;
    }

    public function addEngine(string $extension, Engine $engine): static
    {
        $this->engines[$extension] = $engine;
        return $this;
    }

    public function render(string $template, array $data = []): string
    {
        // render the template...
    }
}

这是来自framework/View/Manager.php

您可能已经猜到了,我们将路径和引擎存储在数组中。我不打算为删除路径或引擎的方法费心,因为我认为它们很容易自己想出来。

类似地,我们可能希望能够为单个引擎支持多个扩展。我们必须添加另一个数组或者改变现有的$engines数组的结构,以便为单个引擎实例存储多个扩展。

如果您对最后一个任务感兴趣,您可能想查看一下 SplObjectStorage 类。它允许一种类似数组的结构,你可以把对象想象成“键”您可以将引擎用作键,将文件扩展名用作值。另一个选择是让引擎告诉我们它是否支持扩展,也许是通过一个$extension->supports($path)方法。

在我们进入render方法需要做什么之前,让我们看看基本的变量字符串模板引擎类可能是什么样子的:

namespace Framework\View\Engine;

class BasicEngine implements Engine
{
    public function render(string $path, array $data = []): string
    {
        $contents = file_get_contents($path);

        foreach ($data as $key => $value) {
            $contents = str_replace(
                '{'.$key.'}', $value, $contents
            );
        }

        return $contents;
    }
}

这是来自framework/View/Engine/BasicEngine.php

BasicEngine获取文件的路径并获取文件内容。对于所提供的数据中的每个键+值对,它进行字符串替换,因此给定['some_data' => 'hello'],用“hello”替换{some_data}

我认为不插入$key会让代码更清晰一点——否则,阅读代码的人可能会被对str_replace的调用中的{{$key}}弄糊涂。

Engine接口确保每个引擎都有Manager要求的方法:

namespace Framework\View\Engine;

interface Engine
{
    public function render(string $path, array $data = []): string;
}

这是来自framework/View/Engine/Engine.php

现在,让我们看看如何选择这个引擎(基于模板扩展)并让它呈现我们的“home”模板:

public function render(string $template, array $data = []): string
{
    foreach ($this->engines as $extension => $engine) {
        foreach ($this->paths as $path) {
            $file = "{$path}/{$template}.{$extension}";

            if (is_file($file)) {
                return $engine->render($file, $data);
            }
        }
    }

    throw new Exception("Could not render '{$view}'");
}

这是来自framework/View/Manager.php

构建 PHP-in-HTML 引擎

接下来,我们将构建一个引擎,在 HTML 中使用常规 PHP,同时在上面添加一些有用的工具。我想添加的主要工具有

  • 避免 XSS 危险

  • 扩展布局模板

  • 包括部分模板

  • 添加一种用“宏”扩展模板的方法

让我们从创建和注册新引擎开始:

namespace Framework\View\Engine;

class PhpEngine implements Engine
{
    protected string $path;

    public function render(string $path, array $data = []): string
    {
        $this->path = $path;

        extract($data);

        ob_start();
        include($this->path);
        $contents = ob_get_contents();
        ob_end_clean();

        return $contents;
    }
}

这是来自framework/View/Engine/PhpEngine.php

这个引擎的核心机制是(1)使用extract函数提取数据和(2)缓冲包含文件的输出。

包含的脚本可以访问在相同范围内定义的变量。我们希望能够用附加数据调用view函数;extract获取数组的键+值,并将它们定义为render方法范围内的变量。

除了$path,我们不需要太担心覆盖已经定义的变量,因为提供的$data可以定义一个同名的变量。如果我给一个视图一个“path”值,它就变成了模板的路径,这将会令人困惑。

这个属性是一个临时的解决方案。我们将很快重构它。

我们可以将路径值复制到一个临时属性中,这样消费者就可以定义任意数量的变量,而不会发生冲突。

我们需要在助手中注册这个引擎:

function view(string $template, array $data = []): string
{
    static $manager;

    if (!$manager) {
        $manager = new View\Manager();

        // let's add a path for our views folder
        // so the manager knows where to look for views
        $manager->addPath(__DIR__ . '/../resources/views');

        // we'll also start adding new engine classes
        // with their expected extensions to be able to pick
        // the appropriate engine for the template
        $manager->addEngine('basic.php', new View\Engine\BasicEngine());
        $manager->addEngine('php', new View\Engine\PhpEngine());
    }

    return $manager->render($template, $data);
}

这是来自framework/helpers.php

PHP 引擎应该最后注册,因为Manager类返回第一个扩展名匹配。如果你的扩展都是独一无二的,那么你就不需要担心这个问题。但是,如果您使用的是我建议的相同扩展,那么”。php“可以在“. basic.php”之前匹配”,这可能是错误的模板引擎…

有了这些,我们可以创建一个类似如下的模板:

<h1>Product</h1>
<p>
    This is the product page for <?php print $product; ?>.
</p>

这是来自resources/views/products/view.php

该视图应通过产品视图路径加载:

$router->add(
    'GET', '/products/view/{product}',
    function () use ($router) {
        $parameters = $router->current()->parameters();

        return view('products/view', [
            'product' => $parameters['product'],
        ]);
    },
);

这是来自app/routes.php

我们再次省略了扩展,它使用 PHP-in-HTML 引擎选择并呈现适当的模板。让我们进入下一个我想添加的功能——避免 XSS 危险。

XSS(或跨站点脚本)是一个漏洞的名称,用户可以向站点提交自己的内容,其中包含 JavaScript,然后在应用中重新呈现。

如果我在我的博客上建了一个评论区,并允许人们提交他们自己的评论,大多数时候他们会用一些文字告诉我我的话有多愚蠢。在某些情况下,读者可能别有用心,提交包含脚本标签的评论。

这些脚本标签可以做许多事情,从使弹出窗口被其他用户看到到窃取登录会话细节并将它们发送到远程服务器。

漏洞不是在他们提交脚本标签时发生的,而是在我盲目地将他们的评论重新呈现到浏览器可以执行脚本标签的地方时发生的。

为了避免这个问题,我们可以提供一个助手来在数据被重新渲染时对其进行转义:

protected function escape(string $content): string
{
    return htmlspecialchars($content, ENT_QUOTES);
}

这是来自framework/View/Engine/PhpEngine.php

htmlspecialchars将转换 HTML 标签的尖括号,因此<script>变成了<script>——这意味着脚本将显示为文本。现在,我们可以重新渲染可怕的数据,而无需对其进行评估:

$router->add(
    'GET', '/products/view/{product}',
    function () use ($router) {
        $parameters = $router->current()->parameters();

        return view('products/view', [
            'product' => $parameters['product'],
            'scary' => '<script>alert("boo!")</script>',
        ]);
    },
);

这是来自app/routes.php

该助手可以在模板中使用:

<h1>Product</h1>
<p>
    This is the product page for <?php print $parameters['product']; ?>.
    <?php print $this->escape($scary); ?>
</p>

这是来自resources/views/products/view.php

不信我说吓人的数据吓人?移除escape方法调用,看看会发生什么。这种逃避的方法真的很受欢迎,而且理由很充分。我建议您一直进行转义,我们构建的下一个引擎默认会这样做。

这个引擎工作得很好,但是扩展布局模板呢?我们不想重复整个 HTML 文档或重复的代码,如菜单和页脚…

我们可以从向PhpEngine添加另一个助手方法开始:

namespace Framework\View\Engine;

class PhpEngine implements Engine
{
    protected string $path;
    protected ?string $layout;
    protected string $contents;

    // ...

    protected function extends(string $template): static
    {
        $this->layout = $template;
        return $this;
    }
}

这是来自framework/View/Engine/PhpEngine.php

我们可以使用它在我们的产品视图模板中存储对我们想要扩展的布局模板的引用:

<?php $this->extends('layouts/products'); ?>
<h1>Product</h1>
<p>
    ...
</p>

这是来自resources/views/products/view.php

布局看起来类似于我们之前创建的“主页”模板:

<!doctype html>
<html lang="en">
    <head>
        <title>Whoosh! Products</title>
        <link rel="stylesheet" href="https://unpkg.com/tailwindcss@¹.0/dist/tailwind.min.css" />
        <meta charset="utf-8" />
    </head>
    <body>
        <div class="container mx-auto font-sans">
            <?php print $this->contents; ?>
        </div>
    </body>
</html>

这是来自resources/views/layouts/products.php

然后,我们需要更改render方法,以考虑使用布局:

public function render(string $path, array $data = []): string
{
    $this->path = $path;

    extract($data);

    ob_start();
    include($this->path);
    $contents = ob_get_contents();
    ob_end_clean();

    if ($this->layout) {
        $__layout = $this->layout;

        $this->layout = null;
        $this->contents = $contents;

        $contentsWithLayout = view($__layout, $data);

        return $contentsWithLayout;
    }

    return $contents;
}

这是来自framework/View/Engine/PhpEngine.php

这看起来有点奇怪。构建中等复杂程度的模板引擎时,可以用不同的方式处理渲染过程:

  • 模板以路径字符串的形式出现,以 HTML 字符串的形式返回。

  • 模板以路径字符串的形式出现,并以对象的形式返回,这些对象可以呈现为 HTML 字符串

  • 模板以“模板”对象的形式出现,并以可以呈现为 HTML 字符串的对象的形式返回。

我们选择了第一种方法,因为这是最简单、最快速的构建方法,但也不是没有缺点。我们可以看到的最大缺点是布局在引擎中是临时链接的。换句话说,你要调用模板内部的extends方法,你给的布局名是唯一可以用共享引擎实例表达的布局。

我们的view函数确保一次只有一个管理器和一个引擎实例(每种类型)在内存中。这意味着它不能一次在内存中保存多个布局属性,这意味着每次调用view函数(或嵌套调用)只能使用一个布局模板。

如果我们将 PhpEngine 的输入或输出表示为一个“视图对象”,那么我们可以将每个模板实例的布局存储在每个模板实例中。我们不需要将$this->layout存储在$__layout中并清除它。对于这种实现,我们必须这样做,否则服务器会因无限递归而崩溃。

通过将$this->layout传递给view而不立即将其设置为null,您可以从递归中看到这种崩溃。

让我们看看如何在没有 gross $__layout变量的情况下解决这个问题,以及如何允许多种布局…

首先,我们需要创建一个新的“视图对象”类:

namespace Framework\View;

use Framework\View\Engine\Engine;

class View
{
    public function __construct(
        protected Engine $engine,
        public string $path,
        public array $data = [],
    ) {}

    public function __toString()
    {
        return $this->engine->render($this);
    }
}

这是来自framework/View/View.php

这是一个有趣的转变,因为这意味着模板只有在被转换成字符串时才会被转换成 HTML 格式——根据需要。PHP 8 引入了直接从构造函数签名设置属性的能力。我不太喜欢它,但是知道它是有益的。我不太可能继续使用这种模式。

render方法签名的这种改变需要在我们目前拥有的引擎中推广。让我们从Engine接口和BasicEngine类开始:

namespace Framework\View\Engine;

use Framework\View\View;

interface Engine
{
    // public function render(string $path, array $data = []): string;
    public function render(View $view): string;
}

这是来自framework/View/Engine/Engine.php

我将替换的行作为注释留下,这样更容易理解发生了什么变化。不过,我会很快删除这些评论…

BasicEngine类也需要改变:

namespace Framework\View\Engine;

use Framework\View\View;

class BasicEngine implements Engine
{
    // public function render(string $path, array $data = []): string
    public function render(View $view): string
    {
        // $contents = file_get_contents($path);
        $contents = file_get_contents($view->path);

        // foreach ($data as $key => $value) {
        foreach ($view->data as $key => $value) {
            $contents = str_replace(
                '{'.$key.'}', $value, $contents
            );
        }

        return $contents;
    }
}

这是来自framework/View/Engine/BasicEngine.php

这个类的工作方式并没有很大的改变。主要是,我们从视图对象获取数据,而不是方法调用签名。这只是意味着我们可以在视图实例中存储与每个单独视图实例相关的信息,而不是依赖于临时链接的方法调用。

view助手现在也需要返回新的视图实例:

use Framework\View;

if (!function_exists('view')) {
    function view(string $template, array $data = []): View\View
    {
        static $manager;

        if (!$manager) {
            // ...
        }

        // return $manager->render($template, $data);
        return $manager->resolve($template, $data);
    }
}

这是来自framework/helpers.php

我们不是在调用这个函数的时候呈现模板,而是将Manager改为返回一个View对象:

public function resolve(string $template, array $data = []): View
{
    foreach ($this->engines as $extension => $engine) {
        foreach ($this->paths as $path) {
            $file = "{$path}/{$template}.{$extension}";

            if (is_file($file)) {
                return new View($engine, realpath($file), $data);
            }
        }
    }

    throw new Exception("Could not resolve '{$template}'");
}

这是来自framework/View/Manager.php

有了这些更改,您应该能够转到主页,它看起来应该和以前一模一样。关于我们如何处理视图的这一变化,最有趣的部分发生在PhpEngine类中:

namespace Framework\View\Engine;

use Framework\View\View;
use function view;

class PhpEngine implements Engine
{
    // protected string $path;
    // protected ?string $layout;
    // protected string $contents;

    protected $layouts = [];

    // public function render(string $path, array $data = []): string
    public function render(View $view): string
    {
        // $this->path = $path;

        // extract($data);
        extract($view->data);

        ob_start();
        // include($this->path);
        include($view->path);
        $contents = ob_get_contents();
        ob_end_clean();

        // if ($this->layout) {
        if ($layout = $this->layouts[$view->path] ?? null) {
            // $__layout = $this->layout;

            // $this->layout = null;
            // $view->contents = $contents;

            // $contentsWithLayout = view($__layout, $data);
            $contentsWithLayout = view($layout, array_merge(
                $view->data,
                ['contents' => $contents],
            ));

            return $contentsWithLayout;
        }

        return $contents;
    }

    protected function escape(string $content): string
    {
        return htmlspecialchars($content);
    }

    protected function extends(string $template): static
    {
        // $this->layout = $template;
        $backtrace = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 1);
        $this->layouts[realpath($backtrace[0]['file'])] = $template;
        return $this;
    }

    protected function includes(string $template, $data = []): void
    {
        print view($template, $data);
    }
}

这是来自framework/View/Engine/PhpEngine.php

概括地说,这些是我们已经改变的事情:

  1. 为了呈现视图,我们存储在属性中的数据都不存在于View类之外。

  2. 当“注册”模板的布局时,我们检查调用这个方法的文件,并将布局模板名称分配给一个布局数组。这有点不可思议,但是它允许我们继续调用$this->layout,而不需要其他的魔法将布局值存储在View对象中。DEBUG_BACKTRACE_IGNORE_ARGS1有助于将回溯限制到它所能包含的最少信息量。

  3. 当我们呈现一个视图时,我们检查在PhpEngine->layouts属性中是否有一个现有的布局。

删除了前面的代码注释后,看起来没有那么混乱了:

namespace Framework\View\Engine;

use Framework\View\View;
use function view;

class PhpEngine implements Engine
{
    protected $layouts = [];

    public function render(View $view): string
    {
        extract($view->data);

        ob_start();
        include($view->path);
        $contents = ob_get_contents();
        ob_end_clean();

        if ($layout = $this->layouts[$view->path] ?? null) {
            $contentsWithLayout = view($layout, array_merge(
                $view->data,
                ['contents' => $contents],
            ));

            return $contentsWithLayout;
        }

        return $contents;
    }

    // ...
}

这是来自framework/View/Engine/PhpEngine.php

在我们继续之前,让我们看一下这段看起来像舞台的代码:

if ($layout = $this->layouts[$view->path] ?? null)

这是一种更简洁的写法:

if (isset($this->layouts[$view->path])) {
    $layout = $this->layouts[$view->path];

我不确定优化是不是更好,但是我希望我们探索现代赋值和比较语法的各种用途。

我想探索的这个引擎的最后一个特性是用“宏”扩展引擎的能力宏是可重用的、有用的函数,我们可以在模板的上下文中访问它们。例如,我们可以将escape定义为一个宏,而不是一个内置的引擎方法:

use Framework\View;

if (!function_exists('view')) {
    function view(string $template, array $data = []): View\View
    {
        static $manager;

        if (!$manager) {
            // ...

            // how about macros? let's add them here for now
            $manager->addMacro('escape', fn($value) => htmlspecialchars($value));
        }

        return $manager->resolve($template, $data);
    }
}

这是来自framework/helpers.php

这意味着我们需要增加Manager存储宏的能力:

namespace Framework\View;

use Closure;
use Exception;
use Framework\View\Engine\Engine;
use Framework\View\View;

class Manager
{
    protected array $paths = [];
    protected array $engines = [];
    protected array $macros = [];

    // ...

    public function addMacro(string $name, Closure $closure): static
    {
        $this->macros[$name] = $closure;
        return $this;
    }

    public function useMacro(string $name, ...$values)
    {
        if (isset($this->macros[$name])) {

            // we bind the closure so that $this
            // inside a macro refers to the view object
            // which means $data and $path can be used
            // and you can get back to the $engine...
            $bound = $this->macros[$name]->bindTo($this);

            return $bound(...$values);
        }

        throw new Exception("Macro isn't defined: '{$name}'");
    }
}

这是来自framework/View/Manager.php

因为我们要将宏存储在Manager中,所以我们需要一种方法让每个引擎获得它们。我们给Engine接口添加一个setManager方法,这样引擎就可以使用那个属性来获取宏,怎么样?

namespace Framework\View\Engine;

use Framework\View\Manager;
use Framework\View\View;

interface Engine
{
    // public function render(string $path, array $data = []): string;
    public function render(View $view): string;
    public function setManager(Manager $manager): static;
}

这是来自framework/View/Engine/Engine.php

我们可以给每个引擎添加这个方法和相应的属性,或者我们可以使用一个特征来做同样的事情:

namespace Framework\View\Engine;

use Framework\View\Manager;

trait HasManager
{
    protected Manager $manager;

    public function setManager(Manager $manager): static
    {
        $this->manager = $manager;
        return $this;
    }
}

这是来自framework/View/Engine/HasManager.php

然后,我们需要将这一特性添加到我们的每个引擎中:

namespace Framework\View\Engine;

use Framework\View\Engine\HasManager;
use Framework\View\View;

class BasicEngine implements Engine
{
    use HasManager;

    // ...
}

这是来自framework/View/Engine/BasicEngine.php

namespace Framework\View\Engine;

use Framework\View\Engine\HasManager;
use Framework\View\View;
use function view;

class PhpEngine implements Engine
{
    use HasManager;

    // ...
}

这是来自framework/View/Engine/PhpEngine.php

最后,我们可以在注册新引擎时设置管理器实例:

public function addEngine(string $extension, Engine $engine): static
{
    $this->engines[$extension] = $engine;
    $this->engines[$extension]->setManager($this);
    return $this;
}

这是来自framework/View/Manager.php

我们现在可以从任何需要访问宏的引擎中调用Manager类上的useMacro。我不认为它对于像基本的变量字符串模板这样的引擎是必要的,但是它对于更复杂的类型是有用的。

这可能是创建另一个使用宏的特征的好时机,但是我将把它作为一个练习留给你。

我们可以定义一个神奇的方法来调用useMacro:

// protected function escape(string $content): string
// {
//     return htmlspecialchars($content);
// }

public function __call(string $name, $values)
{
    return $this->manager->useMacro($name, ...$values);
}

这是来自framework/View/Engine/PhpEngine.php

这意味着我们可以继续从模板内部调用$this->escape,它将使用宏闭包而不是引擎上的方法。

这就完成了这个模板引擎!让我们继续讨论编译器引擎。我们将从高级可变字符串模板引擎开始…

构建高级可变字符串模板引擎

我越想这个名字,就越想给它找个更好的名字。本质上,它只是一个简化的定制编译器,从一个 DSL(或特定领域语言)生成 PHP-in-HTML 模板。

前面,我们简要地看了一下属于这一组的模板种类。我们希望能够像这样处理代码:

@if($hasRocketsToSpare)
    <p>We have rockets for you!</p>
@endif

这应该重写为类似如下的内容:

<?php if($hasRocketsToSpare): ?>
    <p>We have rockets for you!</p>
<?php endif; ?>

好消息是这种编译器遵循与PhpEngine类相似的模式。让我们从复制那个类开始,去掉我们需要的额外方法:

namespace Framework\View\Engine;

use Framework\View\Engine\HasManager;
use Framework\View\View;
use function view;

class AdvancedEngine implements Engine
{
    use HasManager;

    protected $layouts = [];

    public function render(View $view): string
    {
        $hash = md5($view->path);
        $folder = __DIR__ . '/../../../storage/framework/views';
        $cached = realpath("{$folder}/{$hash}.php");

        if (!file_exists($hash) || filemtime($view->path) > filemtime($hash)) {
            $content = $this->compile(file_get_contents($view->path));
            file_put_contents($cached, $content);
        }

        extract($view->data);

        ob_start();
        include($cached);
        $contents = ob_get_contents();
        ob_end_clean();

        if ($layout = $this->layouts[$cached] ?? null) {
            $contentsWithLayout = view($layout, array_merge(
                $view->data,
                ['contents' => $contents],
            ));

            return $contentsWithLayout;
        }

        return $contents;
    }

    protected function compile(string $template): string
    {
        // replace DSL bits with plain PHP...
        return $template;
    }

    protected function extends(string $template): static
    {
        $backtrace = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 1);
        $this->layouts[realpath($backtrace[0]['file'])] = $template;
        return $this;
    }

    public function __call(string $name, $values)
    {
        return $this->manager->useMacro($name, ...$values);
    }
}

这是来自framework/View/Engine/AdvancedEngine.php

它基本上与PhpEngine类相同,但是我们有这个神秘的“编译”步骤,在这里我们将用普通的 PHP-in-HTML 语法替换 DSL 语言。

让我们也注册新的引擎(并使“包括”一个宏):

function view(string $template, array $data = []): View\View
{
    static $manager;

    if (!$manager) {
        $manager = new View\Manager();

        // let's add a path for our views folder
        // so the manager knows where to look for views
        $manager->addPath(__DIR__ . '/../resources/views');

        // we'll also start adding new engine classes
        // with their expected extensions to be able to pick
        // the appropriate engine for the template
        $manager->addEngine('basic.php', new View\Engine\BasicEngine());
        $manager->addEngine('advanced.php', new View\Engine\AdvancedEngine());
        $manager->addEngine('php', new View\Engine\PhpEngine());

        // how about macros? let's add them here for now
        $manager->addMacro('escape', fn($value) => htmlspecialchars($value));
        $manager->addMacro('includes', fn(...$params) => print view(...$params));
    }

    return $manager->resolve($template, $data);
}

这是来自framework/helpers.php

...$params是使用 splat 操作符的另一个例子。概括一下,这意味着我们接受任意数量的参数变量并将它们添加到一个数组中,因此includes($path, $data)变成了$params['path']$params['data']。下一次我们使用它时,我们会再次将数组解包到一个变量列表中。

现在,当我们创建一个模板——使用 PHP-in-HTML 语法和一个新的扩展——我们应该看到它像PhpEngine模板一样工作:

<?php $this->extends('layouts/products'); ?>
<h1>All Products</h1>
<p>Show all products...</p>

这是来自resources/views/products/list.advanced.php

让我们从那个compile方法开始:

protected function compile(string $template): string
{
    // replace `@extends` with `$this->extends`
    $template = preg_replace_callback('#@extends\(([^)]+)\)#', function($matches) {
        return '<?php $this->extends(' . $matches[1] . '); ?>';
        }, $template);

    return $template;
}

这是来自framework/View/Engine/AdvancedEngine.php

我们要编译的第一个语法是从@extends$this->extends的变化。preg_replace_callback非常适合这种情况,因为我们告诉它返回括号内的任何内容,所以重写 PHP-in-HTML 语法非常简单。

这意味着我们可以将模板语法缩短为

@extends('layouts/products')
<h1>All Products</h1>
<p>Show all products...</p>

这是来自resources/views/products/list.advanced.php

我们可以遵循同样的方法来允许控制流语句:

protected function compile(string $template): string
{
    // ...

    // replace `@id` with `if(...):`
    $template = preg_replace_callback('#@if\(([^)]+)\)#', function($matches) {
        return '<?php if(' . $matches[1] . '): ?>';
    }, $template);

    // replace `@endif` with `endif`
    $template = preg_replace_callback('#@endif#', function($matches) {
        return '<?php endif; ?>';
    }, $template);

    return $template;
}

这是来自framework/View/Engine/AdvancedEngine.php

这些新的语法允许我们在模板中使用更少的代码来生成 if 语句:

@if($next)
    <a href="<?php print $next; ?>">next</a>
@endif

这是来自resources/views/products/list.advanced.php

当然,如果我们不需要输入那么长的“print”语句,那就更容易了:

protected function compile(string $template): string
{
    // ...

    // replace `{{ ... }}` with `print $this->escape(...)`
    $template = preg_replace_callback('#\{\{([^}]+)\}\}#', function($matches) {
        return '<?php print $this->escape(' . $matches[1] . '); ?>';
    }, $template);

    return $template;
}

这是来自framework/View/Engine/AdvancedEngine.php

这意味着我们可以在模板中使用这种新语法进行打印:

@if($next)
    <a href="{{ $next }}">next</a>
@endif

这是来自resources/views/products/list.advanced.php

这将打印转义值,但有时我们可能不想打印转义值(尽管不建议这样做)。为此,我们可以添加另一种“打印”语法:

protected function compile(string $template): string
{
    // ...

    // replace `{!! ... !!}` with `print ...`
    $template = preg_replace_callback('#\{!!([^}]+)!!\}#', function($matches) {
        return '<?php print ' . $matches[1] . '; ?>';
    }, $template);

    return $template;
}

这是来自framework/View/Engine/AdvancedEngine.php

所以我们现在可以用{!! ... !!}语法打印未转义的值:

@if($next)
    <a href="{!! $next !!}">next</a>
@endif

这是来自resources/views/products/list.advanced.php

这就是这个模板引擎的全部内容——添加新的正则表达式来处理新的语法。我们可能想做的另一件事是允许调用宏,如果它们没有被定义为现有的语法:

protected function compile(string $template): string
{
    // ...

    // replace `@***(...)` with `$this->***(...)`
    $template = preg_replace_callback('#@([^(]+)\(([^)]+)\)#', function($matches) {
        return '<?php $this->' . $matches[1] . '(' . $matches[2] . '); ?>';
    }, $template);

    return $template;
}

这是来自framework/View/Engine/AdvancedEngine.php

虽然这是一个愚蠢的例子,但是我们现在可以使用@includes语法包含产品细节部分模板:

@includes('includes/product-details', ['name' => 'acme'])

这是来自resources/views/products/list.advanced.php

令人惊讶的是,我们能够添加如此多的功能,而引擎中的新代码相对较少。Blade 中有更多的功能,但这是其功能子集的通用实现。

Blade 还支持类似 HTML 的语法,这将需要更复杂的正则表达式。添加这种语法是一个有趣的挑战。

构建 HTML-in-PHP 引擎

我想让我们看的最后一个引擎,至少部分是 HTML-in-PHP 引擎。这将需要一种不同的方法来加载视图,但我相信我们可以做到。

我们首先需要了解的是之前的模板引擎和最后一个模板引擎之间的主要区别。先前的模板已经加载到路由处理程序中。我提议的是一种发生在 PHP 类内部的模板:

namespace App\Components;

class ProductComponent
{
    protected string $props;

    public function __construct(array $props)
    {
        $this->props = $props;
    }

    public function render()
    {
        return (
            <a href={$this->props->href}>
                {$this->props->name}
            </a>
        );
    }
}

这种编译器需要几件大事才能工作:

  1. 与 Composer 的自动加载系统深度集成

  2. 一个将<a>...</a>编译成普通 PHP 的层——比如render('a', ...)——然后从 PHP 代码生成 HTML

我认为从头开始做整个事情可能有点紧张,但是已经有一个自定义编译器可供我们使用: https://github.com/preprocess/pre-phpx

让我们讨论一下它所采取的步骤,这样我们就可以理解它与我们已经构建的编译器和引擎有什么不同:

  • 与 Composer 的 autoloader 集成,这样它就可以告诉什么时候应该编译包含的文件。它寻找以特殊扩展名结尾的文件,并为编译器准备好它们。

  • 编译器遍历这些文件的源代码,将标记与正则表达式进行匹配。这与之前发生的正则表达式替换不同,而是将字符串分解成一系列标记。

return (
    <a href={$this->props->href}>
        {$this->props->name}
    </a>
);

…被分解成一个类型化令牌数组:

[
    [
        'type' => 'literal',
        'value' => 'return (',
    ],
    [
        'type' => 'tag',
        'value' => 'a',
        'open' => true,
    ],
    [
        'type' => 'attribute',
        'value' => 'href={$this->props->href}',
    ],
    [
        'type' => 'print',
        'value' => '$this->props->name',
    ],
    [
        'type' => 'tag',
        'value' => 'a',
        'close' => true,
    ],
    [
        'type' => 'literal',
        'value' => ');',
    ],
]

这个令牌列表非常有用,因为它允许我们按照层次结构来排列令牌。这种层级类似于

[
    [
        'type' => 'literal',
        'value' => 'return (',
    ],
    [
        'type' => 'tag',
        'attributes' => [
            [
                'type' => 'href',
                'value' => '$this->props->href',
            ],
        ],
        'children' => [
            [
                'type' => 'print',
                'value' => '$this->props->name',
            ],
        ],
    ],
    [
        'type' => 'literal',
        'value' => ');',
    ],
]

这个层次结构(或抽象语法树)可以被翻译成另一种语言或格式。在这种情况下,我们可以用每个标签的简单 PHP 代码来代替它。我们可以将结果代码编译成

return render('a', [
    'href' => $this->props->href,
], [
    $this->props->name,
]);

如果我们聪明地管理这些组件,我们甚至可以让它们以类似于库 LivewireBlazor 的方式与 JavaScript 交互。

如果你对尝试这种方法感兴趣的话,我以前写过如何做到这一点的文章

专家是如何做到的

在我们结束之前,让我们谈一谈流行的模板引擎和库在做什么。

盘子

Plates 是一个提供大量 PHP-in-HTML 处理的库。它具有与扩展类似的机制,包括我们添加的功能,以及一组做各种有用事情的助手,包括 XSS 保护。

设置相当简单:

$templates = new League\Plates\Engine('/path/to/templates');
print $templates->render('profile', ['name' => 'Jonathan']);

然后,在模板内部,他们使用熟悉的语法:

<?php $this->layout('template') ?>
<h1>User Profile</h1>
<p>Hello, <?= $this->e($name) ?></p>

它们还支持添加自定义宏的扩展接口:

use League\Plates\Engine;
use League\Plates\Extension\ExtensionInterface;

class ChangeCase implements ExtensionInterface
{
    public function register(Engine $engine)
    {
        $engine->registerFunction('uppercase', [$this, 'uppercaseString']);
        $engine->registerFunction('lowercase', [$this, 'lowercaseString']);
    }

    public function uppercaseString($var)
    {
        return strtoupper($var);
    }

    public function lowercaseString($var)
    {
        return strtolower($var);
    }
}

这比我们添加宏的方式要冗长一点,但不会太多。总的来说,这是一个很好的库,我强烈推荐使用它,而不是构建自己的 PHP-in-HTML 模板引擎。

您甚至可以考虑在您自己的 PHP-in-HTML 引擎中包装模板,在上面添加您自己的约定!

叶片

我参考了刀片很多,并有很好的理由。这是易用性的黄金标准,但它最适合在 Laravel 应用中使用。

至少在撰写本文时,可以在 Laravel 应用之外使用它,但是以这种方式工作是相当棘手的。

除了我们已经介绍过的功能,Blade 还支持添加自定义控制结构的快捷方式(真的是 if 语句):

Blade::if('cloud', function ($provider) {
    return config('filesystems.default') === $provider;
});

这将允许您在模板中使用自定义的@cloud语句:

@cloud('gcs')
    You're using GCS!
@elsecloud('aws')
    Enjoying AWS?
@endcloud

然后,是我提到的类似 HTML 的语法,它建立在 PHP 类之上:

namespace App\View\Components;

use Illuminate\View\Component;

class ReceiptComponent extends Component
{
    public $receipts;

    public function __construct()
    {
        $this->receipts = auth()->user()->receipts;
    }

    public function render()
    {
        return view('components.receipt');
    }
}

这些组件可以用作

<h2>Receipts</h2>
<x-receipt />

太狂野了!并且会花费太多时间来涵盖细节。相反,我建议您查阅官方文档,了解关于这些组件如何工作的更多细节。

摘要

这一章是模板解析的奇妙旅程。我真的很喜欢构建所有这些示例,并且我确信其中有一个您会喜欢的模板解析器。

在下一章中,我们将研究如何构建一个验证库,我们还将创建一个更好的结构来组织与每条路线相关的代码,并介绍一种在开发过程中显示错误消息的更好方法。

五、构建验证器

是时候开始给我们的应用添加更多的结构了。我们一直非常关注框架代码——这并不是一件坏事——但这意味着我们忽略了开始构建一个合适的网站所需的一些基本东西。

大多数网站都有表格。让我们更容易地构建和使用它们来捕获用户输入!首先,我们需要制定一个替代方案,将所有应用代码保存在 routes 文件中…

什么是控制器?

我们已经讨论了 MVC 的大部分“视图”部分,我们将在下一章深入探讨模型部分。在本章中,我们将创建我们的第一个控制器。

有许多方法可以组织应用,并将普通代码与业务逻辑分开。在这上面花很多时间很诱人,但这不是本书或本章的目的。相反,我建议你看一看马蒂亚斯关于这个主题的书。

控制器更多地由它们不应该包含的代码定义,而不是由它们应该包含的代码定义——至少如果你问 Twitter 或 Reddit:

  • "控制器不应该包含应该在浏览器中显示的代码,比如 HTML 和 CSS . "

  • "控制器不应该包含处理数据库或文件系统的代码."

这些建议可以带来更清晰的代码库,但它们无助于解释控制器中应该包含哪些代码。简单地说,控制器应该是应用深层部分之间的粘合剂。

就我们的框架和应用而言,它们是 HTTP 请求将被发送到的地方,并通过它们将响应发送回浏览器。

这与构建验证器有什么关系?

因为控制器处理请求数据并安排响应数据,所以它们是应该进行验证的地方。以下是我们将在验证库中构建的主要功能:

  • 定义验证函数的一些结构

  • 一些预建的验证函数

  • 一种给验证类简单命名的方法,这样它们就可以被快速使用

  • 针对请求数据运行一组验证规则的方法

  • 一种自定义验证错误信息的方法

一旦我们完成了,我们将看看一些流行的验证库和方法,这样我们就可以对那里有什么有一个感觉。

改进错误处理

在我们开始控制器和验证之前,我想解决一个问题,自从我们制造路由以来,我一直在努力解决这个问题。在Router->dispatch()方法中,我们捕捉异常并显示一个简单的错误页面。

我认为我们可以做一些更有用的东西,至少对于开发环境来说。让我们安装一个伟大的开源库来格式化错误响应:

composer require filp/whoops

然后,我们可以显示一个有用的堆栈跟踪,而不是显示上一个(相当无用的)错误页面:

public function dispatch()
{
    $paths = $this->paths();

    $requestMethod = $_SERVER['REQUEST_METHOD'] ?? 'GET';
    $requestPath = $_SERVER['REQUEST_URI'] ?? '/';

    $matching = $this->match($requestMethod, $requestPath);

    if ($matching) {
        $this->current = $matching;

        try {
            return $matching->dispatch();
        }
        catch (Throwable $e) {
            $whoops = new Run();
            $whoops->pushHandler(new PrettyPageHandler());
            $whoops->register();
            throw $e;

            return $this->dispatchError();
        }
    }

    if (in_array($requestPath, $paths)) {
        return $this->dispatchNotAllowed();
    }

    return $this->dispatchNotFound();
}

这是来自framework/Routing/Router.php

现在我们的错误将更容易追查。

img/299823_2_En_5_Figa_HTML.jpg

更好的错误信息

不过,有一个问题。在生产环境中保留这样的东西通常不是一个好主意,因为它可能会暴露危及您的服务器、服务或用户的秘密。

我们需要添加一些东西来确保它只在开发环境中显示。我们可以根据 URL 过滤,但是有一个更好的解决方案!

让我们创建一个名为.env的文件,其中存储了环境名:

APP_ENV=dev

这是来自.env

将这个文件添加到.gitignore中是很重要的,这样存储在其中的任何秘密都不会被提交和推送到像 GitHub 这样的地方:

vendor
.env

这是来自.gitignore

想法是这样的:

  • 秘密保存在.env和特定于环境的事物中(比如环境的名称或类型)。

  • 没有共享或提交给源代码控制系统。

  • 当应用需要知道某个秘密或环境的名称或类型时,它会查看这个文件。

当然,我们可以创建一个模板,以便人们在他们的机器上设置应用时,知道他们需要什么秘密和环境细节:

APP_ENV=

这是来自.env.example

通常把这个文件叫做.env.example,这样文件名就暗示了这个文件就是一个例子。当你看到一个包含这个文件的项目时,你可以自信地认为这个项目需要一个.env文件中的秘密。

我们将环境称为“dev”,但是我们如何在路由中获得这个值呢?我们可以使用另一个很棒的开源库:

composer require vlucas/phpdotenv

这个库读取.env中的秘密,并将它们放入 PHP 环境中。我们需要在应用生命周期的开始“加载”这些秘密:

require_once __DIR__ . '/../vendor/autoload.php';

$dotenv = Dotenv\Dotenv::createImmutable(__DIR__ . '/..');
$dotenv->load();

$router = new Framework\Routing\Router();

$routes = require_once __DIR__ . '/../app/routes.php';
$routes($router);

print $router->dispatch();

这是来自public/index.php

createImmutable方法寻找一个.env文件,所以我们需要告诉它这个文件可能在哪个文件夹中。现在,我们可以在路由中看到我们的APP_ENV变量:

public function dispatch()
{
    $paths = $this->paths();

    $requestMethod = $_SERVER['REQUEST_METHOD'] ?? 'GET';
    $requestPath = $_SERVER['REQUEST_URI'] ?? '/';

    $matching = $this->match($requestMethod, $requestPath);

    if ($matching) {
        $this->current = $matching;

        try {
            return $matching->dispatch();
        }
        catch (Throwable $e) {
            if (isset($_ENV['APP_ENV']) && $_ENV['APP_ENV'] === 'dev') {
                $whoops = new Run();
                $whoops->pushHandler(new PrettyPageHandler());
                $whoops->register();
                throw $e;
            }

            return $this->dispatchError();
        }
    }

    if (in_array($requestPath, $paths)) {
        return $this->dispatchNotAllowed();
    }

    return $this->dispatchNotFound();
}

这是来自framework/Routing/Router.php

通过此项检查,仅当APP_ENV=dev出现时,才会出现呜呜堆栈跟踪错误页面。我建议您将APP_ENV的默认值设置为其他值,这样使用您的应用(或框架)的人就不会看到这个错误页面。就安全而言,这是最安全的位置。

创建控制器

让我们将我们的路由闭包代码移到控制器中。首先,让我们制作一个新的主页控制器:

namespace App\Http\Controllers;

class ShowHomeController
{
    public function handle()
    {
        return view('home', ['number' => 42]);
    }
}

这是来自app/Http/Controllers/ShowHomePageController.php

我喜欢冗长的名字,像这样。关于这样的后缀是否好,有一个有趣的讨论,但我更喜欢这样命名常见的事物:

  • 型号→事物(单数)→“产品”

  • 控制器→动词+事物+“控制器”→“ShowProductController”

  • 事件→事物+动词(过去式)→“product created”

根据我的经验,这种命名方案会导致更少的冲突和混乱。

现在,我们需要将路线从使用内联闭包改为使用这个控制器:

use App\Http\Controllers\ShowHomePageController;
use Framework\Routing\Router;

return function(Router $router) {
    $router->add(
        'GET', '/',
        [ShowHomePageController::class, 'handle'],
    );

    //...
};

这是来自app/routes.php

这不会马上奏效,原因有几个:

  • 我们已经在RouterRoute类中输入了callable

  • 即使我们不是,我们也不能像调用函数一样调用数组,除非第二项是可以静态调用的方法的名称。

让我们解决这两个问题,从Router类开始,我们移除了$handler的类型:

public function add(string $method, string $path, $handler): Route
{
    $route = $this->routes[] = new Route($method, $path, $handler);
    return $route;
}

这是来自framework/Routing/Router.php

现在,我们可以在Route类中做同样的事情:

public function __construct(string $method, string $path, $handler)
{
    $this->method = $method;
    $this->path = $path;
    $this->handler = $handler;
}

这是来自framework/Routing/Route.php

这只是问题的一半。我们还需要使Route->dispatch()方法能够处理非静态控制器方法:

public function dispatch()
{
    if (is_array($this->handler)) {
        [$class, $method] = $this->handler;
        return (new $class)->{$method}();
    }

    return call_user_func($this->handler);
}

这是来自framework/Routing/Route.php

这个数组引用技巧很巧妙。它让我们将数组分成几个命名的变量,这样我们就可以创建一个变量类并调用一个变量方法。

主页又开始工作了!让我们创建另一个控制器:

namespace App\Http\Controllers\Products;

class ListProductsController
{
    public function handle()
    {
        $parameters = $router->current()->parameters();
        $parameters['page'] ??= 1;

        $next = $router->route(
            'list-products', ['page' => $parameters['page'] + 1]
        );

        return view('products/list', [
            'parameters' => $parameters,
            'next' => $next,
        ]);
    }
}

这是来自app/Http/Controllers/Products/ShowProductsController.php

这不会立即生效,因为我们不再能够访问局部变量$router。现在,让我们改变我们的Route->dispatch方法来接受一个类名或者一个已经创建的对象,这样我们就有可能提供一个已经被赋予路由的控制器对象:

public function dispatch()
{
    if (is_array($this->handler)) {
        [$class, $method] = $this->handler;

        if (is_string($class)) {
            return (new $class)->{$method}();
        }

        return $class->{$method}();
    }

    return call_user_func($this->handler);
}

这是来自framework/Routing/Route.php

我们假设如果第一项不是类名,它就是一个对象。情况可能并非如此,所以您可能需要对此进行更多的验证。

我不太担心,因为我们会回到这个问题上来,以这样一种方式重构依赖关系管理,当以这种方式创建路由时,您不需要或不想传递对象。

现在,我们可以这样定义路线:

$router->add(
    'GET', '/products/{page?}',
    [new ListProductsController($router), 'handle'],
)->name('list-products');

这是来自app/routes.php

但是,我们需要将路由存储在我们的控制器中:

namespace App\Http\Controllers\Products;

use Framework\Routing\Router;

class ListProductsController
{
    public function __construct(Router $router)
    {
        $this->router = $router;
    }

    public function handle()
    {
        $parameters = $this->router->current()->parameters();
        $parameters['page'] ??= 1;

        $next = $this->router->route(
            'list-products', [
                'page' => $parameters['page'] + 1,
            ]
        );

        return view('products/list', [
            'parameters' => $parameters,
            'next' => $next,
        ]);
    }
}

这是来自app/Http/Controllers/Products/ShowProductsController.php

我们可以制作的另外两个控制器用于显示单个产品和单个服务。它们很小,所以我不会展示代码。如果你不能解决这些问题,请查看项目资源库…

重构后的路由文件如下所示:

use App\Http\Controllers\ShowHomePageController;
use App\Http\Controllers\Products\ListProductsController;
use App\Http\Controllers\Products\ShowProductController;
use App\Http\Controllers\Services\ShowServiceController;
use App\Http\Controllers\Users\ShowRegisterFormController;
use Framework\Routing\Router;

return function(Router $router) {
    $router->add(
        'GET', '/',
        [ShowHomePageController::class, 'handle'],
    )->name('show-home-page');

    $router->errorHandler(
        404, fn() => 'whoops!'
    );

    $router->add(
        'GET', '/products/view/{product}',
        [new ShowProductController($router), 'handle'],
    )->name('view-product');

    $router->add(
        'GET', '/products/{page?}',
        [new ListProductsController($router), 'handle'],
    )->name('list-products');

    $router->add(
        'GET', '/services/view/{service?}',
        [new ShowServiceController($router), 'handle'],
    )->name('show-service');
};

这是来自app/routes.php

这比以前整洁多了,部分原因是我们删除了一些调试路径,但也因为它没有内联闭包。

我已经给所有的路线起了名字(并且安排了现有的名字,所以它们与控制器的名字一致)。您还将看到,我已经重构了视图,因此它们都使用相同的布局和引擎。你可以不做这些改变,但是我推荐你做!

创建表单

我们需要为 Whoosh 构建的东西之一是客户购买火箭的能力。他们将需要一个帐户,通过扩展,应用将需要一个注册页面。

让我们创建一个表单:

@extends('layout')
<h1 class="text-xl font-semibold mb-4">Register</h1>
<form
  method="post"
  action="{{ $router->route('show-register-form') }}"
  class="flex flex-col w-full space-y-4"
>
  <label for="name" class="flex flex-col w-full">
    <span class="flex">Name:</span>
    <input
      id="name"
      name="name"
      type="text"
      class="focus:outline-none focus:border-blue-300 border-b-2 border-gray-300"
      placeholder="Alex"
    />
  </label>
  <label for="email" class="flex flex-col w-full">
    <span class="flex">Email:</span>
    <input
      id="email"
      name="email"
      type="email"
      class="focus:outline-none focus:border-blue-300 border-b-2 border-gray-300"
      placeholder="alex.42@gmail.com"
    />
  </label>
  <label for="password" class="flex flex-col w-full">
    <span class="flex">Password:</span>
    <input
      id="password"
      name="password"
      type="password"
      class="focus:outline-none focus:border-blue-300 border-b-2 border-gray-300"
    />
  </label>
  <button
    type="submit"
    class="focus:outline-none focus:border-blue-500 focus:bg-blue-400 border-b-2 border-blue-400 bg-blue-300 p-2"
  >
    Register
  </button>
</form>

这是来自resources/views/register.advanced.php

这里没什么可说的。它是一个表单中的三个表单字段,提交给一个名为register-user的路由。为此,我们需要创建几条新路线:

$router->add(
    'GET', '/register',
    [new ShowRegisterFormController($router), 'handle'],
)->name('show-register-form');

$router->add(
    'POST', '/register',
    [new RegisterUserController($router), 'handle'],
)->name('register-user');

这是来自app/routes.php

第一个控制器类似于我们以前制作的其他“只读”控制器,因为它只需要返回一个视图:

namespace App\Http\Controllers\Users;

use Framework\Routing\Router;

class ShowRegisterFormController
{
    protected Router $router;

    public function __construct(Router $router)
    {
        $this->router = $router;
    }

    public function handle()
    {
        return view('users/register', [
            'router' => $this->router,
        ]);
    }
}

这是来自app/Http/Controllers/ShowRegisterFormController.php

第二个是事情开始变得有趣的地方。我们需要在其中做以下事情:

  • 获取从表单发送的数据。

  • 检查它是否通过各种标准。

  • 如果没有通过标准,则返回错误。

  • 创建新的数据库记录。

  • 重定向至成功消息。

我们将在接下来的两章中学习数据库,所以现在我们可以假装这一部分。以下是其余部分:

namespace App\Http\Controllers\Users;

use Framework\Routing\Router;

class RegisterUserController
{
    protected Router $router;

    public function __construct(Router $router)
    {
        $this->router = $router;
    }

    public function handle()
    {
        $data = validate($_POST, [
            'name' => ['required'],
            'email' => ['required', 'email'],
            'password' => ['required', 'min:10'],
        ]);

        // use $data to create a database record...

        $_SESSION['registered'] = true;

        return redirect($this->router->route('show-home-page'));
    }
}

这是来自app/Http/Controllers/RegisterUserController.php

该控制器需要一组额外的功能-validateredirect。让我们先解决redirect,因为这是两者中比较简单的一个:

if (!function_exists('redirect')) {
    function redirect(string $url)
    {
        header("Location: {$url}");
        exit;
    }
}

这是来自framework/helpers.php

有很多不同的方法来处理重定向,但我认为这是最干净的。从技术上讲,我们不需要将调用结果返回给redirect,但这有助于提醒我们redirect是一个“终结”动作。在我们呼叫redirect之后,在控制器中不会也不应该发生任何事情。

以这种方式设置标题并退出并不理想。应用最好能区分重定向和响应。当我们到第九章关于测试的时候,我们会为这个问题建立一个更好的解决方案。

现在我们需要研究validate方法。它应该创建一个“验证”服务类,我们可以向其中添加框架支持的可用验证方法。

这就是它的样子:

if (!function_exists('validate')) {
    function validate(array $data, array $rules)
    {
        static $manager;

        if (!$manager) {
            $manager = new Validation\Manager();

            // let's add the rules that come with the framework
            $manager->addRule('required', new Validation\Rule\RequiredRule());
            $manager->addRule('email', new Validation\Rule\EmailRule());
            $manager->addRule('min', new Validation\Rule\MinRule());
        }

        return $manager->validate($data, $rules);
    }
}

这是来自framework/helpers.php

这与我们用视图管理器所做的类似。如果这是第一次调用validate函数,那么我们设置验证管理器。

我们向它添加规则,比如requiredmin,这样人们就可以在他们的控制器中使用这些验证器,而不需要自己添加规则。

稍后,我们将学习如何允许其他人将他们自己的验证规则添加到系统中,但这将需要一个比我们目前拥有的更好的“记住”经理的结构。

在我们深入验证管理器类之前,让我们看看规则类是如何定义的。它们基于这个接口:

namespace Framework\Validation\Rule;

interface Rule
{
    public function validate(array $data, string $field, array $params);
    public function getMessage(array $data, string $field, array $params);
}

这是来自framework/Validation/Rule/Rule.php

每个规则都应该有一种方法来判断表单数据是通过还是失败,以及一种方法来返回相应的失败错误消息。然后,我们可以定义我们正在使用的每个规则:

namespace Framework\Validation\Rule;

class EmailRule implements Rule
{
    public function validate(array $data, string $field, array $params)
    {
        if (empty($data[$field])) {
            return true;
        }

        return str_contains($data[$field], '@');
    }

    public function getMessage(array $data, string $field, array $params)
    {
        return "{$field} should be an email";
    }
}

这是来自framework/Validation/Rule/EmailRule.php

EmailRule不应该要求表单中有任何数据(因为那是RequiredRule的工作)。这就是为什么如果没有任何数据,我们会返回一个成功的响应。

另一方面,如果数据存在,那么我们检查它是否包含一个“@”符号。我们可以做更复杂的检查来判断值看起来是否更像电子邮件地址,但它们几乎不能确保电子邮件地址有效。维护它们是一种痛苦。

如果你真的在乎用户提供一个有效的电子邮件地址,那就给他们发一封电子邮件,里面有一个链接,点击后可以验证他们的账户。

对于min规则,我们需要检查提供的表单值的长度是否至少与我们在声明有效性规则时传递的参数一样多。记住,我们将规则定义为'password' => ['required', 'min:10']。这意味着我们需要获取第一个参数,并在与密码的字符串长度进行比较之前检查是否提供了该参数:

namespace Framework\Validation\Rule;

use InvalidArgumentException;

class MinRule implements Rule
{
    public function validate(array $data, string $field, array $params)
    {
        if (empty($data[$field])) {
            return true;
        }

        if (empty($params[0])) {
            throw InvalidArgumentException('specify a min length');
        }

        $length = (int) $params[0];

        strlen($data[$field]) >= $length;
    }

    public function getMessage(array $data, string $field, array $params)
    {
        $length = (int) $params[0];

        return "{$field} should be at least {$length} characters";
    }
}

这是来自framework/Validation/Rule/EmailRule.php

MinRule类寻找长度参数,并确保所提供的数据至少一样长。如果参数丢失,我们可能会抛出一个异常,但这应该很容易自己解决:

namespace Framework\Validation\Rule;

class RequiredRule implements Rule
{
    public function validate(array $data, string $field, array $params)
    {
        return !empty($data[$field]);
    }

    public function getMessage(array $data, string $field, array $params)
    {
        return "{$field} is required";
    }
}

这是来自framework/Validation/Rule/RequiredRule.php

最后,RequiredRule类只检查表单字段是否为空。

好的,这些是我们需要的规则,但是我们如何利用它们呢?下面是验证管理器类:

namespace Framework\Validation;

use Framework\Validation\Rule\Rule;
use Framework\Validation\ValidationException;

class Manager
{
    protected array $rules = [];

    public function addRule(string $alias, Rule $rule): static
    {
        $this->rules[$alias] = $rule;
        return $this;
    }

    public function validate(array $data, array $rules): array
    {
        $errors = [];

        foreach ($rules as $field => $rulesForField) {
            foreach ($rulesForField as $rule) {
                $name = $rule;
                $params = [];

                if (str_contains($rule, ':')) {
                    [$name, $params] = explode(':', $rule);
                    $params = explode(',', $params);
                }

                $processor = $this->rules[$name];

                if (!$processor->validate($data, $field, $params)) {
                    if (!isset($errors[$field])) {
                        $errors[$field] = [];
                    }

                    array_push($errors[$field], $processor->getMessage($data, $field, $params));
                }
            }
        }

        if (count($errors)) {
            $exception = new ValidationException();
            $exception->setErrors($errors);
            throw $exception;
        }

        return array_intersect_key($data, $rules);
    }
}

这是来自framework/Validation/Manager.php

通过阅读代码,没有什么需要解释的。validate方法执行以下步骤:

  1. 对于每个字段,遍历规则。

  2. 对于每个规则,获取处理器(或Rule类)并通过其validate方法运行数据。

  3. 如果出现故障,获取处理器的消息并将其添加到$errors数组中。

  4. 如果有错误,抛出一个包含错误记录的异常。

  5. 或者,返回经过验证的表单值。

    这是我们需要回来重构的另一件事。使用这种ValidationException方法,我的目的是提供一个人们可以扩展/定制的异常处理程序,这样他们就可以对验证异常做出不同的反应。我们将在第九章回到这个话题。

这预先假定了一个ValidationException类:

namespace Framework\Validation;

use InvalidArgumentException;

class ValidationException extends InvalidArgumentException
{
    protected array $errors = [];

    public function setErrors(array $errors): static
    {
        $this->errors = $errors;
        return $this;
    }

    public function getErrors(): array
    {
        return $this->errors;
    }
}

这是来自framework/Validation/ValidationException.php

现在,如果我们提交无效数据,我们应该会看到一个错误的堆栈跟踪屏幕。这并不完全有帮助,因为我们希望客户看到他们错误提交了哪些字段。

在路由级别处理验证异常会更有帮助,这样我们就可以重定向错误:

public function dispatch()
{
    $paths = $this->paths();

    $requestMethod = $_SERVER['REQUEST_METHOD'] ?? 'GET';
    $requestPath = $_SERVER['REQUEST_URI'] ?? '/';

    $matching = $this->match($requestMethod, $requestPath);

    if ($matching) {
        $this->current = $matching;

        try {
            return $matching->dispatch();
        }
        catch (Throwable $e) {
            if ($e instanceof ValidationException) {
                $_SESSION['errors'] = $e->getErrors();
                return redirect($_SERVER['HTTP_REFERER']);
            }

            if (isset($_ENV['APP_ENV']) && $_ENV['APP_ENV'] === 'dev') {
                $whoops = new Run();
                $whoops->pushHandler(new PrettyPageHandler);
                $whoops->register();
                throw $e;
            }

            return $this->dispatchError();
        }
    }

    if (in_array($requestPath, $paths)) {
        return $this->dispatchNotAllowed();
    }

    return $this->dispatchNotFound();
}

这是来自framework/Routing/Router.php

如果截获的错误类型是验证异常,我们可以将错误消息存储在会话中,并重定向回提交表单的前一个页面。

我们不应该忘记启动会话,否则错误消息(以及前面的“注册”会话变量)将不会被存储:

require_once __DIR__ . '/../vendor/autoload.php';

session_start();

//...

这是来自public/index.php

我们还应该显示错误消息,这样客户就知道哪里出错了。我们可以在登记表上这样做:

@extends('layout')
<h1 class="text-xl font-semibold mb-4">Register</h1>
<form
  method="post"
  action="{{ $router->route('register-user') }}"
  class="flex flex-col w-full space-y-4"
>
  @if(isset($_SESSION['errors']))
  <ol class="list-disc text-red-500">
    @foreach($_SESSION['errors'] as $field => $errors) @foreach($errors as
    $error)
    <li>{{ $error }}</li>
    @endforeach @endforeach
  </ol>
  @endif //...
</form>

这是来自resources/views/register.advanced.php

如果有错误,我们可以遍历它们并打印出每一个。通过一点点的格式化,我们可以让它们在客户面前脱颖而出。就这样,我们实现了可重用的表单验证!

保护我们的形式

如果不解决跨站请求伪造(或 CSRF) 的问题,我无法结束这一章。这是一个漏洞,迫使用户在他们不知情的情况下,在他们被认证的网站上做一些事情。

想象一下,我们建立了一个网站,客户可以在那里购买火箭。现在,想象一下有人在另一个网站上嵌入了一些 JavaScript(使用跨站点脚本),该网站将使用我们客户的认证会话来购买最大最好的火箭:所有这些都在我们客户不知情的情况下进行。

CSRF 保护通过强制经过身份验证的用户发起操作,使这变得更加困难。这种保护依赖于启动生成唯一令牌的操作的页面,以及表单提交以检查令牌是否与预期相符的页面。

让我们看看这在代码中是什么样子的:

if (!function_exists('csrf')) {
    function csrf()
    {
        $_SESSION['token'] = bin2hex(random_bytes(32));
        return $_SESSION['token'];
    }
}

if (!function_exists('secure')) {
    function secure()
    {
        if (!isset($_POST['csrf']) || !isset($_SESSION['token']) ||
!hash_equals($_SESSION['token'], $_POST['csrf'])) {
            throw new Exception('CSRF token mismatch');
        }
    }
}

这是来自framework/helpers.php

第一个函数创建一个令牌并将其存储在会话中。第二个检查令牌是否由表单提供,以及它是否与会话存储的令牌匹配。

我们应该用这个来保护每一个重要的控制器,最好是自动的。当我们开发我们的框架时,我们将学习我们可以使用的伟大模式——比如中间件——来达到这个目的。现在,我们可以手动添加安全性:

public function handle()
{
    secure();

    //...

    return redirect($this->router->route('show-home-page'));
}

这是来自app/Http/Controllers/Users/RegisterUserController.php

现在,如果我们提交表单,我们应该会看到抛出的异常。我们需要将令牌添加为隐藏字段:

@extends('layout')
<h1 class="text-xl font-semibold mb-4">Register</h1>
<form
  method="post"
  action="{{ $router->route('register-user') }}"
  class="flex flex-col w-full space-y-4"
>
  <input type="hidden" name="csrf" value="{{ csrf() }}" />
  //...
</form>

这是来自resources/views/register.advanced.php

表单应该可以再次工作,但是这次更加安全。

专家是如何做到的

我们模仿 Laravel 使用的许多模式来建模我们的验证库。框架本身附带了许多内置的验证器,并且有一种明确的方法来扩展验证,以添加定制的验证功能:

namespace App\Rules;

use Illuminate\Contracts\Validation\Rule;

class Uppercase implements Rule
{
    public function passes($attribute, $value)
    {
        return strtoupper($value) === $value;
    }

    public function message()
    {
        return 'The :attribute must be uppercase.';
    }
}

// later...

use App\Rules\Uppercase;

$request->validate([
    'name' => ['required', 'string', new Uppercase],
]);

它还接受闭包作为验证规则,因此您甚至不需要跳出控制器来定义自己的验证规则:

$validator = Validator::make($request->all(), [
    'title' => [
        'required',
        'max:255',
        function ($attribute, $value, $fail) {
            if ($value === 'foo') {
                $fail($attribute.' is invalid.');
            }
        },
    ],
]);

这是 Laravel 让用户变得超级容易的领域之一。与构成框架的许多库一样,验证库可以在 Laravel 应用之外使用。

另一个伟大的验证库是尊重。这里,规则是使用更灵活的语法定义的:

use Respect\Validation\Validator as v;

$username = v::alnum()->noWhitespace()->length(1, 15);
$username->validate('assertchris'); // returns true

尊重有许多内置的规则,所以即使你想使用 Laravel 的验证库,你可能会发现一个验证规则的实现,尊重实现了,但 Laravel 没有。

处理验证错误

Laravel 最酷的特性之一是处理框架抛出的任何异常的扩展点。每个新应用都有一个异常Handler类:

namespace App\Exceptions;

use Illuminate\Foundation\Exceptions\Handler as ExceptionHandler;

class Handler extends ExceptionHandler
{
    /**
     * A list of the exception types that are not reported.
     *
     * @var array
     */
    protected $dontReport = [
        //
    ];

    /**
     * A list of the inputs that are never flashed for validation exceptions.
     *
     * @var array
     */
    protected $dontFlash = [
        'password',
        'password_confirmation',
    ];

    /**
     * Register the exception handling callbacks for the application.
     *
     * @return void
     */
    public function register()
    {
        //
    }
}

这通常是从app/Exceptions/Handler.php开始的。

当我们构建我们的框架时,我想构建一些类似的东西,以便我们添加到路由的验证异常处理代码有一个更合适的位置。

摘要

我们在这一章中讨论了很多。我们从添加更好的错误处理和环境秘密开始。接下来,我们将应用安排到控制器中,这样我们就不必费力地通过一个巨大的 routes 文件。

然后,我们添加验证规则和助手函数来运行它们。帮助器函数以这种方式和那种方式重定向。帮助器函数为我们的表单添加 CSRF 保护。

多么忙碌的一章!

在下一章,我们将开始在数据库中工作。我们将学习如何连接到各种引擎,以及如何读写它们。

六、构建数据库的库

在前一章中,我们构建了一个注册表单。在验证了数据之后,我们停止了将数据放入数据库。我们将在本章中介绍这一点!

我们将为后面的章节打下坚实的基础,创建连接多个数据库引擎并以安全的方式执行查询的代码。我们甚至会提供一个迁移系统。如果有些内容不熟悉,请不要担心,我们会稳步进行。

你需要访问 MySQL 数据库,本章的大部分代码才能运行。有关环境设置的详细信息,请参考第一章。

数据库的库是用来做什么的?

也许您认为数据库的库只对“读取和写入数据库”有用,但是它们可以有深度。首先,流行的数据库的库允许开发人员通过简单的配置更改来使用几种不同的数据库引擎。

管理 MySQL 数据库与管理 SQLite 数据库和管理 MS SQL Server 数据库有点不同。为执行数据库查询而编写的代码应该能够与多个数据库引擎一起工作,使用通用数据库的库的人不应该需要根据他们使用的数据库引擎编写不同的“查询”。

此外,查询应该是“安全的”,这意味着一个好的数据库的库需要对其中使用的数据进行转义,这样就很难做一些不安全的事情。如果可能,应该使用预准备语句,因为它们提供了性能和安全优势。

我们将建立一个拥有所有这些优势的数据库的库。

我们还将花一些时间来构建一个迁移助手,以便可以将数据库的结构提交给代码。严格地说,迁移并不是构建一个好的数据库的库的必要条件,但是它们是有用的,并且在相同的领域中。

我们应该添加的内容

让我们列举一下我们想要构建的重要特性,这样你就知道接下来会发生什么了。以下是我认为我们应该补充的内容:

  • 一个创建数据库“驱动程序”的工厂,该驱动程序特定于我们在简单配置中选择的引擎

  • 与我们关心的每个数据库引擎对话的不同“方言”

  • 执行安全数据库查询的模式(如获取、插入、更新和删除行)

  • 基本迁移,它将新表或表修改持久化到我们支持的每个数据库引擎

    我们不必使用工厂模式来连接数据库引擎,但是维护一个连接创建集中在一个类中的库要容易得多,这个类的工作就是建立这些连接。

编写数据库的库分为两部分:编写常用的查询,并将代码连接到底层驱动程序,这些驱动程序连接到引擎并执行查询。

对于等式的后半部分,我们将使用添加到 PHP v5 中的 PDO(或 PHP 数据对象)扩展。

数据库工作是 web 开发中最大的安全漏洞之一,所以我强烈推荐回顾一下 https://phptherightway.com/#databases 中概述的最佳实践。

与数据库通信

让我们从创建工厂类开始,我们将使用一个简单的配置格式来创建适当的数据库引擎驱动程序对象。我们需要

  • 创建新连接的工厂类

  • 表示到数据库引擎的连接并且可以生成和执行查询的连接类

工厂类如下所示:

namespace Framework\Database;

use Closure;
use Framework\Database\Connection\Connection;
use Framework\Database\Exception\ConnectionException;

class Factory
{
    protected array $connectors;

    public function addConnector(string $alias, Closure $connector): static
    {
        $this->connectors[$alias] = $connector;
        return $this;
    }

    public function connect(array $config): Connection
    {
        if (!isset($config['type'])) {
            throw new ConnectionException('type is not defined');
        }

        $type = $config['type'];

        if (isset($this->connectors[$type])) {
            return $this->connectors$type;
        }

        throw new ConnectionException('unrecognised type');
    }
}

这是来自framework/Database/Factory.php

这类似于我们以前做过的管理器,但它太瘦了,所以我不打算称它为管理器。这只是一个工厂。我们给它一个配置,提示我们想要连接的数据库引擎的类型,它将配置的其余部分传递给我们定义的初始化函数。

如果我们想要打开一个到 MySQL 数据库的新连接,我们可能想要使用类似如下的代码:

namespace App\Http\Controllers;

use Framework\Database\Factory;
use Framework\Database\Connection\MysqlConnection;

class ShowHomePageController
{
    public function handle()
    {
        $factory = new Factory();

        $factory->addConnector('mysql', function($config) {
            return new MysqlConnection($config);
        });

        $connection = $factory->connect([
            'type' => 'mysql',
            'host' => '127.0.0.1',
            'port' => '3306',
            'database' => 'pro-php-mvc',
            'username' => 'root',
            'password' => '',
        ]);

        $product = $connection
            ->query()
            ->select()
            ->from('products')
            ->first();

        return view('home', [
            'number' => 42,
            'featured' => $product,
        ]);
    }
}

这是来自app/Http/Controllers/ShowHomePageController.php

为了让这段代码工作,我们需要几个设计良好的类。我们需要的第一类是抽象不同数据库引擎的连接的类。也许是基于抽象类的东西:

namespace Framework\Database\Connection;

use Framework\Database\QueryBuilder\QueryBuilder;
use Pdo;

abstract class Connection
{
    /**
     * Get the underlying Pdo instance for this connection
     */
    abstract public function pdo(): Pdo;

    /**
     * Start a new query on this connection
     */
    abstract public function query(): QueryBuilder;
}

这是来自framework/Database/Connection/Connection.php

仔细想想,抽象类和具有多种特征的接口没有太大区别。在这种情况下,我可以想象想要向抽象连接类添加方法,这将自然地适合每个特定的数据库引擎连接。

我们可能看到的不同引擎之间的差异,应该用这个抽象类的子类来表示:

namespace Framework\Database\Connection;

use Framework\Database\QueryBuilder\MysqlQueryBuilder;
use InvalidArgumentException;
use Pdo;

class MysqlConnection extends Connection
{
    private Pdo $pdo;

    public function __construct(array $config)
    {
        [
            'host' => $host,
            'port' => $port,
            'database' => $database,
            'username' => $username,
            'password' => $password,
        ] = $config;

        if (empty($host) || empty($database) || empty($username)) {
            throw new InvalidArgumentException('Connection incorrectly configured');
        }

        $this->pdo = new Pdo("mysql:host={$host};port={$port};dbname={$database}", $username, $password);
    }

    public function pdo(): Pdo
    {
        return $this->pdo;
    }

    public function query(): MysqlQueryBuilder
    {
        return new MysqlQueryBuilder($this);
    }
}

这是来自framework/Database/Connection/MysqlConnection.php

MySQL 连接需要一些参数才能成功。我们可以使用数组析构语法将每个键分配给一个局部变量,然后在尝试建立新的连接之前检查它们是否存在。

每个连接都应该创建一个特定于同一引擎的新查询构建器。例如,SqliteConnection类将创建一个SqliteQueryBuilder:

namespace Framework\Database\Connection;

use Framework\Database\QueryBuilder\SqliteQueryBuilder;
use InvalidArgumentException;
use Pdo;

class SqliteConnection extends Connection
{
    private Pdo $pdo;

    public function __construct(array $config)
    {
        ['path' => $path] = $config;

        if (empty($path)) {
            throw new InvalidArgumentException('Connection incorrectly configured');
        }

        $this->pdo = new Pdo("sqlite:{$path}");
    }

    public function pdo(): Pdo
    {
        return $this->pdo;
    }

    public function query(): SqliteQueryBuilder
    {
        return new SqliteQueryBuilder($this);
    }
}

这是来自framework/Database/Connection/SqliteConnection.php

我们需要创建的第二类应该抽象出构建、准备和执行 SQL 查询的工作。同样,我们可以使用抽象库,因为大多数 SQL 语法都是通用的:

namespace Framework\Database\QueryBuilder;

use Framework\Database\Connection\Connection;
use Framework\Database\Exception\QueryException;
use Pdo;
use PdoStatement;

abstract class QueryBuilder
{
    protected string $type;
    protected string $columns;
    protected string $table;
    protected int $limit;
    protected int $offset;

    /**
     * Get the underlying Connection instance for this query
     */
    abstract public function connection(): Connection;

    /**
     * Fetch all rows matching the current query
     */
    public function all(): array
    {
        $statement = $this->prepare();
        $statement->execute();

        return $statement->fetchAll(Pdo::FETCH_ASSOC);
    }

    /**
     * Prepare a query against a particular connection
     */
    public function prepare(): PdoStatement
    {
        $query = '';

        if ($this->type === 'select') {
            $query = $this->compileSelect($query);
            $query = $this->compileLimit($query);
        }

        if (empty($query)) {
            throw new QueryException('Unrecognised query type');
        }

        return $this->connection->pdo()->prepare($query);
    }

    /**
     * Add select clause to the query
     */
    protected function compileSelect(string $query): string
    {
        $query .= " SELECT {$this->columns} FROM {$this->table}";

        return $query;
    }

    /**
     * Add limit and offset clauses to the query
     */
    protected function compileLimit(string $query): string
    {
        if ($this->limit) {
            $query .= " LIMIT {$this->limit}";
        }

        if ($this->offset) {
            $query .= " OFFSET {$this->offset}";
        }

        return $query;
    }

    /**
     * Fetch the first row matching the current query
     */
    public function first(): array
    {
        $statement = $this->take(1)->prepare();
        $statement->execute();

        return $statement->fetchAll(Pdo::FETCH_ASSOC);
    }

    /**
     * Limit a set of query results so that it's possible
     * to fetch a single or limited batch of rows
     */
    public function take(int $limit, int $offset = 0): static
    {
        $this->limit = $limit;
        $this->offset = $offset;

        return $this;
    }

    /**
     * Indicate which table the query is targeting
     */
    public function from(string $table): static
    {
        $this->table = $table;
        return $this;
    }

    /**
     * Indicate the query type is a "select" and remember
     * which fields should be returned by the query
     */
    public function select(string $columns = '*'): static
    {
        $this->type = 'select';
        $this->columns = $columns;

        return $this;
    }
}

这是来自framework/Database/QueryBuilder/QueryBuilder.php

对于这个查询构建器的第一个版本,我们只支持选择查询。我们会在进行的过程中建立这一点…

从数据库表中选择和限制结果的 SQL 语法在 MySQL 和 SQLite 中是相同的。这意味着我们可以拥有相对较轻的 MySQL 和 SQLite 子类:

namespace Framework\Database\QueryBuilder;

use Framework\Database\Connection\MysqlConnection;

class MysqlQueryBuilder extends QueryBuilder
{
    protected MysqlConnection $connection;

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

这是来自framework/Database/QueryBuilder/MysqlQueryBuilder.php

namespace Framework\Database\QueryBuilder;

use Framework\Database\Connection\SqliteConnection;

class SqliteQueryBuilder extends QueryBuilder
{
    protected SqliteConnection $connection;

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

这是来自framework/Database/QueryBuilder/SqliteQueryBuilder.php

这些子类除了确保工厂的类型安全之外没有什么作用,但是随着时间的推移,它们可以存储越来越多的特定于引擎的查询语法。

现在,如果我们创建一个临时的“products”表并向其中添加一条记录,我们应该看到这条记录被返回并存储在$product变量中。

img/299823_2_En_6_Figa_HTML.jpg

只有一行的临时产品表

img/299823_2_En_6_Figb_HTML.jpg

从数据库中提取的特色产品记录

概括地说,这是我们最初设计的数据库的库在显示主页的控制器中具有的代码:

$factory = new Factory();

$factory->addConnector('mysql', function($config) {
    return new MysqlConnection($config);
});

$connection = $factory->connect([
    'type' => 'mysql',
    'host' => '127.0.0.1',
    'port' => '3306',
    'database' => 'pro-php-mvc',
    'username' => 'root',
    'password' => '',
]);

$product = $connection
    ->query()
    ->select()
    ->from('products')
    ->first();

这是来自app/Http/Controllers/ShowHomePageController.php

如果您正在努力创建临时表,请在您选择的数据库编辑器中使用以下 SQL 语句:

CREATE TABLE `products` (
  `id` int(11) unsigned NOT NULL AUTO_INCREMENT,
  `name` varchar(255) DEFAULT NULL,
  PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8;

但是这种语法很难使用。如果我们有一个创建和修改数据库表的系统,那会好得多。让我们创建一个系统来实现这一点!

从终端运行命令

到目前为止,我们已经执行了其他人的命令。让我们建立一些我们自己的,这样我们就有一种方法来扩展我们的应用的管理功能,这些功能可以从终端上运行,并且有可能按时间表运行。

在第一章中,我们了解到可以用多种方式运行 PHP 脚本。其中一种方法是直接在终端中。这方面的基础是

  1. 接受来自已执行命令的输入

  2. 执行一项或多项任务

  3. 将输出发送回终端

让我们通过提取一个名称并打印出转换成大写的名称来尝试每一种方法:

  1. php command.php运行这个将打印“陌生人”

  2. php command.php Jeff运行这个将打印“JEFF”

$name = $argv[1] ?? 'stranger';
print strtoupper($name) . PHP_EOL;

当设计越来越复杂的终端命令时,我们经常要处理输入。我们可能希望验证输入或者允许可选输入的默认值。我们可能想要格式化输出,以利用系统颜色和字体变量。

所有这些都需要越来越多的定制代码或使用大型库。让我们安装 Symfony 的控制台库,为我们抽象这些细节:

composer require symfony/console

Symfony 控制台应用由两个主要部分组成。第一个是入口脚本——类似于public/index.php。第二个是一个或多个“命令”类。

入口脚本如下所示:

require __DIR__ . '/vendor/autoload.php';

use Dotenv\Dotenv;
use Symfony\Component\Console\Application;

$dotenv = Dotenv::createImmutable(__DIR__);
$dotenv->load();

$application = new Application();

$commands = require __DIR__ . '/app/commands.php';

foreach ($commands as $command) {
    $application->add(new $command);
}

$application->run();

这是来自command.php

这是 Symfony 控制台官方文档中提供的示例的一个略微修改的形式。区别如下:

  1. 我们已经启用了在主应用中使用的 DotEnv 机密。

  2. 我们正在从另一个文件加载命令列表。

命令列表来自我们可以在app目录中定义的文件:

use App\Console\Commands\NameCommand;

return [
    NameCommand::class,
];

这是来自app/commands.php

像这样的文件背后的想法是,它提供了一种向终端脚本添加新命令的方法,而无需修改相同的脚本。你把终端脚本和框架一起分发是完全合理的,在里面可能会很吓人。

您不一定希望人们在那里挖来挖去,并潜在地破坏终端脚本的工作方式。这个文件为那些想在框架提供的命令之外添加他们自己的命令的人提供了一个相对来说牢不可破的体验。

Symfony 命令类如下所示:

namespace App\Console\Commands;

use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;

class NameCommand extends Command
{
    protected static $defaultName = 'name';

    protected $requireName = false;

    protected function configure()
    {
        $this
            ->setDescription('Prints the name in uppercase')
            ->setHelp('This command takes an optional name and returns it in uppercase. If no name is provided, "stranger" is used.')
            ->addArgument('name', $this->requireName ? InputArgument::REQUIRED : InputArgument::OPTIONAL, 'Optional name');
    }

    protected function execute(InputInterface $input, OutputInterface $output)
    {
        $output->writeln(strtoupper($input->getArgument('name') ?: 'Stranger'));

        return Command::SUCCESS;
    }
}

这是来自app/Console/Commands/NameCommand.php

每个命令有三个部分:

  1. $defaultName属性,确定终端中命令的名称

  2. configure方法,用于定义如何调用命令,如何描述自己,以及可以使用什么参数

  3. execute方法,命令的工作发生在这里

我已经添加了$requireName属性和一个例子,说明您的命令如何定义可选或必需的参数。

创建 Symfony 的控制台库提供的结构是可能的,但这可能很容易需要多个章节,收效甚微。

正如我们将在本书后面看到的,构建更大的框架和应用通常不是从头开始编写所有的代码,尤其是当它不是您试图实现的目标的重要部分时。

制定迁移命令

既然我们可以运行自己的命令,那么我们可以创建一个命令,将数据库代码结构保存到我们选择的数据库引擎中。我们将扩展我们的数据库的库,以允许创建数据库表。

让我们添加创建、修改和删除表的方法。将普通的 QueryBuilder 代码从迁移代码中分离出来可能会更好,所以让我们创建一组新的类来处理迁移:

namespace Framework\Database\Connection;

use Framework\Database\Migration\Migration;
use Framework\Database\QueryBuilder\QueryBuilder;
use Pdo;

abstract class Connection
{
    /**
     * Get the underlying Pdo instance for this connection
     */
    abstract public function pdo(): Pdo;

    /**
     * Start a new query on this connection
     */
    abstract public function query(): QueryBuilder;

    /**
     * Start a new migration to add a table on this connection
     */
    abstract public function createTable(string $table): Migration;
}

这是来自framework/Database/Connection/Connection.php

这个新方法应该启动一个新的“创建表”迁移。它是抽象的,所以每个连接都应该实现自己的版本:

namespace Framework\Database\Connection;

use Framework\Database\Migration\MysqlMigration;
use Framework\Database\QueryBuilder\MysqlQueryBuilder;
use InvalidArgumentException;
use Pdo;

class MysqlConnection extends Connection
{
    //...

    public function createTable(string $table): MysqlMigration
    {
        return new MysqlMigration($this, $table, 'create');
    }
}

这是来自framework/Database/Connection/MysqlConnection.php

类似于查询构建器,迁移将基于一个公共的抽象类。数据库迁移都是关于要添加到新表中或要在现有表中更改的不同字段类型:

namespace Framework\Database\Migration;

use Framework\Database\Connection\Connection;
use Framework\Database\Migration\Field\BoolField;
use Framework\Database\Migration\Field\DateTimeField;
use Framework\Database\Migration\Field\FloatField;
use Framework\Database\Migration\Field\IdField;
use Framework\Database\Migration\Field\IntField;
use Framework\Database\Migration\Field\StringField;
use Framework\Database\Migration\Field\TextField;

abstract class Migration
{
    protected array $fields = [];

    public function bool(string $name): BoolField
    {
        $field = $this->fields[] = new BoolField($name);
        return $field;
    }

    public function dateTime(string $name): DateTimeField
    {
        $field = $this->fields[] = new DateTimeField($name);
        return $field;
    }

    public function float(string $name): FloatField
    {
        $field = $this->fields[] = new FloatField($name);
        return $field;
    }

    public function id(string $name): IdField
    {
        $field = $this->fields[] = new IdField($name);
        return $field;
    }

    public function int(string $name): IntField
    {
        $field = $this->fields[] = new IntField($name);
        return $field;
    }

    public function string(string $name): StringField
    {
        $field = $this->fields[] = new StringField($name);
        return $field;
    }

    public function text(string $name): TextField
    {
        $field = $this->fields[] = new TextField($name);
        return $field;
    }

    abstract public function connection(): Connection;
    abstract public function execute(): void;
}

这是来自framework/Database/Migration/Migration.php

所有这些方法做的都差不多。所有重复的原因是为每个字段类型提供类型提示,以便开发工具可以在它们被错误使用时正确地分析和警告。

我不打算介绍所有的数据库引擎迁移实现,因为这些代码并不特别有趣。如果你很好奇,可以看看我没有提到的SqliteMigration类和Field子类…

这些字段中的每一个都基于一个抽象的Field类:

namespace Framework\Database\Migration\Field;

abstract class Field
{
    public string $name;
    public bool $nullable = false;

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

    public function nullable(): static
    {
        $this->nullable = true;
        return $this;
    }
}

这是来自framework/Database/Migration/Field/Field.php

我有点被这个架构撕裂了。一方面,所有可空的字段都可以在这里定义它们的nullable方法,这很酷——它节省了不必要的重复。另一方面,我不能定义一个人们可以用来指定默认列值的方法,因为我希望这些值是特定于类型的:

namespace Framework\Database\Migration\Field;

class BoolField extends Field
{
    public bool $default;

    public function default(bool $value): static
    {
        $this->default = $value;
        return $this;
    }
}

这是来自framework/Database/Migration/Field/BoolField.php

在这里,我们让default方法只接受布尔值,我们努力将其定义为Field 上的抽象方法,以定义Field上的无类型方法,然后可以从子类中正确类型化。

我们可以在Field上定义nullable方法,因为它不需要类型作为参数。

这两种方法(nullabledefault)都有另一个问题——不允许这些操作的字段需要特殊的异常处理:

namespace Framework\Database\Migration\Field;

use Framework\Database\Exception\MigrationException;

class IdField extends Field
{
    public function default()
    {
        throw new MigrationException('ID fields cannot have a default value');
    }
}

这是来自framework/Database/Migration/Field/IdField.php

这些字段本身不会对数据库产生很大影响。即使他们这样做了,他们也会遇到数据库引擎中的差异,这可能会导致另一个抽象级别(每个数据库引擎的字段)。

相反,我们的迁移类可以解释不同的字段类型:

namespace Framework\Database\Migration;

use Framework\Database\Connection\MysqlConnection;
use Framework\Database\Exception\MigrationException;
use Framework\Database\Migration\Field\Field;
use Framework\Database\Migration\Field\BoolField;
use Framework\Database\Migration\Field\DateTimeField;
use Framework\Database\Migration\Field\FloatField;
use Framework\Database\Migration\Field\IdField;
use Framework\Database\Migration\Field\IntField;
use Framework\Database\Migration\Field\StringField;
use Framework\Database\Migration\Field\TextField;

class MysqlMigration extends Migration
{
    protected MysqlConnection $connection;
    protected string $table;
    protected string $type;

    public function __construct(MysqlConnection $connection, string $table, string $type)
    {
        $this->connection = $connection;
        $this->table = $table;
        $this->type = $type;
    }

    public function execute()
    {
        $fields = array_map(fn($field) => $this->stringForField($field), $this->fields);
        $fields = join(',' . PHP_EOL, $fields);

        $primary = array_filter($this->fields, fn($field) => $field instanceof IdField);
        $primaryKey = isset($primary[0]) ? "PRIMARY KEY (`{$primary[0]->name}`)" : '';

        $query = "
            CREATE TABLE `{$this->table}` (
                {$fields},
                {$primaryKey}
            ) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8;
        ";

        $statement = $this->connection->pdo()->prepare($query);
        $statement->execute();
    }

    private function stringForField(Field $field): string
    {
        if ($field instanceof BoolField) {
            $template = "`{$field->name}` tinyint(4)";

            if ($field->nullable) {
                $template .= " DEFAULT NULL";
            }

            if ($field->default !== null) {
                $default = (int) $field->default;
                $template .= " DEFAULT {$default}";
            }

            return $template;
        }

        if ($field instanceof DateTimeField) {
            $template = "`{$field->name}` datetime";

            if ($field->nullable) {
                $template .= " DEFAULT NULL";
            }

            if ($field->default === 'CURRENT_TIMESTAMP') {
                $template .= " DEFAULT CURRENT_TIMESTAMP";
            } else if ($field->default !== null) {
                $template .= " DEFAULT '{$field->default}'";
            }

            return $template;
        }

        if ($field instanceof FloatField) {
            $template = "`{$field->name}` float";

            if ($field->nullable) {
                $template .= " DEFAULT NULL";
            }

            if ($field->default !== null) {
                $template .= " DEFAULT '{$field->default}'";
            }

            return $template;
        }

        if ($field instanceof IdField) {
            return "`{$field->name}` int(11) unsigned NOT NULL AUTO_INCREMENT";
        }

        if ($field instanceof IntField) {
            $template = "`{$field->name}` int(11)";

            if ($field->nullable) {
                $template .= " DEFAULT NULL";
            }

            if ($field->default !== null) {
                $template .= " DEFAULT '{$field->default}'";
            }

            return $template;
        }

        if ($field instanceof StringField) {
            $template = "`{$field->name}` varchar(255)";

            if ($field->nullable) {
                $template .= " DEFAULT NULL";
            }

            if ($field->default !== null) {
                $template .= " DEFAULT '{$field->default}'";
            }

            return $template;
        }

        if ($field instanceof TextField) {
            return "`{$field->name}` text";
        }

        throw new MigrationException("Unrecognised field type for {$field->name}");
    }
}

这是来自framework/Database/Migration/MysqlMigration.php

这段代码的大部分存在于stringForField方法中,所以让我们从那里开始。它接受一个Field(可以是任何一个Field子类,比如StringFieldBoolField)并生成 MySQL 兼容的语法来创建字段。

这不是一个详尽的参考。这段代码可能没有考虑到很多边缘情况,但是对于 80%的用例来说已经足够了。

字段类定义自己的语法会更好——以避免所有这些instanceof切换——但是不同引擎之间的语法不同。我们需要能同时理解所有引擎的字段,或者每个引擎一个字符串字段…

execute方法为每个字段调用stringForField,生成需要添加的字段的完整列表。它用 MySQL 版本的CREATE TABLE语句包装了这些。同样,我们可以做很多事情来扩展它:

  • 处理自定义字符集

  • 处理不同的 MySQL 表类型

  • 处理自定义自动编号偏移

您可以随意扩展它来处理您想要的任意多的这些内容。你有一个很好的起点!

您可以像这样使用这个迁移代码:

$orders = $connection->createTable('orders');
$orders->id('id');
$orders->int('quantity')->default(1);
$orders->float('price')->nullable();
$orders->bool('is_confirmed')->default(false);
$orders->dateTime('ordered_at')->default('CURRENT_TIMESTAMP');
$orders->text('notes');
$orders->execute();

让我们把它放在一个“迁移”文件中,这样我们就可以从命令行运行它(以及其他迁移):

use Framework\Database\Connection\Connection;

class CreateOrdersTable
{
    public function migrate(Connection $connection)
    {
        $table = $connection->createTable('orders');
        $table->id('id');
        $table->int('quantity')->default(1);
        $table->float('price')->nullable();
        $table->bool('is_confirmed')->default(false);
        $table->dateTime('ordered_at')->default('CURRENT_TIMESTAMP');
        $table->text('notes');
        $table->execute();
    }
}

这是来自database/migrations/001_CreateOrdersTable.php

我可以想象创建多个这样的文件,每个文件描述一个对数据库的更改。通过这种方式,我们可以跟踪数据库随时间的变化,并了解当所有迁移按顺序运行时它应该是什么样子。

我们应该创建一个新命令,并将其添加到应用知道的命令列表中。该命令需要

  1. 查找所有迁移文件。

  2. 打开到数据库的连接。

  3. “迁移”每个迁移文件,为其提供活动连接。

也许是这样的:

namespace Framework\Database\Command;

use Framework\Database\Factory;
use Framework\Database\Connection\MysqlConnection;
use Framework\Database\Connection\SqliteConnection;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;

class MigrateCommand extends Command
{
    protected static $defaultName = 'migrate';

    protected function configure()
    {
        $this
            ->setDescription('Migrates the database')
            ->setHelp('This command looks for all migration files and runs them');
    }

    protected function execute(InputInterface $input, OutputInterface $output)
    {
        $current = getcwd();
        $pattern = 'database/migrations/*.php';

        $paths = glob("{$current}/{$pattern}");

        if (count($paths) === 0) {
            $this->writeln('No migrations found');
            return Command::SUCCESS;
        }

        $factory = new Factory();

        $factory->addConnector('mysql', function($config) {
            return new MysqlConnection($config);
        });

        $connection = $factory->connect([
            'type' => 'mysql',
            'host' => '127.0.0.1',
            'port' => '3306',
            'database' => 'pro-php-mvc',
            'username' => 'root',
            'password' => '',
        ]);

        foreach ($paths as $path) {
            [$prefix, $file] = explode('_', $path);
            [$class, $extension] = explode('.', $file);

            require $path;

            $obj = new $class();
            $obj->migrate($connection);
        }

        return Command::SUCCESS;
    }
}

这是来自framework/Database/Command/MigrateCommand.php

该命令在假设用户在同一个文件夹中时将运行command.php脚本的情况下运行:通过使用getcwd()函数。这个函数返回 PHP 正在运行的当前文件夹路径。

在此基础上,我们在相对于当前路径的database/migrations文件夹中寻找所有迁移文件。如果找不到,那么我们甚至不用费心去连接数据库。

如果有迁移要运行,我们可以打开一个连接,并将其传递给每个迁移类的migrate()方法。

这种方法的一个问题是它硬编码了使用 MySQL 的选择。我们真的需要一种定义“默认”连接的方法,这样我们就不需要对选择进行硬编码。

我们将在第十一章中构建一个健壮的配置解决方案,但是现在,我们可以使用稍微简单一点的东西:

return [
    'default' => 'mysql',
    'mysql' => [
        'type' => 'mysql',
        'host' => '127.0.0.1',
        'port' => '3306',
        'database' => 'pro-php-mvc',
        'username' => 'root',
        'password' => '',
    ],
    'sqlite' => [
        'type' => 'sqlite',
        'path' => __DIR__ . '/../database/database.sqlite',
    ],
];

这是来自config/database.php

现在,我们可以在任何需要数据库凭证的地方使用这个“配置”文件:

namespace App\Http\Controllers;

use Framework\Database\Factory;
use Framework\Database\Connection\MysqlConnection;
use Framework\Database\Connection\SqliteConnection;

class ShowHomePageController
{
    public function handle()
    {
        $factory = new Factory();

        $factory->addConnector('mysql', function($config) {
            return new MysqlConnection($config);
        });

        $factory->addConnector('sqlite', function($config) {
            return new SqliteConnection($config);
        });

        $config = require __DIR__ . '/../../../config/database.php';

        $connection = $factory->connect($config[$config['default']]);

        $product = $connection
            ->query()
            ->select()
            ->from('products')
            ->first();

        return view('home', [
            'number' => 42,
            'featured' => $product,
        ]);
    }
}

这是来自app/Http/Controllers/ShowHomePageController.php

它仍然不完美——我们仍然必须每次都向工厂添加连接回调——但至少配置决定了要使用的理想连接器。我们将在第十章中提出一个更好的“建设”工厂的方法。

我们可以重构MigrateCommand类来使用类似的配置方法:

protected function execute(InputInterface $input, OutputInterface $output)
{
    $current = getcwd();
    $pattern = 'database/migrations/*.php';

    $paths = glob("{$current}/{$pattern}");

    if (count($paths) < 1) {
        $this->writeln('No migrations found');
        return Command::SUCCESS;
    }

    $connection = $this->connection();

    foreach ($paths as $path) {
        [$prefix, $file] = explode('_', $path);
        [$class, $extension] = explode('.', $file);

        require $path;

        $obj = new $class();
        $obj->migrate($connection);
    }

    return Command::SUCCESS;
}

private function connection(): Connection
{
    $factory = new Factory();

    $factory->addConnector('mysql', function($config) {
        return new MysqlConnection($config);
    });

    $factory->addConnector('sqlite', function($config) {
        return new SqliteConnection($config);
    });

    $config = require getcwd() . '/config/database.php';

    return $factory->connect($config[$config['default']]);
}

这是来自framework/Database/Command/MigrateCommand.php

在运行该命令之前,我们需要将其添加到已知命令列表中:

use App\Console\Commands\NameCommand;
use Framework\Database\Command\MigrateCommand;

return [
    MigrateCommand::class,
    NameCommand::class,
];

这是来自app/commands.php

改变表格

迁移不仅仅是为了创建表。他们还需要能够通过在应用需要时更改和删除列来修改表。

我们可以增加现有的迁移来支持这一点,从改变字段开始

/**
 * Start a new migration to add a table on this connection
 */
abstract public function alterTable(string $table): Migration;

这是来自framework/Database/Connection/Connection.php

…在不同的引擎子类中:

public function alterTable(string $table): MysqlMigration
{
    return new MysqlMigration($this, $table, 'alter');
}

这是来自framework/Database/Connection/MysqlConnection.php

我们还需要改变字段定义的方式,允许将它们添加到现有的表中,并允许对它们进行修改:

private function stringForField(Field $field): string
{
    $prefix = '';

    if ($this->type === 'alter') {
        $prefix = 'ADD';
    }

    if ($field->alter) {
        $prefix = 'MODIFY';
    }

    if ($field instanceof BoolField) {
        $template = "{$prefix} `{$field->name}` tinyint(4)";

        if ($field->nullable) {
            $template .= " DEFAULT NULL";
        }

        if ($field->default !== null) {
            $default = (int) $field->default;
            $template .= " DEFAULT {$default}";
        }

        return $template;
    }

    if ($field instanceof DateTimeField) {
        $template = "{$prefix} `{$field->name}` datetime";

        if ($field->nullable) {
            $template .= " DEFAULT NULL";
        }

        if ($field->default === 'CURRENT_TIMESTAMP') {
            $template .= " DEFAULT CURRENT_TIMESTAMP";
        } else if ($field->default !== null) {
            $template .= " DEFAULT '{$field->default}'";
        }

        return $template;
    }

    if ($field instanceof FloatField) {
        $template = "{$prefix} `{$field->name}` float";

        if ($field->nullable) {
            $template .= " DEFAULT NULL";
        }

        if ($field->default !== null) {
            $template .= " DEFAULT '{$field->default}'";
        }

        return $template;
    }

    if ($field instanceof IdField) {
        return "{$prefix} `{$field->name}` int(11) unsigned NOT NULL AUTO_INCREMENT";
    }

    if ($field instanceof IntField) {
        $template = "{$prefix} `{$field->name}` int(11)";

        if ($field->nullable) {
            $template .= " DEFAULT NULL";
        }

        if ($field->default !== null) {
            $template .= " DEFAULT '{$field->default}'";
        }

        return $template;
    }

    if ($field instanceof StringField) {
        $template = "{$prefix} `{$field->name}` varchar(255)";

        if ($field->nullable) {
            $template .= " DEFAULT NULL";
        }

        if ($field->default !== null) {
            $template .= " DEFAULT '{$field->default}'";
        }

        return $template;
    }

    if ($field instanceof TextField) {
        return "{$prefix} `{$field->name}` text";
    }

    throw new MigrationException("Unrecognised field type for {$field->name}");
}

这是来自framework/Database/Migration/MysqlMigration.php

唯一显著的变化是,当迁移是变更时,我们确定了每个字段定义的前缀(或者是ADD或者是MODIFY)。

类似地,我们需要重构execute方法,根据迁移是创建还是改变表来生成非常不同的查询:

public function execute()
{
    $fields = array_map(fn($field) => $this->stringForField($field), $this->fields);

    $primary = array_filter($this->fields, fn($field) => $field instanceof IdField);
    $primaryKey = isset($primary[0]) ? "PRIMARY KEY (`{$primary[0]->name}`)" : '';

    if ($this->type === 'create') {
        $fields = join(PHP_EOL, array_map(fn($field) => "{$field},", $fields));

        $query = "
            CREATE TABLE `{$this->table}` (
                {$fields}
                {$primaryKey}
            ) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8;
        ";
    }

    if ($this->type === 'alter') {
        $fields = join(PHP_EOL, array_map(fn($field) => "{$field};", $fields));

        $query = "
            ALTER TABLE `{$this->table}`
            {$fields}
        ";
    }

    $statement = $this->connection->pdo()->prepare($query);
    $statement->execute();
}

这是来自framework/Database/Migration/MysqlMigration.php

字段之间也需要不同的分隔符:,用于创建查询,而;用于修改查询。SQLite 迁移类有类似的变化,但是它也限制改变列(因为 SQLite 不允许这种改变)。

最后,我们可以通过添加一个新的Migration方法来删除列:

abstract public function dropColumn(string $name): static;

这是来自framework/Database/Migration/Migration.php

我们希望这是抽象的,因为不同的引擎对删除列有自己的限制。例如,SQLite 不允许删除列,所以在这种情况下我们可以抛出一个异常。

从技术上讲,可以通过重新创建表并转移剩余的行数据来删除一列,但这很麻烦…

protected MysqlConnection $connection;
protected string $table;
protected string $type;
protected array $drops = [];

//...

public function dropColumn(string $name): static
{
    $this->drops[] = $name;
    return $this;
}

这是来自framework/Database/Migration/MysqlMigration.php

然后,我们需要允许将这些“丢弃”添加到变更查询中:

public function execute()
{
    $fields = array_map(fn($field) => $this->stringForField($field), $this->fields);

    $primary = array_filter($this->fields, fn($field) => $field instanceof IdField);
    $primaryKey = isset($primary[0]) ? "PRIMARY KEY (`{$primary[0]->name}`)" : '';

    if ($this->type === 'create') {
        $fields = join(PHP_EOL, array_map(fn($field) => "{$field},", $fields));

        $query = "
            CREATE TABLE `{$this->table}` (
                {$fields}
                {$primaryKey}
            ) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8;
        ";
    }

    if ($this->type === 'alter') {
        $fields = join(PHP_EOL, array_map(fn($field) => "{$field};", $fields));
        $drops = join(PHP_EOL, array_map(fn($drop) => "DROP COLUMN `{$drop}`;", $this->drops));

        $query = "
            ALTER TABLE `{$this->table}`
            {$fields}
            {$drops}
        ";
    }

    $statement = $this->connection->pdo()->prepare($query);
    $statement->execute();
}

这是来自framework/Database/Migration/MysqlMigration.php

我们就到此为止吧。在这一章中我们已经取得了很多成就,是时候反思和实验了。

警告

这是一个很好的起点,但它不是防弹的。有许多方法可以改进我们在本章中构建的内容,并避免常见的错误情况:

  1. 通过支持更多的数据库引擎

  2. 通过扩展查询语法以允许分组和更多类型的条件以及“原始”查询片段

  3. 通过创建一个“迁移”数据库表来跟踪已经运行的迁移,这样我们就不会试图重新创建现有的表

  4. 通过添加“路径”助手,这样我们就不需要依赖getcwd()来寻找配置和迁移

  5. 通过形式化定义新连接器的接口

  6. 通过对更多的配置参数进行类型检查,以便我们在尝试使用它们进行连接之前确定数据类型和形状

  7. 通过验证迁移文件名或使类名推理更加健壮

在我们已经取得的成就之后,我会考虑所有这些有趣的后续步骤。继续尝试其中的一两个…

职业选手是如何做到的

我向您展示的大部分内容都受到了 Laravel 等框架的启发。Laravel 有一个广泛的数据库的库、迁移系统和其他好东西,使使用数据库成为一种愉快的体验。

它有几个不同的命令行工具,用于用虚拟数据播种数据库和从头开始运行所有迁移(因此,在再次运行迁移之前,您不必手动“清空数据库”)。

此外,Laravel 的迁移看起来像这样:

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

class CreateUsersTable extends Migration
{
    public function up()
    {
        Schema::create('users', function (Blueprint $table) {
            $table->id();
            $table->string('name');
            $table->string('email')->unique();
            $table->timestamp('email_verified_at')->nullable();
            $table->string('password');
            $table->rememberToken();
            $table->timestamps();
        });
    }

    public function down()
    {
        Schema::dropIfExists('users');
    }
}

…其中,up()方法在迁移“正常”运行时运行,而down()方法在迁移反向或请求“全新”运行时运行。

其他数据库的库有不同的(定义迁移的非 PHP 方法)。在 Propel 中定义一个“表”如下所示:

<database name="bookstore" defaultIdMethod="native">
  <table name="book" description="Book Table">
    <column name="id" type="integer" primaryKey="true" autoIncrement="true" />
    <column name="title" type="varchar" required="true" primaryString="true" />
    <column name="isbn" required="true" type="varchar" size="24" phpName="ISBN" />
  </table>
</database>

这不是我最喜欢的定义表结构的方法,但是很有效。尽管如此,其他数据库的库(如 SilverStripe 中的那个)没有任何可见的迁移。在那里,您定义了“内联”表结构:

use SilverStripe\ORM\DataObject;

class Player extends DataObject
{
    private static $db = [
        'PlayerNumber' => 'Int',
        'FirstName' => 'Varchar(255)',
        'LastName' => 'Text',
        'Birthday' => 'Date'
    ];
}

执行的迁移遵循一组约定,它们可以执行破坏性的操作,如删除表或列。在修改内联表定义之前,您需要仔细研究它们的文档…

这些类型的框架通常有一小组包罗万象的“缓存”命令,用于构建和保存应用运行所需的一切。

摘要

在这一章中,我们做了许多繁重的工作。我们使用 PDO 构建了一个数据库的库,抽象了连接和查询过程。

我们添加了命令行支持,因此我们的框架可以开始定义在终端中启动的有用流程。我们还构建了一种在代码中定义数据库结构的方法,并将这些结构连接到命令行。

在下一章,我们将更进一步,在 PHP 对象中构建数据库行的表示。我们将构建自己的 ORM。

七、构建 ORM 库

在前一章中,我们为数据库工作打下了坚实的基础。我们有一个基于驱动的数据库的库,通过它我们可以以一种与引擎无关的方式执行 SQL 查询。

在这一章中,我们将更进一步。我们将构建一个对象关系映射器(或 ORM)库,将数据库行和表表示为 PHP 对象。

我们已经构建了足够的框架,然后嗖!我们可以对网站的设计和功能做很多改进。在本书中,我不会花时间去做这些,而是在两章之间开始做这些工作。

同时…

在我们深入 ORM 库之前,我想谈谈自上一章的代码以来发生了什么变化。

你可以在 GitHub 上找到我将要讨论的变化的代码。对于章节之间出现的代码,我将遵循这种between-chapter-x-and-y模式。

重新设计的注册、主页和产品页面

我认为是时候改进这些页面的视觉设计了。我不是设计师,但我认为 Tailwind 提供了一系列工具来简化视觉设计。

我去了一趟 undraw.co,找到了一个火箭的插图,并创作了几个不同的“特征”包括。我还在主页上列出了产品,并添加了一个订购按钮…

img/299823_2_En_7_Figa_HTML.jpg

新的主页设计

注册页面进行了改版,使用了更小的“特性”include,现在包括了一个登录表单。

img/299823_2_En_7_Figb_HTML.jpg

新注册页面

最后,我更改了产品页面,增加了一张订单。它将需要一些登录只显示订单的认证用户,但它是在一个很好的地方,我们在它的工作。

img/299823_2_En_7_Figc_HTML.jpg

新订单页面

新迁移标志和播种

我认为是时候在迁移中构建 products 表,并用示例产品填充它了。用示例数据填充迁移表的过程称为播种。这在代码中并没有很大的不同:

use Framework\Database\Connection\Connection;

class SeedProducts
{
    public function migrate(Connection $connection)
    {
        $products = [
            [
                'name' => 'Space Tour',
                'description' => 'Take a trip on a rocket ship. Our tours are out of this world. Sign up now for a journey you won&apos;t soon forget.',
            ],
            [
                'name' => 'Large Rocket',
                'description' => 'Need to bring some extra space-baggage? Everyone asking you to bring back a moon rock for them? This is the rocket you want...',
            ],
            [
                'name' => 'Small Rocket',
                'description' => 'Space exploration is expensive. This rocket comes in under budget and atmosphere.',
            ],
        ];

        foreach ($products as $product) {
            $connection
                ->query()
                ->from('products')
                ->insert(['name', 'description'], $product);
        }
    }
}

这是来自database/migrations/006_SeedProducts.php

这段代码依赖于对数据库结构和库的一些重要更改。添加产品表的迁移如下所示:

use Framework\Database\Connection\Connection;

class CreateProductsTable
{
    public function migrate(Connection $connection)
    {
        $table = $connection->createTable('products');
        $table->id('id');
        $table->string('name');
        $table->text('description');
        $table->execute();
    }
}

这是来自database/migrations/005_CreateProductsTable.php

下一个重大变化是引入了插入新数据库记录的方法:

protected array $values;

public function insert(array $columns, array $values): int
{
    $this->type = 'insert';
    $this->columns = $columns;
    $this->values = $values;

    $statement = $this->prepare();

    return $statement->execute($values);
}

public function prepare(): PdoStatement
{
    // ...

    if ($this->type === 'insert') {
        $query = $this->compileInsert($query);
    }

    // ...
}

protected function compileInsert(string $query): string
{
    $joinedColumns = join(', ', $this->columns);
    $joinedPlaceholders = join(', ', array_map(fn($column) => ":{$column}", $this->columns));

    $query .= " INSERT INTO {$this->table} ({$joinedColumns}) VALUES ({$joinedPlaceholders})";

    return $query;
}

这是来自framework/Database/QueryBuilder/QueryBuilder.php

这段代码与我们之前使用的 SQL“select”代码非常相似,但是它使用了 PDO 预准备语句的一个特性,其中通过使用占位符来插入值。以下 SQL 语句告诉 PDO 需要占位符中的值:

INSERT INTO products (name, description) VALUES (:name, :description)

这些值通过以下代码发送:

$sql = "...the above SQL statement";

$values = [
    'name' => $name,
    'description' => $description,
];

$affectedRows = $pdo->prepare($sql)->execute($values);

我还修改了protected array $columns属性以期望一个数组而不是一个字符串,并对select方法做了相应的修改。

手动删除表来验证这些更改有点烦人,所以我给migrate命令添加了一个新标志,所以我们现在可以开始了

php command.php migrate --fresh

migrate 命令现在还创建了一个migrations表,以便迁移仅在尚未运行时才运行(或者如果存在--fresh标志):

protected function configure()
{
    $this
        ->setDescription('Migrates the database')
        ->addOption('fresh', null, InputOption::VALUE_NONE, 'Delete all tables before running the migrations')
        ->setHelp('This command looks for all migration files and runs them');
}

protected function execute(InputInterface $input, OutputInterface $output)
{
    // ...get the migration files and connection

    if ($input->getOption('fresh')) {
        $output->writeln('Dropping existing database tables');

        $connection->dropTables();
        $connection = $this->connection();
    }

    if (!$connection->hasTable('migrations')) {
        $output->writeln('Creating migrations table');
        $this->createMigrationsTable($connection);
    }

    foreach ($paths as $path) {
        // ...run the migration

        $connection
            ->query()
            ->from('migrations')
            ->insert(['name'], ['name' => $class]);
    }

    return Command::SUCCESS;
}

private function createMigrationsTable(Connection $connection)
{
    $table = $connection->createTable('migrations');
    $table->id('id');
    $table->string('name');
    $table->execute();
}

这是来自framework/Database/Command/MigrateCommand.php

这意味着我还必须向Connection类添加方法,以找到表并删除它们:

/**
 * Return a list of table names on this connection
 */
abstract public function getTables(): array;

/**
 * Find out if a table exists on this connection
 */
abstract public function hasTable(string $name): bool;

/**
 * Drop all tables in the current database
 */
abstract public function dropTables(): int;

这是来自framework/Database/Connection/Connection.php

这些方法因数据库引擎而异,所以如果您感兴趣的话,可以看看MysqlConnectionSqliteConnection是如何实现这些方法的。

SqliteConnection->dropTables方法有一个警告:只有当文件路径不是:memory:时,它才会删除表。你看,有可能有一个内存中的 SQLite 数据库,它不存储在文件系统中,并且需要一个更复杂的dropTables实现。

验证错误和会话

我还对验证错误处理做了一个小改动。现在可以选择在会话中存储错误的名称。这使得在同一页面上有多个表单并分别显示表单错误成为可能。

控制器

为了更好地设计表单,我不得不修改一些控制器代码。这主要是一个将csrf调用移动到控制器(以便它们可以在同一页面上的多个表单中使用)并从控制器发送表单动作(路由)的过程。

我还添加了一种向QueryBuilder类发送“where”子句的方法,并改变了单行的返回方式:

protected array $wheres = [];

public function all(): array
{
    $statement = $this->prepare();
    $statement->execute($this->getWhereValues());

    return $statement->fetchAll(Pdo::FETCH_ASSOC);
}

protected function getWhereValues(): array
{
    $values = [];

    if (count($this->wheres) === 0) {
        return $values;
    }

    foreach ($this->wheres as $where) {
        $values[$where[0]] = $where[2];
    }

    return $values;
}

public function prepare(): PdoStatement
{
    $query = '';

    if ($this->type === 'select') {
        $query = $this->compileSelect($query);
        $query = $this->compileWheres($query);
        $query = $this->compileLimit($query);
    }

    // ...
}

protected function compileWheres(string $query): string
{
    if (count($this->wheres) === 0) {
        return $query;
    }

    $query .= ' WHERE';

    foreach ($this->wheres as $i => $where) {
        if ($i > 0) {
            $query .= ', ';
        }

        [$column, $comparator, $value] = $where;

        $query .= " {$column} {$comparator} :{$column}";
    }

    return $query;
}

public function first(): array
{
    $statement = $this->take(1)->prepare();
    $statement->execute($this->getWhereValues());

    $result = $statement->fetchAll(Pdo::FETCH_ASSOC);

    if (count($result) === 1) {
        return $result[0];
    }

    return null;
}

这是来自framework/Database/QueryBuilder/QueryBuilder.php

这使用相同的“:placeholder”语法来定义 where 子句。我们可以提供许多类似的功能,以便更容易地处理空值和“likes”等等。

这些是自上一章以来的重大变化。花些时间浏览一下代码,看看我是如何解决上一章末尾为您设置的挑战的。

为什么不仅仅是数据库的库呢?

正如你所看到的,从上一章和我从那以后所做的工作来看,数据库的库已经相当强大了。当然,我们可以把它包装得更好一点——这样当我们想要一个新的连接时就没有那么多样板文件了——但是它已经很好地使用和扩展了。

那么,为什么我们还需要更多呢?

作为 PHP 开发人员,我们所做的大部分工作都涉及到从数据库中存储和检索数据。这分两个阶段发生:

  1. 从数据库中提取数据或向其中写入数据

  2. 转换需要写入数据库或刚从数据库中读取的数据

除此之外,我们经常需要通过外键对数据库数据的相关位进行分组。关系数据库引擎通常提供一种基于这些类型的关系进行选择的方法,但是它们不能以一种有用的方式表示相关的行。

img/299823_2_En_7_Figd_HTML.jpg

令人困惑的查询…

虽然更复杂的查询确实有助于避免这种令人困惑的情况(以及随之而来的 SQL 查询错误),但是访问两种相关的数据类型并没有那么困难。

如果有一些东西可以对数据库行进行查询,并返回代表每个数据库行的单独的数据类型(或对象),那就更好了。

这些对象也可以表示对象列表(映射到底层数据库行),但是我们可以利用 PHP 结构(比如迭代器和 getter/setter)来简化底层行的操作。

我们的数据库的库非常适合从数据库中提取数据和向数据库中写入数据,但是它的职责没有扩展到将行(和相关的行)表示为 PHP 对象。

这就是术语“对象关系映射器”的由来。ORM 的核心是表示对象(表示数据库行)之间的关系。

我们可以写几本书来介绍构建 ORM 的业务,所以将我们的努力限制在几个核心特性上是明智的:

  1. 一个抽象的基础 PHP Model类,每个“数据类型”都可以扩展和定制。

  2. Model应该能够通过 SQL“where”子句找到多条记录或一行。

  3. Model应该提供对行的底层属性的对象访问,以及为属性定义 getters 或 setters 的能力。

  4. Model应该能够判断它是一个新行(将要创建)还是一个现有行的表示。

  5. 应插入新行;现有的行表示应该更新现有的行。

  6. Model应该允许将行值转换为非字符串类型。

  7. Model应该允许检索简单的关系数据(比如“有一个”、“有许多”和“属于”)。

“有……一个吗?”

这些关系类型并不特定于 PHP 或 ORM,但是理解它们很重要。当链接数据库行时,我们可以根据数据的定义和含义来描述它们之间的关系。

“有一个”关系是您关心的行在另一行(甚至可能是另一个表中的一行)中被引用的一种方式。一个例子可能会有所帮助:

  1. 桃子公主是蘑菇王国的统治者。

  2. 蘑菇王国属于桃子公主。

  3. 所以桃公主“有一个”王国。

    我们从历史中知道,统治者可以统治多个王国。在 Peach 公主统治多个王国的情况下,我们可能需要将这种关系表示为一对多。一个桃公主到多个王国…

它可以用以下数据库结构表示:

img/299823_2_En_7_Fige_HTML.jpg

“有一个”关系

“属于”关系正好相反。我们说桃子公主“有一个”蘑菇王国,但我们也可以说蘑菇王国“属于”桃子公主。

“多”关系是指多行属于一行。

img/299823_2_En_7_Figf_HTML.jpg

“有很多”

这种关系有点不同:

  1. 超级马里奥奥德赛发生在几个不同的王国。

  2. 每个王国“属于”游戏超级马里奥奥德赛。

  3. 超级马里奥奥德赛“有很多”王国。

    这些王国也有可能出现在多个游戏中,在这种情况下,这种关系将成为多对多的关系。多个王国隶属于多个游戏。我们不打算冒险深入 ORM 中的关系,因为实现起来很复杂,而且收益很少。

把它放在一起

让我们从抽象基类Model开始。我们可以让它设置默认连接,并为订单和产品创建几次子类:

namespace Framework\Database;

use Framework\Database\Connection\Connection;
use Framework\Database\Connection\MysqlConnection;
use Framework\Database\Connection\SqliteConnection;
use Framework\Database\Exception\ConnectionException;

abstract class Model
{
    protected Connection $connection;

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

    public function getConnection(): Connection
    {
        if (!isset($this->connection)) {
            $factory = new Factory();

            $factory->addConnector('mysql', function($config) {
                return new MysqlConnection($config);
            });

            $factory->addConnector('sqlite', function($config) {
                return new SqliteConnection($config);
            });

            $config = require basePath() . 'config/database.php';

            $this->connection = $factory->connect($config[$config['default']]);
        }

        return $this->connection;
    }
}

这是来自framework/Database/Model.php

我们添加的第一个方法允许覆盖模型将使用的数据库连接,但也提供了一个默认的连接。这将为我们节省相当多的样板文件。

当涉及到建立新的数据库连接时,它仍然不是最终的解决方案,但是我们将在下一章看到它是什么样子的!

我们可以用自定义数据类型扩展这个Model类:

namespace App\Models;

use Framework\Database\Model;

class Product extends Model
{
}

这是来自app/Models/Product.php

现在,我们可以使用下面的代码访问默认的数据库连接:

(new Product())->getConnection()

接下来,我们需要弄清楚哪个数据库表应该用于Product模型。我们可以从类名中推断出表名,但我认为我们可以明确地说明这一点:

protected string $table;

public function setTable(string $table): static
{
    $this->table = $table;
    return $this;
}

public function getTable(): string
{
    if (!isset($this->table)) {
        throw new Exception('$table is not set and getTable is not defined');
    }

    return $this->table;
}

这是来自framework/Database/Model.php

我认为表名最好是显式的,因为这使得系统总体上更加灵活——因为覆盖显式的表名更容易。它还通过自动化导致更少的混乱和错误(像奇数单数到复数的转换情况)。

这意味着我们的产品类可以覆盖getTable方法(如果它需要动态确定一个表名)或者定义一个$table属性:

namespace App\Models;

use Framework\Database\Model;

class Product extends Model
{
    protected string $table = 'products';
}

这是来自app/Models/Product.php

让我们也为一个Model创造一种方法,用一系列属性来水合:

protected array $attributes;

public static function with(array $attributes = []): static
{
    $model = new static();
    $model->attributes = $attributes;

    return $model;
}

这是来自framework/Database/Model.php

绑定到查询构建器

现在,我们可以将一行或多行装载到这些数据库对象中。我们使用我在章节和我插入到表格中的三行之间创建的005_CreateProductsTable.php迁移:

public function all(): array
{
    if (!isset($this->type)) {
        $this->select();
    }

    // ...
}

public function first(): array
{
    if (!isset($this->type)) {
        $this->select();
    }

    // ...
}

public static function query(): mixed
{
    $model = new static();

    return $model->getConnection()->query()
        ->from($model->getTable());
}

public static function __callStatic(string $method, array $parameters = []): mixed
{
    return static::query()->$method(...$parameters);
}

这是来自framework/Database/Model.php

这个__callStatic方法意味着我们可以调用我们的Product类上的任何QueryBuilder方法,并且该方法将被转发给查询构建器的一个实例。例如,使用章节间实现的all方法,我们可以获取所有产品:

Product::all();
// → [['id' => '1', 'name' => 'Large Rocket', ...], ...]

我们甚至可以使用“where”子句找到单行:

Product::where('id', 1)->first();
// → ['id' => '1', 'name' => 'Large Rocket', ...]

这很好,但是它没有将行作为模型对象提供给我们,所以我们需要弄清楚如何做到这一点。一种方法是为我们的每个数据类型创建一个子类MysqlQueryBuilderSqliteQueryBuilder,并在我们的每个定制模型类型中覆盖newQuery

对我来说,那听起来像是许多可怕的工作。相反,我们可以定义一个装饰类来打包我们的行:

namespace Framework\Database;

use Framework\Database\QueryBuilder\QueryBuilder;

class ModelCollector
{
    private QueryBuilder $builder;
    private string $class;

    public function __construct(QueryBuilder $builder, string $class)
    {
        $this->builder = $builder;
        $this->class = $class;
    }

    public function __call(string $method, array $parameters = []): mixed
    {
        $result = $this->builder->$method(...$parameters);

        // in case it's a fluent method...
        if ($result instanceof QueryBuilder) {
            $this->builder = $result;
            return $this;
        }

        return $result;
    }

    public function first()
    {
        $class = $this->class;

        $row = $this->builder->first();

        if (!is_null($row)) {
            $row = $class::with($row);
        }

        return $row;
    }

    public function all()
    {
        $class = $this->class;

        $rows = $this->builder->all();

        foreach ($rows as $i => $row) {
            $rows[$i] = $class::with($row);
        }

        return $rows;
    }
}

这是来自framework/Database/ModelCollector.php

这个装饰器获取了一个QueryBuilder的实例和一个模型的类名,这样它就可以拦截所有对firstall方法的调用,并且每次都返回水合模型。当我们创建新的查询时,我们需要使用这个“收集器”:

public static function query(): ModelCollector|QueryBuilder
{
    $model = new static();
    $query = $model->getConnection()->query();

    return (new ModelCollector($query, static::class))
        ->from($model->getTable());
}

这是来自framework/Database/Model.php

当我们查询产品行时,它们现在将是Product实例,而不是数组:

Product::all();
// → [object(App\Models\Product), ...]

Product::where('id', 1)->first();
// → object(App\Models\Product)

Getters 和 Setters

这比使用普通的旧数组要好得多。我们引入了一个新的问题,那就是我们不再能够容易地获得行属性。我们需要创建一个通用的 getter 方法,以便可以使用对象查找语法来访问属性:

public function __get(string $property): mixed
{
    if (isset($this->attributes[$property])) {
        return $this->attributes[$property];
    }

    return null;
}

public function __set(string $property, $value)
{
    $this->attributes[$property] = $value;
}

这是来自framework/Database/Model.php

您可能想更进一步,当__get方法试图查找数据库行中不存在的属性时,或者当__set方法试图设置未知行的值时,您可能会抛出异常。我认为,要正确地做到这一点,我们需要存储数据库行的原始列名,或者在每个模型类中有一个已知列的列表…

给定这些__set__get方法,我们现在可以使用对象查找语法来访问属性:

Product::where('id', 1)->first()->name;
// → 'Large Rocket'

如果我们想让Product定义自定义的 getters 和 setters,会怎么样呢?让我们定义一个以大写字母开始每个单词的name getter 和一个将描述字符数限制为 50 的description setter:

protected function setDescriptionAttribute(string $value)
{
    $limit = 50;
    $ending = '...';

    if (mb_strwidth($value, 'UTF-8') <= $limit) {
        return $value;
    }

    return rtrim(mb_strimwidth($value, 0, $limit, '', 'UTF-8')) . $ending;
}

这是来自app/Models/Product.php

Attribute作为这些方法的后缀更安全一些,因为我们在下面的代码片段中动态地组合了它们的名称。“属性”与数据库行属性数组的内部名称相关。

我们需要根据被访问的属性的名称来判断是否应该调用这些方法:

public function __get(string $property): mixed
{
    $getter = 'get' . ucfirst($property) . 'Attribute';

    if (method_exists($this, $getter)) {
        return $this->$getter($this->attributes[$property] ?? null);
    }

    if (isset($this->attributes[$property])) {
        return $this->attributes[$property];
    }

    return null;
}

public function __set(string $property, $value)
{
    $setter = 'set' . ucfirst($property) . 'Attribute';

    if (method_exists($this, $setter)) {
        $this->attributes[$property] = $this->$setter($value);
    }

    $this->attributes[$property] = $value;
}

这是来自framework/Database/Model.php

这些神奇的方法检查名为get*Attributeset*Attribute的方法是否存在,而是调用它们。如果我们愿意,我们仍然可以在我们的 getters 和 setters 中访问$this->attributes,但是大多数时候我们只需要设置或查找一个值。

插入、更新和删除

判断我们是否需要插入或更新的最简单的方法是检查是否存在一个id属性。不过,我们确实需要给QueryBuilder添加一个更新和删除方法。让我们从更新方法开始:

public function prepare(): PdoStatement
{
    // ...

    if ($this->type === 'update') {
        $query = $this->compileUpdate($query);
        $query = $this->compileWheres($query);
    }

    // ...
}

protected function compileUpdate(string $query): string
{
    $joinedColumns = '';

    foreach ($this->columns as $i => $column) {
        if ($i > 0) {
            $joinedColumns .= ', ';
        }

        $joinedColumns = " {$column} = :{$column}";
    }

    $query .= " UPDATE {$this->table} SET {$joinedColumns}";

    return $query;
}

public function update(array $columns, array $values): int
{
    $this->type = 'update';
    $this->columns = $columns;
    $this->values = $values;

    $statement = $this->prepare();

    return $statement->execute($this->getWhereValues() + $values);
}

这是来自framework/Database/QueryBuilder/QueryBuilder.php

我们遵循与我们用于“where”和“insert”子句的代码相似的模式,包括准备好的语句中值的占位符。

SQL“update”子句插入语句的语法略有不同:

UPDATE products SET field = :field WHERE id = :id

一些引擎支持更接近“插入”子句语法的替代语法,但它不像这个标准版本那样可跨引擎移植。

使用这个新的update方法,我们可以更新Model类来拥有一个通用的save方法:

protected array $dirty = [];

public function __set(string $property, $value)
{
    $setter = 'set' . ucfirst($property) . 'Attribute';

    array_push($this->dirty, $property);

    // ...
}

public function save(): static
{
    $values = [];

    foreach ($this->dirty as $dirty) {
        $values[$dirty] = $this->attributes[$dirty];
    }

    $data = [array_keys($values), $values];

    $query = static::query();

    if (isset($this->attributes['id'])) {
        $query
            ->where('id', $this->attributes['id'])
            ->update(...$data);

        return $this;
    }

    $query->insert(...$data);

    $this->attributes['id'] = $query->getLastInsertId();
    $this->dirty = [];

    return $this;
}

这是来自framework/Database/Model.php

如果我们将完整的$attributes属性传递给updateinsert方法,我们可能会保存没有改变的字段。

相反,我们可以使用$dirty数组来存储已经更改的字段的名称。这意味着我们将只发送已经设置或更改的字段的数据。

我们需要确保将新创建的行的 ID 存储回模型对象中,以便后续对save方法的调用执行更新而不是插入。

为此,我们需要添加一个方法来将先前插入的行返回到QueryBuilder类:

public function getLastInsertId(): string
{
    return $this->connection->pdo()->lastInsertId();
}

这是来自framework/Database/QueryBuilder/QueryBuilder.php

我们可以使用它来获取和存储新创建的行的 ID:

public function save(): static
{
    // ...

    $query->insert(...$data);

    $this->attributes['id'] = $query->getLastInsertId();

    return $this;
}

这是来自framework/Database/Model.php

这段代码非常好用!我们可以使用非常相似的代码创建或更新行(而不关心下面使用的是什么 SQL 查询):

$product = Product::where('id', 1)->first();
$product->description = 'This is a new, better description';
$product->save();
// → updated existing database row

$product = new Product();
$product->name = 'A whole new product';
$product->description = 'The best description in the world';
$product->save();
// → created new database row and set $product->id

现在,让我们把注意力集中在从数据库中删除行上。我们需要几个新方法:一个在QueryBuilder上,另一个在Model上:

public function prepare(): PdoStatement
{
    // ...

    if ($this->type === 'delete') {
        $query = $this->compileDelete($query);
        $query = $this->compileWheres($query);
    }

    // ...
}

protected function compileDelete(string $query): string
{
    $query .= " DELETE FROM {$this->table}";
    return $query;
}

public function delete(): int
{
    $this->type = 'delete';

    $statement = $this->prepare();

    return $statement->execute($this->getWhereValues());
}

这是来自framework/Database/QueryBuilder/QueryBuilder.php

“删除”子句不需要数据或列。它们甚至不需要“where”子句,但是没有子句就使用它们是不明智的。不过这没关系,因为Model->delete方法将总是定义一个(或无操作):

public function delete(): static
{
    if (isset($this->attributes['id'])) {
        static::query()
            ->where('id', $this->attributes['id'])
            ->delete();
    }

    return $this;
}

这是来自framework/Database/Model.php

如果一行在保存之前被删除,抛出异常也是有效的,但是我们至少应该确保没有向数据库发送不受约束的“delete”子句。

我们的实现不能很好地处理先调用delete方法,然后再调用save方法的情况。我们可能想让“保存”成为一个无操作,或者抛出一个异常,表明一个被删除的模型不能被再次保存。

铸造值

大多数数据库引擎以字符串格式返回数据。让我们添加一些代码,将每一列的值转换为适当的类型。我们可以在Model类中这样做:

public function __get(string $property): mixed
{
    $getter = 'get' . ucfirst($property) . 'Attribute';

    $value = null;

    if (method_exists($this, $getter)) {
        $value = $this->$getter($this->attributes[$property] ?? null);
    }

    if (isset($this->attributes[$property])) {
        $value = $this->attributes[$property];
    }

    if (isset($this->casts[$property]) && is_callable($this->casts[$property])) {
        $value = $this->casts$property;
    }

    return $value;
}

这是来自framework/Database/Model.php

我们已经交换了东西,这样我们得到的任何值都可以通过一个可调用的东西传递,在$casts属性中设置。用一个例子解释就简单一点了。假设我们想将一个产品的 ID 值转换成一个整数;我们可以定义一个可调用函数,并将其设置在$casts属性上:

namespace App\Models;

use Framework\Database\Model;

function toInt($value): int
{
    return (int) $value;
}

class Product extends Model
{
    protected array $casts = [
        'id' => 'App\Models\toInt',
    ];

    // ...
}

这是来自app/Models/Product.php

ID 属性的值将通过这个toInt函数传递,然后通过对象访问($obj->id)返回。我们可以在框架帮助器中定义强制转换函数,甚至扩展这些代码来识别和使用类而不是函数。不过,我认为这已经足够好了…

关系

我想处理的最后一点代码是管理简单的关系。考虑以下示例:

$user = new User();
$user->email = "cgpitt@gmail.com";
$user->save();

$profile = new Profile();
$profile->user_id = $user->id;
$profile->save();

$user->profile;
// → object(App\Models\Profile)

我想让这种代码工作。它表示“有一个”类型的关系,因为用户“有一个”简档。

让我们在Model上定义一个方法,我们可以用它来表示这种关系,然后看看如何使用这个方法:

public function hasOne(string $class, string $foreignKey, string $primaryKey = 'id'): mixed
{
    $model = new $class;
    $query = $class::query()->from($model->getTable())->where($foreignKey, $this->attributes['id']);

    return new Relationship($query, 'first');
}

这是来自framework/Database/Model.php

表示关系的一种简单方法是部分完成的查询。这里,我们开始构建一个关于相关数据的查询。Relationship类看起来像这样:

namespace Framework\Database;

use Framework\Database\ModelCollector;

class Relationship
{
    public ModelCollector $collector;
    public string $method;

    public function __construct(ModelCollector $collector, string $method)
    {
        $this->collector = $collector;
        $this->method = $method;
    }

    public function __invoke(array $parameters = []): mixed
    {
        return $this->collector->$method(...$parameters);
    }

    public function __call(string $method, array $parameters = []): mixed
    {
        return $this->collector->$method(...$parameters);
    }
}

这是来自framework/Database/Relationship.php

只是个装修工。所有的方法调用都被传递给底层的ModelCollector实例——这是静态的query方法返回的结果——关系实例也可以像函数一样被调用。这意味着我们可以通过以下方式使用这种关系:

$user = new User();
$user->email = 'cgpitt@gmail.com';
$user->save();

$profile = new Profile();
$profile->user_id = $user->id;
$profile->save();

$user->profile()->first();
// → object(App\Models\Profile)

// or

$relationship = $user->profile();
$relationship()->first();
// → object(App\Models\Profile)

我们存储额外的$method属性,因为它告诉我们如果与对象访问一起使用,如何解析查询。让我告诉你我的意思。让我们修改一下Model->__get方法:

public function __get(string $property): mixed
{
    $getter = 'get' . ucfirst($property) . 'Attribute';

    $value = null;

    if (method_exists($this, $property)) {
        $relationship = $this->$property();
        $method = $relationship->method;

        $value = $relationship->$method();
    }

    // ...
}

这是来自framework/Database/Model.php

这意味着我们现在可以用下面的代码访问相关的模型:

$user = new User();
$user->email = 'cgpitt@gmail.com';
$user->save();

$profile = new Profile();
$profile->user_id = $user->id;
$profile->save();

$user->profile;
// → object(App\Models\Profile)

img/299823_2_En_7_Figg_HTML.jpg

关系调用图

让我们扩展这些关系来表示“有许多”和“属于”关系类型:

public function hasMany(string $class, string $foreignKey, string $primaryKey = 'id'): mixed
{
    $model = new $class;
    $query = $class::query()->from($model->getTable())->where($foreignKey, $this->attributes['id']);

    return new Relationship($query, 'all');
}

public function belongsTo(string $class, string $foreignKey, string $primaryKey = 'id'): mixed
{
    $model = new $class;
    $query = $class::query()->from($model->getTable())->where($primaryKey, $this->attributes[$foreignKey]);

    return new Relationship($query, 'first');
}

这是来自framework/Database/Model.php

这些方法可用于在各自的模型中定义新的关系类型:

namespace App\Models;

use Framework\Database\Model;

class Order extends Model
{
    protected string $table = 'orders';

    public function user(): mixed
    {
        return $this->belongsTo(User::class, 'user_id');
    }
}

这是来自app/Models/Order.php

namespace App\Models;

use Framework\Database\Model;

class Profile extends Model
{
    protected string $table = 'profiles';

    public function user(): mixed
    {
        return $this->belongsTo(User::class, 'user_id');
    }
}

这是来自app/Models/Product.php

namespace App\Models;

use Framework\Database\Model;

class User extends Model
{
    protected string $table = 'users';

    public function profile(): mixed
    {
        return $this->hasOne(Profile::class, 'user_id');
    }

    public function orders(): mixed
    {
        return $this->hasMany(Order::class, 'user_id');
    }
}

这是来自app/Models/User.php

我认为,我们能够对现有的ModelQueryBuilder类进行如此少的调整就能表示这些关系,这是很不可思议的。

属性呢

PHP 8.0 的一个引人入胜的特性是定义和使用属性的能力。它们看起来有点像注释(因此会被旧版本的 PHP 忽略),但是它们可以通过不同类型的反射来访问。

假设我们希望允许用户将他们的模型的表定义为一个属性,可能使用如下代码:

#[Table('users')]
class User extends Model
{
    public function profile(): mixed
    {
        return $this->hasOne(Profile::class, 'user_id');
    }

    public function orders(): mixed
    {
        return $this->hasMany(Order::class, 'user_id');
    }
}

我们实际上可以深入到这个类的属性中,并从那里获得表名。让我们在Model课堂上试试:

public function getTable(): string
{
    if (!isset($this->table)) {
        $reflector = new ReflectionClass(static::class);

        foreach ($reflector->getAttributes() as $attribute) {
            if ($attribute->getName() == TableName::class) {
                return $attribute->getArguments()[0];
            }
        }

        throw new Exception('$table is not set and getTable is not defined');
    }

    return $this->table;
}

这是来自framework/Database/Model.php

我们还需要定义这个属性——因为所有属性都需要定义为类:

namespace Framework\Database;

#[Attribute]
class TableName
{
    public string $name;

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

这是来自framework/Database/TableName.php

我认为这是一个有趣的属性用法,但是注意不要过度使用属性,因为这会导致大量的搜索,当人们想知道属性在幕后做什么的时候。

使用方法覆盖(这是我们用于getTable的方法),非常清楚表名是如何以及在哪里生成的。属性格式不太好。

我不想让人觉得我不喜欢属性。我认为同样的问题(过度使用)也适用于魔法方法。在这个 ORM 代码中,我们甚至可能已经用神奇的方法跨过了那条线。

我认为衡量属性和魔法方法使用的一个很好的标准是它们是否可以在保持一个灵活和易于理解的系统的同时被使用。当代码更难更改或理解时,这是一个信号,表明我们应该审查和重构我们的代码,以使用更少的魔法。

警告

以下是您在框架和应用中需要克服的更多挑战:

  1. 我们目前正在几个地方对id列名进行硬编码。如果这是我们可以忽略的东西,那就更酷了…

  2. 我们仍然在控制器中使用旧的QueryBuilder代码。

  3. 我们可以围绕$this->attributes['id']做更多的类型检查,以确保我们处理的是数字 id。

  4. 我认为我们用得太多了。如何更好地定义返回类型,甚至通过联合类型?

我将在这一章和下一章之间解决其中的一些问题,所以在阅读下一章之前,一定要尝试任何你感兴趣的东西。

职业选手是如何做到的

本章中的 ORM 代码在很大程度上受到了 Laravel 的 ORM 开发经验的启发。它不像雄辩那样功能丰富,并且做一些不同的小事情(比如如何处理造型和定义关系),但是对于 Laravel 开发人员来说感觉很熟悉。

Laravel 有一组更广泛的关系可以定义,比如“属于许多”和“多态”关系。我认为它们是很棒的特性,但是在这一章中它们可能还需要 20 页才能实现。

雄辩术不使用属性,也不使用之前广泛使用的属性形式——phpDoc 注释。大多数配置都是通过属性和覆盖来完成的,如本章所述。

雄辩术和 Laravel 数据库紧密地交织在一起,数据库具有丰富的特性。例如,关于 where 子句的文档非常多。

concertive 在构建 concertive 查询时重用了这一底层数据库功能(与我们在本章中所做的方式类似),但是数据库的库的所有附加功能都可以在 concertive 模型上使用。

口才还提供了所谓的渴望加载和懒惰加载的功能。这些规避了我们构建的 ORM 中的一个问题,称为 N+1 问题。

不涉及太多细节,我们采用的方法可能会导致在获取相关记录时出现大量查询。急切加载通过执行尽可能少的查询并将相关记录链接到内存中来防止这种情况。

只有在需要的时候,惰性加载才尽可能多地完成(从数据库查询的)工作。

ORM 理论

教条是库的集合的组织,最流行的是教条形式。Doctrine 也有一个数据库的库,ORM 就是在这个基础上构建的。

ORM 和 convention 之间的主要区别在于,前者更喜欢一种叫做实体管理的方法,而后者更喜欢配置而不是约定。

实体管理是一个过程,通过这个过程,对象被一次保存到数据库中,而不是通过它们自己的逻辑。也许我可以用一些伪代码来更好地说明这种区别:

// in eloquent
$product = new Product();
$product->name = 'ACME product';
$product->save();

// in doctrine orm
$product = new Product();
$product->name = 'ACME product';

$manager = new EntityManager();
$manager->persist($product);
$manager->flush();

口才比配置更喜欢约定,所以在开始之前需要设置的东西要少得多,但是也有很多关于您希望数据如何显示以及逻辑如何工作的假设。

事实上,雄辩的模型看起来与教条模型非常不同。教条模型中几乎没有魔法,而雄辩模型却非常神奇。

摘要

这是对构建 ORM 过程的一次有益的深入探索。我希望你和我一样感到兴奋。这可能是我们将在本书中涉及的最复杂的主题,所以如果您需要额外的时间来理解我们所涉及的内容,请不要难过。

在下一章中,我们将解决样板文件的问题,因为我们要构建自己的服务定位器。

八、构建依赖注入容器

在前一章中,我们构建了一个非常棒的 ORM。有了它,我们可以将数据库工作提升到一个全新的水平。它应该给你很多东西去修补和思考。

在这一章中,我们将致力于消除样板文件,使系统不同部分之间的依赖关系更容易共享。

课间休息时…

在我们开始构建依赖注入容器之前,我们要回顾一下我在章节之间对网站做的一些改进。

最大的变化是将数据库的库代码换成了 ORM 代码。这是之前版本的样子:

$factory = new Factory();

$factory->addConnector('mysql', function($config) {
    return new MysqlConnection($config);
});

$factory->addConnector('sqlite', function($config) {
    return new SqliteConnection($config);
});

$config = require __DIR__ . '/../../../config/database.php';

$connection = $factory->connect($config[$config['default']]);

$products = $connection
    ->query()
    ->select()
    ->from('products')
    ->all();

$productsWithRoutes = array_map(fn($product) => array_merge($product, [
    'route' => $this->router->route('view-product', ['product' => $product['id']]),
]), $products);

这是来自app/Http/Controllers/ShowHomePageController.php

这是新版本的样子:

$products = Product::all();

$productsWithRoutes = array_map(function($product) {
    $product->route = $this->router->route('view-product', ['product' => $product->id]);
    return $product;
}, $products);

这是来自app/Http/Controllers/ShowHomePageController.php

不可否认,这在很大程度上减少了建立新数据库连接的样板代码。这些代码已经移到了模型类中,但是它将再次移到本章中。

对模型的这种改变意味着在模板中使用对象访问:

@extends('layout') @includes('includes/large-feature') @foreach($products as $i
=> $product)
<div
  class="
        z-10
        @if($i % 2 === 0)
            bg-gray-50
        @endif
    "
>
  <div class="container mx-auto px-8 py-8 md:py-16">
    <h2 class="text-3xl font-bold">{{ $product->name }}</h2>
    <p class="text-xl my-4">{!! $product->description !!}</p>
    <a
      href="{{ $product->route }}"
      class="bg-indigo-500 rounded-lg p-2 text-white"
    >
      Order
    </a>
  </div>
</div>
@endforeach

这是来自resources/views/home.advanced.php

在产品视图页面上,我们需要根据路线参数获取单个产品。我在Model类中添加了一个方法来更快地完成这项工作:

public static function find(int $id): static
{
    return static::where('id', $id)->first();
}

这是来自framework/Database/Model.php

这使得控制器看起来像这样:

public function handle()
{
    $parameters = $this->router->current()->parameters();

    $product = Product::find((int) $parameters['product']);

    return view('products/view', [
        'product' => $product,
        'orderAction' => $this->router->route('order-product', ['product' => $product->id]),
        'csrf' => csrf(),
    ]);
}

这是来自app/Http/Controllers/Products/ShowProductController.php

在涉及数据库的应用中,find是非常有用和常见的操作。

最后,我认为人们可以注册新账户并使用这些账户登录会很酷。用户注册对于商业应用来说是必不可少的。

注册和登录控制器如下所示:

public function handle()
{
    // check the csrf token...
    secure();

    $data = validate($_POST, [
        'name' => ['required'],
        'email' => ['required', 'email'],
        'password' => ['required', 'min:10'],
    ], 'register_errors');

    $user = new User();
    $user->name = $data['name'];
    $user->email = $data['email'];
    $user->password = password_hash($data['password'], PASSWORD_DEFAULT);
    $user->save();

    // store a variable to show a message to the user...
    $_SESSION['registered'] = true;

    return redirect($this->router->route('show-home-page'));
}

这是来自app/Http/Controllers/Users/RegisterUserController.php

password_hash是 PHP 的一个相对较新的补充。它提供了一种便捷的方式来生成加密的强密码。 Bcrypt 提供者生成一个 salt 并将其添加到散列中。

因为我们只存储散列,所以我们需要使用另一个内置函数来验证密码是否正确:

public function handle()
{
    // check the csrf token...
    secure();

    $data = validate($_POST, [
        'email' => ['required', 'email'],
        'password' => ['required', 'min:10'],
    ], 'login_errors');

    $user = User::where('email', $data['email'])->first();

    if ($user && password_verify($data['password'], $user->password)) {
        $_SESSION['user_id'] = $user->id;
    }

    return redirect($this->router->route('show-home-page'));
}

这是来自app/Http/Controllers/Users/LogInUserController.php

我没有记录登录/注册/注销成功或失败的消息,但是如果您感兴趣的话,可以随时继续。这就完成了认证工作,所以是时候开始依赖注入和服务定位了。

依赖注入容器是用来做什么的?

正如我们所看到的,在将大型应用和定制框架缝合在一起的过程中,涉及到许多样板文件。助手就是这样一个例子:

function view(string $template, array $data = []): View\View
{
    static $manager;

    if (!$manager) {
        $manager = new View\Manager();

        // ...lots of manager setup
    }

    return $manager->resolve($template, $data);
}

这是来自framework/helpers.php

我们有几个助手——就像这样——在那里我们创建和配置新的管理器。这个设置是必需的,但是它不应该存在于这些函数中。定制配置并不容易,测试起来大概也比较棘手。

另一个有问题的样板文件的例子可以在Model中找到:

public function getConnection(): Connection
{
    if (!isset($this->connection)) {
        $factory = new Factory();

        $factory->addConnector('mysql', function($config) {
            return new MysqlConnection($config);
        });

        $factory->addConnector('sqlite', function($config) {
            return new SqliteConnection($config);
        });

        $config = require basePath() . 'config/database.php';

        $this->connection = $factory->connect($config[$config['default']]);
    }

    return $this->connection;
}

这是来自framework/Database/Model.php

至少,如果在调用getConnection之前调用setConnection,或者在子类中覆盖getConnection,那么覆盖连接会更容易。

一个更微妙的例子在 routes 文件中:

$router->add(
    'GET', '/',
    [new ShowHomePageController($router), 'handle'],
)->name('show-home-page');

这是来自app/routes.php

我们需要在ShowHomePageController类中生成路由,所以我们将路由的一个实例传递给它。如果我们需要访问别的东西呢?在几章中,我们将构建会话、缓存和日志记录。这些都不是我们每次使用时想要创建的新实例。

如果我们可以键入提示方法参数并自动解析这些参数,岂不是更酷?想象一下这样的事情:

public function handle(Request $request, Router $router, Session $session)
{
    secure();

    $data = $request->validate([
        'email' => ['required', 'email'],
        'password' => ['required', 'min:10'],
    ], 'login_errors');

    $user = User::where('email', $data['email'])->first();

    if ($user && password_verify($data['password'], $user->password)) {
        $session->put('user_id', $user->id);
    }

    return redirect($router->route('show-home-page'));
}

并不是我不喜欢 helper 函数,但它们真的应该更简洁,定制它们使用和返回的内容应该毫不费力。

以下是我希望我们的依赖注入容器具备的一些特性:

  1. 一种存储(或绑定)依赖实例的方式

  2. 检索(或解析)这些依赖实例的方法

  3. 一种代理函数或方法调用的方式,以便可以在容器外解析类型提示的参数

  4. 如何配置容器的一些结构

容器是框架的核心,但是我们需要将依赖注入部分与配置和加载部分分开。

img/299823_2_En_8_Figa_HTML.jpg

容器和应用结构

随着我们的发展,这种设计的工作原理会变得更加清晰。

让我们开始建造吧!

前两个需求类似于我们以前编写的代码。我们可以创建两个数组:一个存储工厂函数,另一个存储这些工厂函数创建的东西:

namespace Framework;

use InvalidArgumentException;

class Container
{
    private array $bindings = [];
    private array $resolved = [];

    public function bind(string $alias, callable $factory): static
    {
        $this->bindings[$alias] = $factory;
        $this->resolved[$alias] = null;

        return $this;
    }

    public function resolve(string $alias): mixed
    {
        if (!isset($this->bindings[$alias])) {
            throw new InvalidArgumentException("{$alias} is not bound");
        }

        if (!isset($this->resolved[$alias])) {
            $this->resolved[$alias] = call_user_func($this->bindings[$alias], $this);
        }

        return $this->resolved[$alias];
    }
}

这是来自framework/Container.php;

我希望我们能够尽快使用它,所以让我们创建App子类并将大部分public/index.php移入其中。我们使用容器作为中央存储,所以我们需要使它成为单例。

如果我们非常小心地只在一个地方创建容器,那么使容器成为单例容器并不是严格必需的。强迫它作为一个单例来使用,可以确保它永远是我们所指的同一个实例,不管我们试图“创建”它多少次。

namespace Framework;

use Dotenv\Dotenv;
use Framework\Routing\Router;

class App extends Container
{
    private static $instance;

    public static function getInstance()
    {
        if (!static::$instance) {
            static::$instance = new static();
        }

        return static::$instance;
    }

    private function __construct() {}
    private function __clone() {}

    public function run()
    {
        session_start();

        $dotenv = Dotenv::createImmutable(__DIR__ . '/..');
        $dotenv->load();

        $router = new Router();

        $routes = include __DIR__ . '/../app/routes.php';
        $routes($router);

        print $router->dispatch();
    }
}

这是来自framework/App.php

这意味着我们可以从public/index.php中删除一些代码。下面是注释掉现有代码并调用新方法后的样子:

require_once __DIR__ . '/../vendor/autoload.php';

// basePath(__DIR__ . '/../');

// session_start();

// $dotenv = Dotenv\Dotenv::createImmutable(__DIR__ . '/..');
// $dotenv->load();

// $router = new Framework\Routing\Router();

// $routes = require_once __DIR__ . '/../app/routes.php';
// $routes($router);

// print $router->dispatch();

$app = \Framework\App::getInstance();
$app->bind('paths.base', fn() => __DIR__ . '/../');
$app->run();

这是来自public/index.php

最后,在我们有一个简洁的方法将$app传递给其他类之前,我们应该实现单例方法和一个助手来使用它们:

use Framework\App;

if (!function_exists('basePath')) {
    function basePath(string $newBasePath = null): ?string
    {
        // static $basePath;

        // if (!is_null($newBasePath)) {
        //     $basePath = $newBasePath;
        // }

        // return $basePath;

        return app('paths.base');
    }
}

if (!function_exists('app')) {
    function app(string $alias = null): mixed
    {
        if (is_null($alias)) {
            return App::getInstance();
        }

        return App::getInstance()->resolve($alias);
    }
}

这是来自framework/helpers.php

绑定和解析

确保这一点的最好方法是重构其他助手函数来使用容器,而不是静态变量。让我们改变viewvalidate助手来使用app助手功能:

if (!function_exists('view')) {
    app()->bind('view', function($app) {
        $manager = new View\Manager();

        $manager->addPath(__DIR__ . '/../resources/views');
        $manager->addPath(__DIR__ . '/../resources/images');

        $manager->addEngine('basic.php', new View\Engine\BasicEngine());
        $manager->addEngine('advanced.php', new View\Engine\AdvancedEngine());
        $manager->addEngine('php', new View\Engine\PhpEngine());
        $manager->addEngine('svg', new View\Engine\LiteralEngine());

        $manager->addMacro('escape', fn($value) => htmlspecialchars($value, ENT_QUOTES));
        $manager->addMacro('includes', fn(...$params) => print view(...$params));

        return $manager;
    });

    function view(string $template, array $data = []): View\View
    {
        return app()->resolve('view')->resolve($template, $data);
    }
}

if (!function_exists('validate')) {
    app()->bind('validator', function($app) {
        $manager = new Validation\Manager();

        $manager->addRule('required', new Validation\Rule\RequiredRule());
        $manager->addRule('email', new Validation\Rule\EmailRule());
        $manager->addRule('min', new Validation\Rule\MinRule());

        return $manager;
    });

    function validate(array $data, array $rules, string $sessionName = 'errors')
    {
        return app('validator')->validate($data, $rules, $sessionName);
    }
}

这是来自framework/helpers.php

这将考验bindresolveappviewvalidate都减少到一行代码,这很好,因为这些依赖项的配置不应该发生在这些方法内部。

不过,我讨厌对app()->bind(...)的调用放在这个助手文件中。让我们将视图配置移到一个配置类中。

我们将把它放在一个新的类中,称为“提供者”,框架将使用它来“加载”常用代码。这是我们要打造的众多供应商中的第一家:

namespace Framework\Provider;

use Framework\App;
use Framework\View\Manager;
use Framework\View\Engine\BasicEngine;
use Framework\View\Engine\AdvancedEngine;
use Framework\View\Engine\PhpEngine;
use Framework\View\Engine\LiteralEngine;

class ViewProvider
{
    public function bind(App $app)
    {
        $app->bind('view', function($app) {
            $manager = new Manager();

            $this->bindPaths($app, $manager);
            $this->bindMacros($app, $manager);
            $this->bindEngines($app, $manager);

            return $manager;
        });
    }

    private function bindPaths(App $app, Manager $manager)
    {
        $manager->addPath($app->resolve('paths.base') . '/resources/views');
        $manager->addPath($app->resolve('paths.base') . '/resources/images');
    }

    private function bindMacros(App $app, Manager $manager)
    {
        $manager->addMacro('escape', fn($value) => htmlspecialchars($value, ENT_QUOTES));
        $manager->addMacro('includes', fn(...$params) => print view(...$params));
    }

    private function bindEngines(App $app, Manager $manager)
    {
        $app->bind('view.engine.basic', fn() => new BasicEngine());
        $app->bind('view.engine.advanced', fn() => new AdvancedEngine());
        $app->bind('view.engine.php', fn() => new PhpEngine());
        $app->bind('view.engine.literal', fn() => new LiteralEngine());

        $manager->addEngine('basic.php', $app->resolve('view.engine.basic'));
        $manager->addEngine('advanced.php', $app->resolve('view.engine.advanced'));
        $manager->addEngine('php', $app->resolve('view.engine.php'));
        $manager->addEngine('svg', $app->resolve('view.engine.literal'));
    }
}

这是来自framework/Provider/ViewProvider.php

这是非常相似的,除了我们还使视图引擎可用,以防它们需要被重用。让我们添加一个新的配置文件,列出我们希望框架加载的提供者:

return [
    \Framework\Provider\ViewProvider::class,
];

这是来自config/providers.php

我们可以在App类中使用这个配置文件来加载提供者并执行它们的bind函数。我们可能想在这些提供者中挂接应用的其他部分,所以让我们将bind方法设为可选:

public function run()
{
    session_start();

    $basePath = $this->resolve('paths.base');

    $this->configure($basePath);
    $this->bindProviders($basePath);
    $this->dispatch($basePath);
}

private function configure(string $basePath)
{
    $dotenv = Dotenv::createImmutable($basePath);
    $dotenv->load();
}

private function bindProviders(string $basePath)
{
    $providers = require "{$basePath}/config/providers.php";

    foreach ($providers as $provider) {
        $instance = new $provider;

        if (method_exists($instance, 'bind')) {
            $instance->bind($this);
        }
    }
}

private function dispatch(string $basePath)
{
    $router = new Router();

    $this->bind(Router::class, fn() => $router);

    $routes = require "{$basePath}/app/routes.php";
    $routes($router);

    print $router->dispatch();
}

这是从framework/App.php开始的。

现在,我们可以注释掉view助手之前的所有配置,视图仍然会被呈现:

if (!function_exists('view')) {
    // app()->bind('view', function($app) {
    //     $manager = new View\Manager();

    //     $manager->addPath(__DIR__ . '/../resources/views');
    //     $manager->addPath(__DIR__ . '/../resources/images');

    //     $manager->addEngine('basic.php', new View\Engine\BasicEngine());
    //     $manager->addEngine('advanced.php', new View\Engine\AdvancedEngine());
    //     $manager->addEngine('php', new View\Engine\PhpEngine());
    //     $manager->addEngine('svg', new View\Engine\LiteralEngine());

    //     $manager->addMacro('escape', fn($value) => htmlspecialchars($value, ENT_QUOTES));
    //     $manager->addMacro('includes', fn(...$params) => print view(...$params));

    //     return $manager;
    // });

    function view(string $template, array $data = []): View\View
    {
        return app()->resolve('view')->resolve($template, $data);
    }
}

这是来自framework/helpers.php

移动这个配置是我在这个应用中做过的最令人满意的事情之一。花一些时间将验证器的配置迁移到它自己的新 provider 类,以掌握这种配置风格。

解析函数参数

我们现在要实现的另一个特性是调用函数或方法并从容器中解析缺失参数的能力。我们将不得不使用一些反射来解决这个问题,从函数期望的参数开始:

public function call(array|callable $callable, array $parameters = []): mixed
{
    $reflector = $this->getReflector($callable);

    $dependencies = [];

    foreach ($reflector->getParameters() as $parameter) {
        $name = $parameter->getName();
        $type = $parameter->getType();

        // ...do something with name and type
    }
}

private function getReflector(array|callable $callable): ReflectionMethod|ReflectionFunction
{
    if (is_array($callable)) {
        return new ReflectionMethod($callable[0], $callable[1]);
    }

    return new ReflectionFunction($callable);
}

这是来自framework/App.php

ReflectionFunctionReflectionMethod有这个方便的getParameters方法,它为函数或方法中定义的每个参数返回ReflectionParameter对象。

为了得到这些,我们需要在一个可调用的(函数或字符串或Closure)和一个数组之间进行选择。它成为数组的唯一原因是如果我们引用一个类或对象的方法。

给定名称和类型,我们实际上可以判断参数是否已经提供,或者它们是否需要从容器中解析:

foreach ($reflector->getParameters() as $parameter) {
    $name = $parameter->getName();
    $type = $parameter->getType();

    if (isset($parameters[$name])) {
        $dependencies[$name] = $parameters[$name];
        continue;
    }

    if ($parameter->isDefaultValueAvailable()) {
        $dependencies[$name] = $parameter->getDefaultValue();
        continue;
    }

    if ($type instanceof ReflectionNamedType) {
        $dependencies[$name] = $this->resolve($type);
        continue;
    }

    throw new InvalidArgumentException("{$name} cannot be resolved");
}

这是来自framework/App.php

我们尝试按以下顺序解析参数:

  1. 如果有人用一个参数数组调用app()->call(...),并且期望的方法参数在第二个call参数中有一个匹配值,那么我们就使用它。

  2. 如果这个方法有这个参数的默认值,那就是我们使用的值。

  3. 最后,如果没有定义值,也没有默认值,那么我们尝试从容器中解析依赖关系。

这两个新方法使用联合类型来定义它们的参数类型提示。它允许我们指定类型,但也允许不同的用法。

为了将它用于路由,我们需要更改路由调用它们的方式:

public function dispatch()
{
    if (is_array($this->handler)) {
        [$class, $method] = $this->handler;

        if (is_string($class)) {
            // return (new $class)->{$method}();
            return app()->call([new $class, $method]);
        }

        // return $class->{$method}();
        return app()->call([$class, $method]);
    }

    // return call_user_func($this->handler);

    return app()->call($this->handler);
}

这是来自framework/Routing/Router.php

现在,我们可以注入我们需要的任何依赖项:

// protected Router $router;

// public function __construct(Router $router)
// {
//     $this->router = $router;
// }

public function handle(Router $router)
{
    $products = Product::all();

    $productsWithRoutes = array_map(function ($product) use ($router) {
        $product->route = $router->route('view-product', ['product' => $product->id]);
        return $product;
    }, $products);

    return view('home', [
        'products' => $productsWithRoutes,
    ]);
}

这是来自app/Http/Controllers/ShowHomePageController.php

此外,我们可以通过用call方法调用它来解析我们喜欢的任何方法的依赖性:

use App\Models\Product;
use Framework\View\Manager;

$html = app()->call(
    fn(Manager $view, $product) => $view->resolve('embed', $product),
    ['product' => Product::first()],
);

相当干净!我认为这将带来巨大的变化,并给我们一个机会来重构我们现有的代码。

警告

以下是我会考虑添加或更改的一些内容。体验其中的一些乐趣:

  1. 如前所述,我认为为验证代码提供服务会很酷。

  2. 我们甚至可以将其他配置转移到提供者,比如启动会话的代码或者将路由绑定到容器的代码。

  3. 可以用更新的或修饰过的依赖项替换绑定的依赖项,但这并不简单。我想知道我们是否可以添加另一个容器方法来扩展绑定。

不同的探索方法

我们采用了一种非常固执的方法来构建这个服务定位器和依赖注入容器。Laravel 的容器有很多替代品——这也是我构建这个模式的基础——它们都很棒。

以下是我们可以考虑支持的一些事情:

  1. 为方法调用挑选参数的一种不太“神奇”的方法

  2. 允许构造函数依赖注入,这样依赖可以在方法之间共享,而不是为每个“神奇的”方法调用进行解析

其中一个库,类似于我们制作的,我经常喜欢使用的,叫做 PHP-DI。它更喜欢配置而不是约定,但是它的外观和感觉与我们制作的容器库相似。

摘要

在这一章中,我们在组织代码方面向前迈进了一大步。我很兴奋这对我们的框架和网站意味着什么。我将忙于应对前面提到的一些挑战…

在下一章中,我们将讨论这个期待已久的测试主题。我们将弄清楚如何开始测试并构建一组我们可以继续重用的助手。

九、测试我们的框架

在前一章中,我们构建了一个服务定位器和依赖注入容器,这样我们就可以共享框架的各个部分,而无需多余的样板文件。

在这一章中,我们将学习如何测试新的代码,以及如何构建我们的应用和测试,从而使我们投入的时间获得最大的收益。

在章节之间…

我想快速介绍一下自上一章以来我所做的一些改变。依赖注入容器是一个强大的工具,因为它减少了代码的重复,使构建框架代码变得更加容易。

使用容器,我能够将验证代码移动到一个新的提供者:

namespace Framework\Provider;

use Framework\App;
use Framework\Validation\Manager;
use Framework\Validation\Rule\RequiredRule;
use Framework\Validation\Rule\EmailRule;
use Framework\Validation\Rule\MinRule;

class ValidationProvider
{
    public function bind(App $app)
    {
        $app->bind('validator', function($app) {
            $manager = new Manager();

            $this->bindRules($app, $manager);

            return $manager;
        });
    }

    private function bindRules(App $app, Manager $manager)
    {
        $app->bind('validation.rule.required', fn() => new RequiredRule());
        $app->bind('validation.rule.email', fn() => new EmailRule());
        $app->bind('validation.rule.min', fn() => new MinRule());

        $manager->addRule('required', $app->resolve('validation.rule.required'));
        $manager->addRule('email', $app->resolve('validation.rule.email'));
        $manager->addRule('min', $app->resolve('validation.rule.min'));
    }
}

这是来自framework/Provider/ValidationProvider.php

我喜欢将单个规则绑定到容器上(就像上一章我们对视图引擎所做的那样),因为它们可以扩展、修饰和重新配置。

例如,我们可以解决电子邮件验证规则,改变它的工作方式,或者覆盖它以提供一些新的逻辑。如果没有办法把它从容器中取出来,那就不那么简单了。

由于我们改变了路由(使用容器来解析处理程序),我也可以删除每个路由动作构造器方法。我不会展示所有这些变化,但它们类似于以下内容:

namespace App\Http\Controllers\Products;

use App\Models\Product;
use Framework\Routing\Router;

class ShowProductController
{
    // protected Router $router;

    // public function __construct(Router $router)
    // {
    //     $this->router = $router;
    // }

    public function handle(Router $router)
    {
        $parameters = $router->current()->parameters();

        $product = Product::find((int) $parameters['product']);

        return view('products/view', [
            'product' => $product,
            'orderAction' => $router->route('order-product', ['product' => $product->id]),
            'csrf' => csrf(),
        ]);
    }
}

这是来自app/Http/Controllers/Products/ShowProductcontroller.php

这些是对代码库的唯一更改。您是否尝试过将验证管理器配置转移到它自己的提供者?对我来说这是一次有趣的经历…

为什么我们要测试我们的代码?

在我们研究测试的实用性之前,重要的是要考虑我们为什么要测试。测试有多种形式,毫无疑问,你已经在做一种形式的测试了。

最常见的是在编码时保持浏览器或终端标签打开,并定期刷新浏览器或运行与编码内容相关的脚本。

这是手工测试,没有什么特别的问题。如果这是你唯一做的测试,你可能会错过一些重要的东西:

  • 你还记得你需要测试的所有东西吗?

  • 你的测试有效率吗?

  • 您是否有最新的文档向团队中的其他人解释什么需要测试?

如果没有我们将在本章中探讨的那种测试,这些事情是很难实现的。

我们将要探索的这种测试叫做自动化测试。通过设计,它解决了前面列出的每个问题。自动化测试是当你写代码来测试你的其他代码时发生的事情,这些代码可以在尽可能少的交互和尽可能多的系统上运行。

不同类型的测试

围绕不同种类的测试有很多信息和困惑。我们将探讨一对夫妇,我将把他们称为“单元”和“集成”

这两者之间的主要区别是“单元”测试针对的是很小一部分代码,并且尽可能少地依赖于其他部分。另一方面,“集成”测试是关于测试一些东西,就像我们手工做的一样。如果您是测试新手,那么这可能会有点混乱。

在相关的地方,我一定会描述我们正在编写什么样的测试,以及为什么它们是那种测试。总是知道或关心测试的种类并不是非常重要,但是当与其他开发人员谈论测试时,知道一些术语是有帮助的。

我们将要测试的方法

我们已经写了相当多的代码,所以还有很多要测试。在这一章中,我将检查一些我想测试的东西,剩下的留给你自己去测试。以下是我希望我们涵盖的内容:

  1. 测试验证库,以确保返回正确的消息,并在适当的时候引发错误

  2. 测试路由库,以确保路由被适当地分派,并且从容器中解析依赖性

  3. 测试那各种嗖!网站页面正常工作,注册和登录页面正常工作

这个列表可能看起来很小,但是有大量的工作要做。和第六章一样,我们将会看到存在哪些流行的测试库,以及为什么把我们的时间集中在为它们构建测试和助手上是明智的,而不是重新发明一个完整的测试库。

我们开始吧!

把这一切放在一起

测试是一个很大的话题,但是大多数测试分三步进行:

  1. 设置测试开始时的条件

  2. 计算预期的结果是什么

  3. 运行您希望生成预期结果的代码,并将结果与预期进行比较

每一个好的测试框架都会让这些事情变得更容易。我们可以用代码描述这些步骤,如下所示:

// tests use framework classes...
require __DIR__ . '/../vendor/autoload.php';

// validation manager uses $_SESSION...
session_start();

use Framework\Validation\Manager;
use Framework\Validation\Rule\EmailRule;
use Framework\Validation\ValidationException;

class ValidationTest
{
    protected Manager $manager;

    public function setUp()
    {
        $this->manager = new Manager();
        $this->manager->addRule('email', new EmailRule());
    }

    public function testInvalidEmailValuesFail()
    {
        $this->setUp();

        $expected = ['email' => ['email should be an email']];

        try {
            $this->manager->validate(['email' => 'foo'], ['email' => ['email']]);
        }
        catch (Throwable $e) {
            assert($e instanceof ValidationException, 'error should be thrown');
            assert($e->getErrors()['email'] === $expected['email'], 'messages should match');
            return;
        }

        throw new Exception('validation did not fail');
    }

    public function testValidEmailValuesPass()
    {
        $this->setUp();

        try {
            $this->manager->validate(['email' => 'foo@bar.com'], ['email' => ['email']]);
        }
        catch (Throwable $e) {
            throw new Exception('validation did failed');
            return;
        }

    }
}

$test = new ValidationTest();
$test->testInvalidEmailValuesFail();
$test->testValidEmailValuesPass();

print 'All tests passed' . PHP_EOL;

这是来自tests/ValidationTest.php

这里发生了很多事情,所以让我们把它分成更小的部分:

  • 我们有两个测试,第一个确保验证器在无效邮件通过时抛出一个ValidationException,第二个确保有效邮件不会触发邮件验证异常。

  • 两个测试都期望一个验证Manager,并添加了email规则,这是我们在setUp方法中设置的。

  • 第一个测试明确声明了预期,即ValidationException中会有一个电子邮件错误。

  • 第二个测试隐式地声明了期望,即当有效的电子邮件地址被验证时不会发生错误。

  • 我称之为可接受的测试代码库的一小部分,使这成为一个单元测试。

你将会看到的一件事是,测试通常会比它所测试的代码花费更多的代码。那是因为好的测试测试的不仅仅是“快乐之路”好的测试还需要测试广泛的故障条件…

“快乐之路”是一个短语,意思是通过一个接口或一段代码的路径,其中接口或代码被完全按照预期使用。

addIntegerToInteger这样的方法可能需要两个整数,并返回两个整数的和,所以“快乐的路径”是当有人用两个整数调用它并希望这两个数字相加时。

用两个字符串调用它不是“好方法”,期望该方法执行乘法也不是。

有些事情我们可以留给静态分析工具去做——比如输入是正确的类型。其他的事情对于静态分析来说更难解决,而这些肯定是我们应该在测试中涉及的事情。

正如我所说的,每一个好的测试框架都会使这些步骤变得更容易。他们会做一些有用的事情,比如自动加载你的框架和应用代码,而不需要你调用require

他们将确保像setUp这样的方法在每次测试前运行。他们将运行测试方法(通常寻找前缀,就像我们用testX添加的一样),这样我们就不需要创建新的测试类实例并手动调用这些方法。

一些测试框架会使分离代码单元、进行测试或者创建虚假的依赖变得更加容易。

正如我们所了解的,对于我们为数据库的库添加的控制台命令,有些东西不值得我们花费时间去构建。

如果你喜欢从头开始创建你自己的测试库,那就去做吧!看看 PHPUnit 做了什么,并从我们刚刚看到的代码中进行推断。即使我们从使用 PHPUnit 开始,我们也有大量的工作要做,比如允许来自测试的请求通过路由,然后检查响应。

让我们安装 PHPUnit 并设置它需要运行的配置文件:

composer require --dev phpunit/phpunit

PHPUnit 的配置文件是一个 XML 文件,它定义了运行哪些测试文件,以及其他内容:

<phpunit
    backupGlobals="true"
    bootstrap="vendor/autoload.php"
    colors="true"
>
    <testsuites>
        <testsuite name="Test Suite">
            <directory>tests</directory>
        </testsuite>
    </testsuites>
</phpunit>

这是来自phpunit.xml

这是完整配置的精简版: 3。XML 配置文件–PHPUnit 9。5 手动。通过指定tests目录,PHPUnit 将查找以Test.php结尾的文件(默认情况下)。

PHPUnit 附带了一个类,该类提供了一些断言助手方法。我们可以扩展该类并删除一些现有的样板文件:

// tests use framework classes...
// require __DIR__ . '/../vendor/autoload.php';

// validation manager uses $_SESSION...
session_start();

use Framework\Validation\Manager;
use Framework\Validation\Rule\EmailRule;
use Framework\Validation\ValidationException;

class ValidationTest extends \PHPUnit\Framework\TestCase
{
    protected Manager $manager;

    public function testInvalidEmailValuesFail()
    {
        $manager = new Manager();
        $manager->addRule('email', new EmailRule());

        $expected = ['email' => ['email should be an email']];

        try {
            $manager->validate(['email' => 'foo'], ['email' => ['email']]);
        }
        catch (Throwable $e) {
            assert($e instanceof ValidationException, 'error should be thrown');
            assert($e->getErrors()['email'] === $expected['email'], 'messages should match');
            return;
        }

        throw new Exception('validation did not fail');
    }

    public function testValidEmailValuesPass()
    {
        $manager = new Manager();
        $manager->addRule('email', new EmailRule());

        try {
            $manager->validate(['email' => 'foo@bar.com'], ['email' => ['email']]);
        }
        catch (Throwable $e) {
            throw new Exception('validation did failed');
            return;
        }
    }
}

// $test = new ValidationTest();
// $test->testInvalidEmailValuesFail();
// $test->testValidEmailValuesPass();

// print 'All tests passed' . PHP_EOL;

这是来自tests/ValidationTest.php

TestCase类自动调用setUp,测试运行器自动调用所有前缀为testX的方法。

这些测试方法仍然非常混乱,因为我们在处理异常。我们只需要第一个方法中的异常,但是也许我们可以进一步精简代码。

不过,在此之前,让我们先进行测试:

vendor/bin/phpunit

PHPUnit 没有显示测试失败,但也没有显示成功。它有方法表明当某些代码运行时会出现异常,但是它没有一种简单的方法来查看该异常包含的内容(就异常消息和嵌套数据而言)。

让我们创建一个框架助手类,使检查异常更容易:

namespace Framework\Testing;

use Closure;
use Exception;
use PHPUnit\Framework\TestCase as BaseTestCase;
use Throwable;

class TestCase extends BaseTestCase
{
    protected function assertExceptionThrown(Closure $risky, string $exceptionType)
    {
        $result = null;
        $exception = null;

        try {
            $result = $risky();
            $this->fail('exception was not thrown');
        }
        catch (Throwable $e) {
            $actualType = $e::class;

            if ($actualType !== $exceptionType) {
                $this->fail("exception was {$actualType}, but expected {$exceptionType}");
            }

            $exception = $e;
        }

        return [$exception, $result];
    }
}

这是来自framework/Testing/TestCase.php

这个新的assertExceptionThrown运行一个函数并记录抛出的异常和方法的结果。如果没有抛出异常,正在运行的测试将失败。

$result只有在没有抛出预期的异常时才有用。它只是为了帮助调试,以防有风险的闭包没有产生期望的异常。

这使得testValidEmailValuesPass更加干净:

public function testInvalidEmailValuesFail()
{
    $expected = ['email' => ['email should be an email']];

    // try {

    // }
    // catch (Throwable $e) {
    //     assert($e instanceof ValidationException, 'error should be thrown');
    //     assert($e->getErrors()['email'] === $expected['email'], 'messages should match');
    //     return;
    // }

    [ $exception ] = $this->assertExceptionThrown(
        fn() => $this->manager->validate(['email' => 'foo'], ['email' => ['email']]),
        ValidationException::class,
    );

    $this->assertEquals($expected, $exception->getErrors());

    // throw new Exception('validation did not fail');
}

这是来自tests/ValidationTest.php

我们还使用 PHPUnit 的assertEquals方法来比较完整的期望值和异常中返回的错误的完整数组。让我们添加一个 Composer 脚本来更快地运行测试:

"scripts": {
    "serve": "php -S 127.0.0.1:8000 -t public",
    "test": "vendor/bin/phpunit"
},

这是来自composer.json

现在,我们可以用composer test运行测试。结果看起来超级酷,但目前只有一个测试通过了。

img/299823_2_En_9_Figa_HTML.jpg

用 PHPUnit 运行测试

让我们将下一个测试改为使用 PHPUnit 的断言。我们不妨只检查validate方法的返回值:

public function testValidEmailValuesPass()
{
    // try {
    //     $this->manager->validate(['email' => 'foo@bar.com'], ['email' => ['email']]);
    // }
    // catch (Throwable $e) {
    //     throw new Exception('validation did failed');
    //     return;
    // }

    $data = $this->manager->validate(['email' => 'foo@bar.com'], ['email' => ['email']]);
    $this->assertEquals($data['email'], 'foo@bar.com');
}

这是来自tests/ValidationTest.php

为我们已经拥有的另外两个验证规则添加测试是很好的,但是它们看起来将与这些非常相似。也许你可以把它们加在章节之间?(轻推和眨眼。)

测试 HTTP 请求

让我们继续测试 routes 加载正确的页面,没有错误。我们可以通过实例化路由来做到这一点(就像我们刚刚为验证而做的单元测试),但我认为这是向应用发出请求的好机会。

对于设置,我们需要启动应用。然后,我们可以伪造对它的 HTTP 请求:

use Framework\App;

class RoutingTest extends Framework\Testing\TestCase
{
    protected App $app;

    public function setUp(): void
    {
        parent::setUp();

        $this->app = App::getInstance();
        $this->app->bind('paths.base', fn() => __DIR__ . '/../');
    }

    public function testHomePageIsShown()
    {
        $_SERVER['REQUEST_METHOD'] = 'GET';
        $_SERVER['REQUEST_URI'] = '/';

        ob_start();
        $this->app->run();
        $html = ob_get_contents();
        ob_end_clean();

        $expected = 'Take a trip on a rocket ship';

        $this->assertStringContainsString($expected, $html);
    }
}

这是来自tests/RoutingTest.php

路由使用REQUEST_METHODREQUEST_URI来计算选择哪条路由。我们可以输入自己的值——就像这些值是从浏览器发送的一样——路由将选择归属路由。

我们可以伪造更复杂的请求,比如我们想测试验证错误消息是否被正确显示:

public function testRegistrationErrorsAreShown()
{
    $_SERVER['REQUEST_METHOD'] = 'POST';
    $_SERVER['REQUEST_URI'] = '/register';
    $_SERVER['HTTP_REFERER'] = '/register';

    $_POST['email'] = 'foo';
    $_POST['csrf'] = csrf();

    $expected = 'email should be an email';

    $this->assertStringContainsString($expected, $this->app->run());
}

这是来自tests/RoutingTest.php

可惜,这是行不通的。从理论上来说,应该是这样,因为我们在为注册动作伪造表单提交的理想条件。问题是应用已经在发送消息头,可能与启动会话有关。

img/299823_2_En_9_Figb_HTML.jpg

邮件头已发送…

PHP 不喜欢我们在任何文本已经发送到浏览器或终端之后再发送标题。这个问题的解决方案并不简单。

我们基本上需要开始将我们的响应包装在响应对象中,我们可以检查这些对象以查看它们包含的内容,而无需将它们的文本内容发送到浏览器或终端。

让我们创建这个新的Response类,并将其绑定到容器:

namespace Framework\Http;

use InvalidArgumentException;

class Response
{
    const REDIRECT = 'REDIRECT';
    const HTML = 'HTML';
    const JSON = 'JSON';

    private string $type = 'HTML';
    private ?string $redirect = null;
    private mixed $content = '';
    private int $status = 200;
    private array $headers = [];

    public function content(mixed $content = null): mixed
    {
        if (is_null($content)) {
            return $this->content;
        }

        $this->content = $content;

        return $this;
    }

    public function status(int $code = null): int|static
    {
        if (is_null($code)) {
            return $this->code;
        }

        $this->code = $code;

        return $this;
    }

    public function header(strign $key, string $value): static
    {
        $this->headers[$key] = $value;
        return $this;
    }

    public function redirect(string $redirect = null): mixed
    {
        if (is_null($redirect)) {
            return $this->redirect;
        }

        $this->redirect = $redirect;
        $this->type = static::REDIRECT;
        return $this;
    }

    public function json(mixed $content): static
    {
        $this->content = $content;
        $this->type = static::JSON;
        return $this;
    }

    public function type(string $type = null): string|static
    {
        if (is_null($type)) {
            return $this->type;
        }

        $this->type = $type;

        return $this;
    }

    public function send(): string
    {
        foreach ($this->headers as $key => $value) {
            header("{$key}: {$value}");
        }

        if ($this->type === static::HTML) {
            header('Content-Type: text/html');
            http_response_code($this->status);
            print $this->content;
        }

        if ($this->type === static::JSON) {
            header('Content-Type: application/json');
            http_response_code($this->status);
            print json_encode($this->content);
        }

        if ($this->type === static::REDIRECT) {
            header("Location: {$this->redirect}");
            http_response_code($this->code);
        }

        throw new InvalidArgumentException("{$this->type} is not a recognised type");
    }
}

这是来自framework/Http/Response.php

我认为支持三种最常见的响应类型是个好主意:HTML、JSON 和重定向响应。这里,我们遵循以前遵循的模式,即将 getters 和 setters 混合在一起。这种情况下特别有效,因为我们在每个控制器中发送响应。

这个类和我们之前采用的方法之间的一个重要区别是,没有任何东西被自动发送到浏览器。我们必须调用send方法来实现这一点。

让我们将这个类绑定到容器,这样我们就可以在应用的其余部分使用它:

namespace Framework\Provider;

use Framework\App;
use Framework\Http\Response;

class ResponseProvider
{
    public function bind(App $app)
    {
        $app->bind('response', function($app) {
            return new Response();
        });
    }
}

这是来自framework/Provider/ResponseProvider.php

你可能在想:“坚持住。回应不是一种服务。我们为什么要将它绑定为服务?”

它不是,这是一种奇怪的传递方式,但它是我们希望能够重用的东西,所以我们在应用的生命周期中添加到现有的响应对象。

现在,我们可以使用response绑定来包装所有响应:

public function run()
{
    if (session_status() !== PHP_SESSION_ACTIVE) {
        session_start();
    }

    $basePath = $this->resolve('paths.base');

    $this->configure($basePath);
    $this->bindProviders($basePath);

    return $this->dispatch($basePath);
}

private function dispatch(string $basePath): Response
{
    $router = new Router();

    $this->bind(Router::class, fn() => $router);

    $routes = require "{$basePath}/app/routes.php";
    $routes($router);

    $response = $router->dispatch();

    if (!$response instanceof Response) {
        $response = $this->resolve('response')->content($response);
    }

    return $response;
}

这是来自framework/App.php

如果控制器返回的不是这个响应类,我们可以假设这个值应该是发送到浏览器的 HTML。最后,我们应该更改public.php入口点,以便它发送响应:

$app = \Framework\App::getInstance();
$app->bind('paths.base', fn() => __DIR__ . '/../');
$app->run()->send();

这是来自public/index.php

花点时间启动服务器(你可以用composer serve来完成),确保你仍然可以看到所有的页面。

我们还可以重构重定向助手来使用这种新的重定向方法:

if (!function_exists('response')) {
    function response()
    {
        return app('response');
    }
}

if (!function_exists('redirect')) {
    function redirect(string $url)
    {
        return response()->redirect($url);
    }
}

这是来自framework/helpers.php

测试也需要更新以使用这种新的run方法:

public function testHomePageIsShown()
{
    $_SERVER['REQUEST_METHOD'] = 'GET';
    $_SERVER['REQUEST_URI'] = '/';

    $expected = 'Take a trip on a rocket ship';

    $this->assertStringContainsString($expected, $this->app->run()->content());
}

public function testRegistrationErrorsAreShown()
{
    $_SERVER['REQUEST_METHOD'] = 'POST';
    $_SERVER['REQUEST_URI'] = '/register';
    $_SERVER['HTTP_REFERER'] = '/register';

    $_POST['email'] = 'foo';
    $_POST['csrf'] = csrf();

    $expected = 'email should be an email';

    $this->assertStringContainsString($expected, $this->app->run()->content());
}

这是来自tests/RoutingTest.php

有了这个新的抽象,错误就不再是在内容之后发送标题了。

img/299823_2_En_9_Figc_HTML.jpg

新的错误

现在的问题是重定向没有被发送到终端。对于如何检查测试中的响应,我们需要更聪明一点。让我们创建一个 response decorator 类,它可以在响应中“跟随重定向”,而无需向终端或浏览器发送任何头或内容:

namespace Framework\Testing;

use Framework\App;
use Framework\Http\Response;

class TestResponse
{
    private Response $response;

    public function __construct(Response $response)
    {
        $this->response = $response;
    }

    public function isRedirecting(): bool
    {
        return $this->response->type() === Response::REDIRECT;
    }

    public function redirectingTo(): ?string
    {
        return $this->response->redirect();
    }

    public function follow(): static
    {
        while ($this->isRedirecting()) {
            $_SERVER['REQUEST_METHOD'] = 'GET';
            $_SERVER['REQUEST_URI'] = $this->redirectingTo();
            $this->response = App::getInstance()->run();
        }

        return $this;
    }

    public function __call(string $method, array $parameters = []): mixed
    {
        return $this->response->$method(...$parameters);
    }
}

这是来自framework/Testing/TestResponse.php

就我们测试的内容而言,这也使我们的注册错误测试更加清晰:

public function testRegistrationErrorsAreShown()
{
    $_SERVER['REQUEST_METHOD'] = 'POST';
    $_SERVER['REQUEST_URI'] = '/register';
    $_SERVER['HTTP_REFERER'] = '/register';

    $_POST['email'] = 'foo';
    $_POST['csrf'] = csrf();

    $response = new TestResponse($this->app->run());

    $this->assertTrue($response->isRedirecting());
    $this->assertEquals('/register', $response->redirectingTo());

    $response->follow();

    $this->assertStringContainsString('email should be an email', $response->content());
}

这是来自tests/RoutingTest.php

这些测试几乎向应用发出完整的 HTTP 请求,是集成测试的例子。我们不只是在测试一个类或方法:我们在测试完整的控制器功能、重定向和存储在会话中的错误。

这是一种不太集中的测试,但它在短时间内覆盖了很多领域。最好的测试套件是单元测试和集成测试的健康结合。

我们的测试现在应该通过了!

img/299823_2_En_9_Figd_HTML.jpg

测试通过

测试浏览器交互

我想谈的最后一个主题是测试网站,就像我们使用浏览器一样(但仍然是自动化过程)。这类测试往往很脆弱,因为它们依赖于 HTML 的结构,但它们是测试所有 JavaScript 代码工作正常的唯一方法…

为了实现这一目标,我们将引入另一个优秀的依赖项——Symfony Panther。它是 PHP 和浏览器之间的桥梁:

composer require --dev symfony/panther
composer require --dev dbrekelmans/bdi
vendor/bin/bdi detect drivers

这些命令安装 Panther 和一个推荐的浏览器驱动程序。Panther 提供了一些助手,用于与浏览器交互。它基于脸书的网络驱动库。有了 Panther,我们可以做各种很酷的事情:

use Facebook\WebDriver\WebDriverBy;
use Framework\Testing\TestCase;
use Symfony\Component\Panther\Client;

class BrowserTest extends TestCase
{
    public function testLoginForm()
    {
        $client = Client::createFirefoxClient();
        $client->request('GET', 'http://127.0.0.1:8000/register');

        $client->waitFor('.log-in-button');
        $client->executeScript("document.querySelector('.log-in-button').click()");

        $client->waitFor('.log-in-errors');
        $element = $client->findElement(WebDriverBy::className('log-in-form'));

        $this->assertStringContainsString('password is required', $element->getText());
    }
}

这是来自tests/BrowserTest.php

如果你使用 ChromeDriver,而不是 GeckoDriver,你应该使用createChromeClient方法。这些方法启动一个测试浏览器,代码使用它与页面的 HTML 进行交互。

浏览器测试通常是一系列的步骤,在这些步骤中,我们等待东西变得可用,然后使用它们(通过点击、键入等)。在本例中,我们等待一个登录按钮,单击它,然后确保显示错误消息。

这不是严格地测试 JavaScript 功能,但是如果你需要这样做,那么你可以这样做。

确保将所有这些助手类添加到您的登录表单的 HTML 中:

<form
  method="post"
  action="{{ $logInAction }}"
  class="flex flex-col w-full space-y-4 max-w-xl log-in-form"
>
  @if(isset($_SESSION['login_errors']))
  <ol class="list-disc text-red-500 log-in-errors">
    @foreach($_SESSION['login_errors'] as $field => $errors) @foreach($errors as
    $error)
    <li>{{ $error }}</li>
    @endforeach @endforeach
  </ol>
  @endif
  <!-- ... -->
  <button
    type="submit"
    class="bg-indigo-500 rounded-lg p-2 text-white log-in-button"
  >
    Log in
  </button>
</form>

这是来自resources/views/users/register.advanced.php

如果您希望看到测试浏览器工作,您可以使用一个特殊的环境变量来运行测试:

PANTHER_NO_HEADLESS=1 composer test

如果你使用的是正确的浏览器客户端,并且你已经把所有的东西都编码好了,你应该会看到 Chromium 或者 Firefox 启动并进入你网站的注册页面。

您还应该看到测试浏览器单击 login 按钮,并看到错误消息作为结果出现。

我推荐看一下 Symfony Panther 文档和脸书 WebDriver 文档,以了解更多关于这些库如何工作的信息。

警告

这是旋风般的一章。关于测试,关于支撑我们的应用和框架代码以便更容易测试,有太多东西需要学习。

在我们的框架中有很多我们可以改进的地方,使它更容易测试。例如,我们需要在测试文件中启动会话,这并不好。我们将在接下来的章节中修正这些问题,但是不要让框架中需要改进的地方阻止你尝试测试。

要在你的头脑中巩固这一知识,有很多事情要做:

  • 如果能完成测试验证、路由和表单交互就太好了。

  • 如果我们有某种 HTTP 请求抽象,测试请求会更容易,就像我们有 HTTP 响应抽象一样。

  • 我们如何在测试套件开始时自动启动网站服务器,并在所有测试运行后停止它?

专业人士如何做事

每个好的和流行的框架都有测试助手,几乎所有的都在幕后使用 PHPUnit。这是业内测试的标准。

这并不是说您不能或不应该尝试构建自己的替代方案,但这确实意味着您可以依赖这个可爱的开源库。

Laravel 有更多的测试工具,但是测试 HTTP 响应的底层方法是相同的。Laravel 的 HTTP 类建立在 Symfony 的 HTTP 类之上。

这意味着在 Symfony 中工作的代码和方法很可能在 Laravel 应用中工作,没有什么大惊小怪的。这也意味着对这些类的理解是必不可少的,并且可以在用两种框架编写的应用之间转移。

有许多社区支持的库和方法可以向浏览器发送不同种类的响应,比如 RSS、XML 和流响应。

我们已经看到了 Panther,但是 Laravel 有一个类似的浏览器测试库,叫做 Dusk。在框架之外,它不是超级可移植的,但是如果您在 Laravel 应用中进行浏览器测试,那么使用它是一种享受。

摘要

在这一章中,我们学习了很多关于测试的知识。随着我们构建更多的框架,这是我们应该继续做的事情,但这可能是一个陡峭的学习曲线。

在下一章,我们将开始用基于驱动程序的库来填充我们的框架,以做一些有用的事情(比如处理会话和文件系统)。

十、配置、缓存、会话、文件系统

既然我们已经解决了测试和服务地点的问题,现在我们已经进入了旅程的最后阶段。在这一章中,我们将制定一个更好的加载配置的方法。

我们还将为缓存、会话管理和文件系统访问添加驱动程序。我们将关注每个技术领域的一到两个驱动程序,但我们将建立一个良好的基础,在此基础上您可以添加自己的驱动程序。

自上次以来有什么变化?

在我们进入这一章的主要内容之前,我想回顾一下我在这两章之间做了哪些修改。在前一章快结束时,我设置了一些挑战,这是我为解决这些挑战所做的工作的总结…

我开始清理一些已经转移到Model类的数据库配置。这是我开始之前的样子:

public function getConnection(): Connection
{
    if (!isset($this->connection)) {
        $factory = new Factory();

        $factory->addConnector('mysql', function($config) {
            return new MysqlConnection($config);
        });

        $factory->addConnector('sqlite', function($config) {
            return new SqliteConnection($config);
        });

        $config = require basePath() . 'config/database.php';

        $this->connection = $factory->connect($config[$config['default']]);
    }

    return $this->connection;
}

这是来自framework/Database/Model.php

我认为最好在提供者中进行配置:

namespace Framework\Provider;

use Framework\App;
use Framework\Database\Factory;
use Framework\Database\Connection\MysqlConnection;
use Framework\Database\Connection\SqliteConnection;

class DatabaseProvider
{
    public function bind(App $app): void
    {
        $app->bind('database', function($app) {
            $factory = new Factory();
            $this->addMysqlConnector($factory);
            $this->addSqliteConnector($factory);

            $config = $this->config($app);

            return $factory->connect($config[$config['default']]);
        });
    }

    private function config(App $app): array
    {
        $base = $app->resolve('paths.base');
        $separator = DIRECTORY_SEPARATOR;

        return require "{$base}{$separator}config/database.php";
    }

    private function addMysqlConnector($factory): void
    {
        $factory->addConnector('sqlite', function($config) {
            return new SqliteConnection($config);
        });
    }

    private function addSqliteConnector($factory): void
    {
        $factory->addConnector('mysql', function($config) {
            return new MysqlConnection($config);
        });
    }
}

这是来自framework/Provider/DatabaseProvider.php

这意味着任何需要预配置数据库连接的东西都可以直接从容器中访问它。我们可以显著缩短模型代码:

public function getConnection(): Connection
{
    if (!isset($this->connection)) {
        $this->connection = app('database');
    }

    return $this->connection;
}

这是来自framework/Database/Model.php

我把剩下的时间花在了添加自动化测试套件上。我为注册表单的验证添加了浏览器测试,并为剩余的验证规则添加了单元测试。

我不喜欢在一个终端选项卡中保持服务器运行,而在另一个选项卡中运行浏览器测试,所以我想出了一种方法让浏览器测试在运行时“引导”服务器。

为了实现这一点,我必须将composer serve命令重构为一个框架命令:

namespace Framework\Support\Command;

use InvalidArgumentException;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Process\Process;

class ServeCommand extends Command
{
    protected static $defaultName = 'serve';

    private Process $process;

    protected function configure()
    {
        $this
            ->setDescription('Starts a development server')
            ->setHelp('You can provide an optional host and port, for the development server.')
            ->addOption('host', null, InputOption::VALUE_REQUIRED)
            ->addOption('port', null, InputOption::VALUE_REQUIRED);
    }

    protected function execute(InputInterface $input, OutputInterface $output): int
    {
        $base = app('paths.base');
        $host = $input->getOption('host') ?: env('APP_HOST', '127.0.0.1');
        $port = $input->getOption('port') ?: env('APP_PORT', '8000');

        if (empty($host) || empty($port)) {
            throw new InvalidArgumentException('APP_HOST and APP_PORT both need values');
        }

        $this->handleSignals();
        $this->startProcess($host, $port, $base, $output);

        return Command::SUCCESS;
    }

    private function command(string $host, string $port, string $base): array
    {
        $separator = DIRECTORY_SEPARATOR;

        return [
            PHP_BINARY,
            "-S",
            "{$host}:{$port}",
            "{$base}{$separator}server.php",
        ];
    }

    private function handleSignals(): void
    {
        pcntl_async_signals(true);

        pcntl_signal(SIGTERM, function($signal) {
            if ($signal === SIGTERM) {
                $this->process->signal(SIGKILL);
                exit;
            }
        });
    }

    private function startProcess(string $host, string $port, string $base, OutputInterface $output): void
    {
        $this->process = new Process($this->command($host, $port, $base), $base);
        $this->process->setTimeout(PHP_INT_MAX);

        $this->process->start(function($type, $buffer) use ($output) {
            $output->write("<info>{$buffer}</info>");
        });

        $output->writeln("Serving requests at http://{$host}:{$port}");

        $this->process->wait();
    }
}

这是来自framework/Support/Command/ServeCommand.php

这个命令封装了运行 PHP 开发服务器的代码,但是它不是指向一个公共文件夹,而是指向一个server.php文件。这是对public/index.php的代理:

$path = __DIR__;
$separator = DIRECTORY_SEPARATOR;
$uri = urldecode(parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH));

if (is_file("{$path}{$separator}public{$separator}{$uri}")) {
    return false;
}

require_once "{$path}{$separator}public{$separator}index.php";

这是来自server.php

也在用信号做一些有趣的事情。有一个 PHP 扩展,默认情况下与 PHP 一起安装,可以用来拦截许多中断信号。

我实现了一个信号监听器,这样我就可以在命令停止时优雅地停止 PHP 开发服务器。这是我从 Cal Evans 的 信令 PHP 中学来的一招。

使用这个 serve 命令,我可以添加一个 PHPUnit 扩展,它在测试之前启动服务器,在测试运行之后停止服务器:

namespace Framework\Testing;

use PHPUnit\Runner\BeforeFirstTestHook;
use PHPUnit\Runner\AfterLastTestHook;
use Symfony\Component\Process\Process;

final class ServerExtension implements BeforeFirstTestHook, AfterLastTestHook
{
    private Process $process;
    private bool $startedServer = false;

    private function startServer()
    {
        if ($this->serverIsRunning()) {
            $this->startedServer = false;
            return;
        }

        $this->startedServer = true;

        $base = app('paths.base');
        $separator = DIRECTORY_SEPARATOR;

        $this->process = new Process([
            PHP_BINARY,
            "{$base}{$separator}command.php",
            "serve"
        ], $base);

        $this->process->start(function($type, $buffer) {
            print $buffer;
        });
    }

    private function serverIsRunning()
    {
        $connection = @fsockopen(
            env('APP_HOST', '127.0.0.1'),
            env('APP_PORT', '8000'),
        );

        if (is_resource($connection)) {
            fclose($connection);
            return true;
        }

        return false;
    }

    private function stopServer()
    {
        if ($this->startedServer) {
            $this->process->signal(SIGTERM);
        }
    }

    public function executeBeforeFirstTest(): void
    {
        $this->startServer();
    }

    public function executeAfterLastTest(): void
    {
        $this->stopServer();
    }
}

这是来自framework/Testing/ServerExtension.php

在这里,我们看到 SIGTERM 信号被发送到 serve 命令。如果没有它(和信号处理),就有测试运行结束时服务器没有关闭的风险。有点奇怪,但事情就是这样发生的。

img/299823_2_En_10_Figa_HTML.jpg

处理信号

还有其他一些小变化,但这些是需要了解的重要变化。现在,我想我们已经准备好进入这一章的内容了!

更好的配置管理

我们一直使用有限的配置数据,通常使用require语句。我认为,通过实现以下目标,我们可以做得更好:

  1. 根据需要缓存配置

  2. 抽象加载配置文件的文件系统细节

可能有很多方法可以存储和加载配置(例如,从数据库或第三方服务),但我们将保持简单。让我们将文件系统配置文件访问转移到一个中心类,并将其绑定到容器。

Laravel 使用了一种点符号,我希望我们朝着这个方向发展。配置查找采用config('database.default')的形式,这会导致config/database.php文件被加载,第一个点之后的所有内容都用于遍历嵌套数组。

例如,config('database.mysql.username')正在从config/database.php加载['mysql']['username']。这不是太多的工作要复制…

让我们从Config类开始:

namespace Framework\Support;

use Framework\App;

class Config
{
    private array $loaded = [];

    public function get(string $key, mixed $default = null): mixed
    {
        $segments = explode('.', $key);
        $file = array_shift($segments);

        if (!isset($this->loaded[$file])) {
            $base = App::getInstance()->resolve('paths.base');
            $separator = DIRECTORY_SEPARATOR;

            $this->loaded[$file] = (array) require "{$base}{$separator}config{$separator}{$file}.php";
        }

        if ($value = $this->withDots($this->loaded[$file], $segments)) {
            return $value;
        }

        return $default;
    }

    private function withDots(array $array, array $segments): mixed
    {
        $current = $array;

        foreach ($segments as $segment) {
            if (!isset($current[$segment])) {
                return null;
            }

            $current = $current[$segment];
        }

        return $current;
    }
}

这是来自framework/Support/Config.php

我们首先将$key解构为我们想要的配置值。第一部分是文件名,其余部分用于嵌套查找。

withDots是一种遍历嵌套数组的迭代方法,但是递归方法也同样有效。

这需要绑定到容器,以便更容易使用:

namespace Framework\Provider;

use Framework\App;
use Framework\Support\Config;

class ConfigProvider
{
    public function bind(App $app): void
    {
        $app->bind('config', function($app) {
            return new Config();
        });
    }
}

这是来自framework/Provider/ConfigProvider.php

ConfigProvider类添加到config/providers.php中,以便加载应用。它应该是第一个条目,以便后续的提供者可以访问新的配置抽象…

我们现在可以清理框架中使用配置的各个部分。下面是清理数据库提供程序代码的方法:

public function bind(App $app): void
{
    $app->bind('database', function($app) {
        $factory = new Factory();
        $this->addMysqlConnector($factory);
        $this->addSqliteConnector($factory);

        // $config = $this->config($app);
        $config = $app->resolve('config')->get('database');

        return $factory->connect($config[$config['default']]);
    });
}

// private function config(App $app): array
// {
//     $base = $app->resolve('paths.base');
//     $separator = DIRECTORY_SEPARATOR;

//     return require "{$base}{$separator}config/database.php";
// }

这是来自framework/Provider/DatabaseProvider.php

我们可以通过创建一个配置助手来做得更好:

if (!function_exists('config')) {
    function config(string $key, mixed $default = null): mixed
    {
        return app('config')->get($key, $default);
    }
}

这是来自framework/helpers.php

最终的数据库提供程序代码如下所示:

public function bind(App $app): void
{
    $app->bind('database', function($app) {
        $factory = new Factory();
        $this->addMysqlConnector($factory);
        $this->addSqliteConnector($factory);

        $config = config('database');

        return $factory->connect($config[$config['default']]);
    });
}

这是来自framework/Provider/DatabaseProvider.php

除了使配置更容易使用之外,这种抽象还使配置管理更有效——因为配置文件只加载一次。

现在我们有了这个,我们可以毫不费力地使用越来越多的配置文件(用于缓存、会话和文件系统)!

躲藏

我能想到许多不同的缓存提供者,但大多数都涉及第三方服务或与 web 服务器并行运行的服务器。让我们实现对以下几种更简单的缓存提供者的支持:

  1. 内存缓存(也就是我们用来缓存配置的那种)

  2. 文件系统缓存

  3. 快取记忆体

    Memcache 是一个与 web 服务器并行运行的服务器,这意味着我们需要安装它来运行这个缓存驱动程序。如果你在安装时遇到问题,那么你可以跳过这个特殊的“驱动程序”…

我们需要一个与数据库连接类似的工厂设置:

namespace Framework\Cache;

use Closure;
use Framework\Cache\Driver\Driver;
use Framework\Cache\Exception\DriverException;

class Factory
{
    protected array $drivers;

    public function addDriver(string $alias, Closure $driver): static
    {
        $this->drivers[$alias] = $driver;
        return $this;
    }

    public function connect(array $config): Driver
    {
        if (!isset($config['type'])) {
            throw new DriverException('type is not defined');
        }

        $type = $config['type'];

        if (isset($this->drivers[$type])) {
            return $this->drivers$type;
        }

        throw new DriverException('unrecognised type');
    }
}

这是来自framework/Cache/Factory.php

DriverExceptionRuntimeException的空子类。缓存配置文件如下所示:

return [
    'default' => 'memory',
    'memory' => [
        'type' => 'memory',
        'seconds' => 31536000,
    ],
];

这是来自config/cache.php

目前这真的很简单,但是随着我们增加额外的驱动程序,会变得更加复杂。31536000秒是 1 年,我们将使用它作为默认的缓存到期值。然而,Driver界面更有趣一些:

namespace Framework\Cache\Driver;

interface Driver
{
    /**
     * Tell if a value is cached (still)
     */
    public function has(string $key): bool;

    /**
     * Get a cached value
     */
    public function get(string $key, mixed $default = null): mixed;

    /**
     * Put a value into the cache, for an optional number of seconds
     */
    public function put(string $key, mixed $value, int $seconds = null): static;

    /**
     * Remove a single cached value
     */
    public function forget(string $key): static;

    /**
     * Remove all cached values
     */
    public function flush(): static;
}

这是来自framework/Cache/Driver/Driver.php

我们可以通过将这些方法签名中的每一个连接到内部数组来创建内存中的驱动程序:

namespace Framework\Cache\Driver;

class MemoryDriver implements Driver
{
    private array $config = [];
    private array $cached = [];

    public function __construct(array $config)
    {
        $this->config = $config;
    }

    public function has(string $key): bool
    {
        return isset($this->cached[$key]) && $this->cached[$key]['expires'] > time();
    }

    public function get(string $key, mixed $default = null): mixed
    {
        if ($this->has($key)) {
            return $this->cached[$key]['value'];
        }

        return $default;
    }

    public function put(string $key, mixed $value, int $seconds = null): static
    {
        if (!is_int($seconds)) {
            $seconds = (int) $this->config['seconds'];
        }

        $this->cached[$key] = [
            'value' => $value,
            'expires' => time() + $seconds,
        ];

        return $this;
    }

    public function forget(string $key): static
    {
        unset($this->cached[$key]);
        return $this;
    }

    public function flush(): static
    {
        $this->cached = [];
        return $this;
    }
}

这是来自framework/Cache/Driver/MemoryDriver.php

hasget方法看起来类似于我们对Config类所做的,只是增加了一个expires键。当有人告诉我们一个值要缓存多少秒时,我们将这些秒加到 unix 时间戳上。我们可以将其与 unix 时间戳(将来)进行比较,以确定该值是否应该过期。

让我们将它连接到一个提供者中,这样我们就可以快速地使用它:

namespace Framework\Provider;

use Framework\App;
use Framework\Cache\Factory;
use Framework\Cache\Driver\MemoryDriver;

class CacheProvider
{
    public function bind(App $app): void
    {
        $app->bind('cache', function($app) {
            $factory = new Factory();
            $this->addMemoryDriver($factory);

            $config = config('cache');

            return $factory->connect($config[$config['default']]);
        });
    }

    private function addMemoryDriver($factory): void
    {
        $factory->addDriver('memory', function($config) {
            return new MemoryDriver($config);
        });
    }
}

这是来自framework/Provider/CacheProvider.php

我们可以利用这一点来存储不太可能经常改变的数据:

$cache = app('cache');
$products = Product::all();

$productsWithRoutes = array_map(function ($product) use ($router) {
    $key = "route-for-product-{$product->id}";

    if (!$cache->has($key)) {
        $cache->put($key, $router->route('view-product', ['product' => $product->id]));
    }

    $product->route = $cache->get($key);

    return $product;
}, $products);

return view('home', [
    'products' => $productsWithRoutes,
]);

这是来自app/Http/Controllers/ShowHomePageController.php

在这个例子中,我们可以一次性计算出每个产品的路线,并将其存储在缓存中。

不要忘记将CacheProvider类添加到config/providers.php中,这样就可以加载应用了。

在使用 PHP 开发服务器时,内存驱动程序有点没用,因为在每个页面返回到浏览器后,内存都会被清除。这对于测试目的或者在每次请求后都不清除内存的环境中非常有用。

让我们添加下一个驱动程序,其中缓存的值存储在文件系统中:

namespace Framework\Cache\Driver;

use Framework\App;

class FileDriver implements Driver
{
    private array $config = [];
    private array $cached = [];

    public function __construct(array $config)
    {
        $this->config = $config;
    }

    public function has(string $key): bool
    {
        $data = $this->cached[$key] = $this->read($key);

        return isset($data['expires']) and $data['expires'] > time();
    }

    private function path(string $key): string
    {
        $base = $this->base();
        $separator = DIRECTORY_SEPARATOR;
        $key = sha1($key);

        return "{$base}{$separator}{$key}.json";
    }

    private function base(): string
    {
        $base = App::getInstance()->resolve('paths.base');
        $separator = DIRECTORY_SEPARATOR;

        return "{$base}{$separator}storage{$separator}framework{$separator}cache";
    }

    private function read(string $key)
    {
        $path = $this->path($key);

        if (!is_file($path)) {
            return [];
        }

        return json_decode(file_get_contents($path), true);
    }

    public function get(string $key, mixed $default = null): mixed
    {
        if ($this->has($key)) {
            return $this->cached[$key]['value'];
        }

        return $default;
    }

    public function put(string $key, mixed $value, int $seconds = null): static
    {
        if (!is_int($seconds)) {
            $seconds = (int) $this->config['seconds'];
        }

        $data = $this->cached[$key] = [
            'value' => $value,
            'expires' => time() + $seconds,
        ];

        return $this->write($key, $data);
    }

    private function write(string $key, mixed $value): static
    {
        file_put_contents($this->path($key), json_encode($value));
        return $this;
    }

    public function forget(string $key): static
    {
        unset($this->cached[$key]);

        $path = $this->path($key);

        if (is_file($path)) {
            unlink($path);
        }

        return $this;
    }

    public function flush(): static
    {
        $this->cached = [];

        $base = $this->base();
        $separator = DIRECTORY_SEPARATOR;

        $files = glob("{$base}{$separator}*.json");

        foreach ($files as $file){
            if (is_file($file)) {
                unlink($file);
            }
        }

        return $this;
    }
}

这是来自framework/Cache/Driver/FileDriver.php

这里,我们保留了内部缓存数组的概念,但这样做只是为了减少多次读取同一个文件的次数。缓存值及其到期时间保存在 JSON 文件中。

这不是一个有效的缓存驱动程序,所以它只在没有更好的替代驱动程序可用的情况下,或者在只有缓存的值比多个文件系统读取和写入花费更长时间的情况下才真正有用。

让我们扩展这个文件系统驱动程序的配置,以及我们将要添加的 Memcache 驱动程序:

return [
    'default' => 'memcache',
    'memory' => [
        'type' => 'memory',
        'seconds' => 31536000,
    ],
    'file' => [
        'type' => 'file',
        'seconds' => 31536000,
    ],
    'memcache' => [
        'type' => 'memcache',
        'host' => '127.0.0.1',
        'port' => 11211,
        'seconds' => 31536000,
    ],
];

这是来自config/cache.php

最终的缓存驱动程序使用 Memcache:

namespace Framework\Cache\Driver;

use Memcached;

class MemcacheDriver implements Driver
{
    private array $config = [];
    private Memcached $memcache;

    public function __construct(array $config)
    {
        $this->config = $config;

        $this->memcache = new Memcached();
        $this->memcache->addServer($config['host'], $config['port']);
    }

    public function has(string $key): bool
    {
        return $this->memcache->get($key) !== false;
    }

    public function get(string $key, mixed $default = null): mixed
    {
        if ($value = $this->memcache->get($key)) {
            return $value;
        }

        return $default;
    }

    public function put(string $key, mixed $value, int $seconds = null): static
    {
        if (!is_int($seconds)) {
            $seconds = (int) $this->config['seconds'];
        }

        $this->memcache->set($key, $value, time() + $seconds);
        return $this;
    }

    public function forget(string $key): static
    {
        $this->memcache->delete($key);
        return $this;
    }

    public function flush(): static
    {
        $this->memcache->flush();
        return $this;
    }
}

这是来自framework/Cache/MemcacheDriver.php

这比文件系统驱动程序实现起来要快得多,因为 Memcache 在后台完成了大量的文件系统和序列化操作。

唯一棘手的是,Memcache实例是在构造函数中创建的,通过 getter 和 setter 或者构造函数注入(wink)可能会更好。

让我们将这两个新驱动程序添加到提供程序中:

public function bind(App $app): void
{
    $app->bind('cache', function($app) {
        $factory = new Factory();
        $this->addFileDriver($factory);
        $this->addMemcacheDriver($factory);
        $this->addMemoryDriver($factory);

        $config = config('cache');

        return $factory->connect($config[$config['default']]);
    });
}

private function addFileDriver($factory): void
{
    $factory->addDriver('file', function($config) {
        return new FileDriver($config);
    });
}

private function addMemcacheDriver($factory): void
{
    $factory->addDriver('memcache', function($config) {
        return new MemcacheDriver($config);
    });
}

private function addMemoryDriver($factory): void
{
    $factory->addDriver('memory', function($config) {
        return new MemoryDriver($config);
    });
}

这是来自framework/Provider/CacheProvider.php

花几分钟时间切换默认的缓存提供者,从memoryfile再到memcache。我觉得有趣的是,在使用非常不同的技术(在引擎盖下),改变一个配置变量的情况下,系统工作得很好。

会议

我们已经使用了会话,但是让我们在一个相似的因素/驱动安排中形式化代码。我们将继续支持本地会话管理——通过更好的初始化和简洁的getput方法。

虽然可以创建和使用其他会话驱动程序,但是考虑到我们在本章已经看到的内容,这个练习会变得有点乏味。如果你觉得这样做有挑战性,我建议尝试在章节之间添加额外的会话驱动。

让我们创建另一个工厂和相应的驱动程序接口:

namespace Framework\Session;

use Closure;
use Framework\Session\Driver\Driver;
use Framework\Session\Exception\DriverException;

class Factory
{
    protected array $drivers;

    public function addDriver(string $alias, Closure $driver): static
    {
        $this->drivers[$alias] = $driver;
        return $this;
    }

    public function connect(array $config): Driver
    {
        if (!isset($config['type'])) {
            throw new DriverException('type is not defined');
        }

        $type = $config['type'];

        if (isset($this->drivers[$type])) {
            return $this->drivers$type;
        }

        throw new DriverException('unrecognised type');
    }
}

这是来自framework/Session/Factory.php

这与缓存工厂类完全相同。想起来了。也许这是一个很好的抽象候选——一个这些库可以重用的通用类…

该接口不同于我们在缓存库中使用的接口:

namespace Framework\Session\Driver;

interface Driver
{
    /**
     * Tell if a value is session
     */
    public function has(string $key): bool;

    /**
     * Get a session value
     */
    public function get(string $key, mixed $default = null): mixed;

    /**
     * Put a value into the session
     */
    public function put(string $key, mixed $value): static;

    /**
     * Remove a single session value
     */
    public function forget(string $key): static;

    /**
     * Remove all session values
     */
    public function flush(): static;
}

这是来自framework/Session/Driver/Driver.php

主要区别在于会话方法不关心到期时间。如果过期是受管理的,那么它应该是配置的一部分,并在会话启动时设置。

本机会话驱动程序如下所示:

namespace Framework\Session\Driver;

class NativeDriver implements Driver
{
    private array $config = [];

    public function __construct(array $config)
    {
        $this->config = $config;

        if (session_status() !== PHP_SESSION_ACTIVE) {
            session_start();
        }
    }

    public function has(string $key): bool
    {
        $prefix = $this->config['prefix'];
        return isset($_SESSION["{$prefix}{$key}"]);
    }

    public function get(string $key, mixed $default = null): mixed
    {
        $prefix = $this->config['prefix'];

        if (isset($_SESSION["{$prefix}{$key}"])) {
            return $_SESSION["{$prefix}{$key}"];
        }

        return $default;
    }

    public function put(string $key, mixed $value): static
    {
        $prefix = $this->config['prefix'];
        $_SESSION["{$prefix}{$key}"] = $value;
        return $this;
    }

    public function forget(string $key): static
    {
        $prefix = $this->config['prefix'];
        unset($_SESSION["{$prefix}{$key}"]);
        return $this;
    }

    public function flush(): static
    {
        foreach (array_keys($_SESSION) as $key) {
            if (str_starts_with($key, $prefix)) {
                unset($_SESSION[$key]);
            }
        }

        return $this;
    }
}

这是来自framework/Session/Driver/NativeDriver.php

需要指出的一点是,我们存储的会话变量带有以可配置前缀为前缀的键。我们这样做是为了让框架可以与其他可能也在会话中存储值的库共存,而不存在键冲突的可能性。

因为我们要将对session_start的调用转移到这个类,所以我们可以将它从App类中移除:

public function run()
{
    // if (session_status() !== PHP_SESSION_ACTIVE) {
    //     session_start();
    // }

    $basePath = $this->resolve('paths.base');

    $this->configure($basePath);
    $this->bindProviders($basePath);

    return $this->dispatch($basePath);
}

这是来自framework/App.php

现在,我们需要会话配置文件和将它绑定到容器的提供者:

return [
    'default' => 'native',
    'native' => [
        'type' => 'native',
        'prefix' => 'framework_',
    ],
];

这是来自config/session.php

namespace Framework\Provider;

use Framework\App;
use Framework\Session\Factory;
use Framework\Session\Driver\NativeDriver;

class SessionProvider
{
    public function bind(App $app): void
    {
        $app->bind('session', function($app) {
            $factory = new Factory();
            $this->addNativeDriver($factory);

            $config = config('session');

            return $factory->connect($config[$config['default']]);
        });
    }

    private function addNativeDriver($factory): void
    {
        $factory->addDriver('native', function($config) {
            return new NativeDriver($config);
        });
    }
}

这是来自framework/Provider/SessionProvider.php

这看起来像是抽象的另一个候选,因为它基本上与CacheProvider类相同…

不要忘记将SessionProvider类添加到config/providers.php中,这样就可以加载应用了。

这意味着我们现在可以从任何地方使用会话,而不需要每次都引导它:

app('session')->put(
    'hits', app('session')->get('hits', 0) + 1
);

文件系统

本章中我们要看的最后一个库是用于文件系统的。我们可以使用文件系统做很多事情:

  1. 加载与模板相关的文件

  2. 加载国际化文件,以显示特定于区域设置的 UI 标签

  3. 存储图像、视频和音频文件等应用资产

还有各种我们可以存储东西的地方——各种可以称为文件系统的系统:

  1. 本地服务器文件系统

  2. 基于云的对象商店,像 S3GFS

  3. 基础设施服务,如 FTP、??、SFTP 和 ??

我们可以着手构建一些这样的驱动程序,但是我认为这是一个很好的机会来看看在我们自己的 API 中“包装”一个现有的文件系统库会涉及到什么。

我们将使用一个名为 Flysystem 的库,但我们将通过自己的镜头来呈现它。

让我们使用

composer require league/flysystem

现在,让我们创建另一个工厂,使用 Flysystem 自带的所有驱动程序:

namespace Framework\Filesystem;

use Closure;
use Framework\Filesystem\Driver\Driver;
use Framework\Filesystem\Exception\DriverException;

class Factory
{
    protected array $drivers;

    public function addDriver(string $alias, Closure $driver): static
    {
        $this->drivers[$alias] = $driver;
        return $this;
    }

    public function connect(array $config): Driver
    {
        if (!isset($config['type'])) {
            throw new DriverException('type is not defined');
        }

        $type = $config['type'];

        if (isset($this->drivers[$type])) {
            return $this->drivers$type;
        }

        throw new DriverException('unrecognised type');
    }
}

这是来自framework/Filesystem/Factory.php

更多相同的…

让我们为文件系统驱动程序创建一个配置文件:

return [
    'default' => 'local',
    'local' => [
        'type' => 'local',
        'path' => __DIR__ . '/../storage/app',
    ],
    's3' => [
        'type' => 's3',
        'key' => '',
        'secret' => '',
        'token' => '',
        'region' => '',
        'bucket' => '',
    ],
    'ftp' => [
        'type' => 'ftp',
        'host' => '',
        'root' => '',
        'username' => '',
        'password' => '',
    ],
];

这是来自config/filesystem.php

代替接口,我们可以使用一个抽象类来定义驱动程序的签名。这是因为我们实际上没有实现它们的任何功能,我们只是实例化了 Flysystem 驱动程序:

namespace Framework\Filesystem\Driver;

use League\Flysystem\Filesystem;

abstract class Driver
{
    protected Filesystem $filesystem;

    public function __construct(array $config)
    {
        $this->filesystem = $this->connect($config);
    }

    abstract protected function connect(array $config): Filesystem;

    public function list(string $path, bool $recursive = false): iterable
    {
        return $this->filesystem->listContents($path, $recursive);
    }

    public function exists(string $path): bool
    {
        return $this->filesystem->fileExists($path);
    }

    public function get(string $path): string
    {
        return $this->filesystem->read($path);
    }

    public function put(string $path, mixed $value): static
    {
        $this->filesystem->write($path, $value);
        return $this;
    }

    public function delete(string $path): static
    {
        $this->filesystem->delete($path);
        return $this;
    }
}

这是来自framework/Filesystem/Driver/Driver.php

我通过研究 Flysystem 文档构建了这些方法。每个 Flysystem 方法都“包装”在一个方法中,该方法与我们用其他库创建的模式相匹配。

每个驱动程序需要实现的唯一方法是抽象的connect方法。在LocalDriver类中是这样的:

namespace Framework\Filesystem\Driver;

use League\Flysystem\Filesystem;
use League\Flysystem\Local\LocalFilesystemAdapter;

class LocalDriver extends Driver
{
    protected function connect()
    {
        $adapter = new LocalFilesystemAdapter($this->config['path']);
        $this->filesystem = new Filesystem($adapter);
    }
}

这是来自framework/Filesystem/Driver/LocalDriver.php

记住,我们还需要一个在容器中绑定这些类的提供者:

namespace Framework\Provider;

use Framework\App;
use Framework\Filesystem\Factory;
use Framework\Filesystem\Driver\LocalDriver;

class FilesystemProvider
{
    public function bind(App $app): void
    {
        $app->bind('filesystem', function($app) {
            $factory = new Factory();
            $this->addLocalDriver($factory);

            $config = config('filesystem');

            return $factory->connect($config[$config['default']]);
        });
    }

    private function addLocalDriver($factory): void
    {
        $factory->addDriver('local', function($config) {
            return new LocalDriver($config);
        });
    }
}

这是来自framework/Provider/FilesystemProvider.php

最后,需要将这个提供者添加到config/providers.php中。该文件在本章的过程中不断发展,因此最终的提供者配置文件如下所示:

return [
    // load config first, so the rest can use it...
    \Framework\Provider\ConfigProvider::class,

    \Framework\Provider\CacheProvider::class,
    \Framework\Provider\DatabaseProvider::class,
    \Framework\Provider\FilesystemProvider::class,
    \Framework\Provider\ResponseProvider::class,
    \Framework\Provider\SessionProvider::class,
    \Framework\Provider\ValidationProvider::class,
    \Framework\Provider\ViewProvider::class,
];

这是来自config/providers.php

现在可以从应用中的任何地方访问文件系统抽象,如下例所示:

if (!app('filesystem')->exists('hits.txt')) {
    app('filesystem')->put('hits.txt', '');
}

app('filesystem')->put(
    'hits.txt',
    (int) app('filesystem')->get('hits.txt', 0) + 1,
);

警告

我们以创纪录的速度创建了缓存、会话和文件系统库。还有几件事要做,可以让这些变得更好:

  • 我们只创建了一个会话驱动程序。如果我们有更多的驱动程序,这将会很酷,但它肯定会涉及到使用内置的会话驱动方法来做好…

  • 我们只“包装”了一个 Flysystem 适配器——在本地驱动程序中。使用我们设置的配置和我们使用的模式,你认为你可以添加 S3 和 FTP 支持吗?

  • 到最后,很明显这些类中的一些可以重用——特别是工厂和提供者类。并非所有的工厂都是相同的(例如,数据库工厂),也并非所有的提供者都是相同的(例如,验证提供者)。对于非常相似的工厂和提供商,这可以减少我们需要维护的代码量…

  • 我们所有的配置都是无类型的和未经检查的。我们对配置值的结构和存在做了许多假设,所以在这里增加一些安全性是有用的。

  • 在其他库中重用其中的一些库会很好,比如重用文件系统库来支持基于文件的会话存储。你准备好迎接挑战了吗?

摘要

在本章中,我们创建了一个有用的配置抽象,然后用它来实现一些关键的框架组件。大多数流行的框架都包括这些组件以及其他一些组件。

在接下来的一章中,我们将会实现更多的,因为我们一起完成了我们的时间。试着在下一章之前完成一些挑战,这样你对这些组件的知识就会增长。

十一、队列、日志、电子邮件

我们正处于代码的最后一章,我们已经走了很长一段路。继续上一章的主题,我们将通过创建另外三个库来结束我们的旅程:这次是为了排队缓慢的操作、记录错误和发送电子邮件。

当我们创建开发人员体验,使我们的框架在所有技能集的开发人员中受欢迎时,我们将查看一些很好的底层库来重新打包。

同时…

我想在各章之间回顾一下我所做的一些事情,清理一些框架和应用代码。有一些较小的变化,但有两个变化非常显著:

  1. 创建更好的异常处理系统

  2. 重构大约一半只提供基于驱动的工厂的提供者

更好的异常处理

在我们旅程的开始,我们创造了路由。其中一部分是处理路由调度时抛出的异常。后来,我们通过拦截和响应验证异常,添加到潜在的异常列表中。

这是代码的样子,直到我开始弄乱它:

public function dispatch()
{
    $paths = $this->paths();

    $requestMethod = $_SERVER['REQUEST_METHOD'] ?? 'GET';
    $requestPath = $_SERVER['REQUEST_URI'] ?? '/';

    $matching = $this->match($requestMethod, $requestPath);

    if ($matching) {
        $this->current = $matching;

        try {
            return $matching->dispatch();
        }
        catch (Throwable $e) {
            if ($e instanceof ValidationException) {
                $_SESSION[$e->getSessionName()] = $e->getErrors();
                return redirect($_SERVER['HTTP_REFERER']);
            }

            if (isset($_ENV['APP_ENV']) && $_ENV['APP_ENV'] === 'dev') {
                $whoops = new Run();
                $whoops->pushHandler(new PrettyPageHandler);
                $whoops->register();
                throw $e;
            }

            return $this->dispatchError();
        }
    }

    if (in_array($requestPath, $paths)) {
        return $this->dispatchNotAllowed();
    }

    return $this->dispatchNotFound();
}

这是来自framework/Routing/Router.php

有一堆不属于这里的东西。路由不应该知道会话或验证库。如果其中任何一项被禁用(例如,为了加快响应时间),那么路由就会中断。

路由也不应该关心我们在开发中如何呈现有用的错误消息。

我们把这些东西放在这里,因为我们没有更好的地方放它们,因为它们是作为路由过程的一部分被触发的。我们需要更好的解决方案…

我通过创建一个名为ExceptionHandler的新支持类来解决这个问题:

namespace Framework\Support;

use Framework\Validation\Exception\ValidationException;
use Throwable;
use Whoops\Handler\PrettyPageHandler;
use Whoops\Run;

class ExceptionHandler
{
    public function showThrowable(Throwable $throwable)
    {
        if ($throwable instanceof ValidationException) {
            return $this->showValidationException($throwable);
        }

        if (isset($_ENV['APP_ENV']) && $_ENV['APP_ENV'] === 'dev') {
            $this->showFriendlyThrowable($throwable);
        }
    }

    public function showValidationException(ValidationException $exception)
    {
        if ($session = session()) {
            $session->put($exception->getSessionName(), $exception->getErrors());
        }

        return redirect(env('HTTP_REFERER'));
    }

    public function showFriendlyThrowable(Throwable $throwable)
    {
        $whoops = new Run();
        $whoops->pushHandler(new PrettyPageHandler());
        $whoops->register();

        throw $throwable;
    }
}

这是来自framework/Support/ExceptionHandler.php

这个新类负责决定如何处理框架可能抛出的各种异常。我们还没怎么用过这种控制流策略,但至少现在我们有了更好的使用方法。

它分为两个主要部分:

  1. 计算出Throwable的类型

  2. 用它做一些事情——无论这意味着在开发中重定向或显示一个有用的错误页面

这已经是一个比将这些代码放入路由更好的解决方案,但是如果允许开发人员将他们自己的异常处理加入进来,效果会更好。这个想法是我在 Laravel 中看到的,他们为所有新应用中的这种处理提供了一个模板。

新的应用在其应用文件夹中带有这个句柄的子类。我用一个新的应用类复制了这种行为:

namespace App\Exceptions;

use Framework\Support\ExceptionHandler;
use Throwable;

class Handler extends ExceptionHandler
{
    public function showThrowable(Throwable $throwable)
    {
        // add in some reporting...

        return parent::showThrowable($throwable);
    }
}

这是来自app/Exceptions/Handler.php

这有几个好处:

  1. 开发人员可以添加他们自己的异常控制流:他们可以在路由中抛出异常(针对异常情况),并在一个中心位置计算出如何处理这些异常。

  2. 不可能为源自框架内部的异常情况添加定制的错误日志和处理。

为了让框架知道将这些异常发送给哪个处理程序,我们需要一些识别适当类的系统。我认为一个配置文件会有用:

return [
    'exceptions' => \App\Exceptions\Handler::class,
];

这是来自config/handlers.php

然后我们需要使用这个配置文件来发送异常:

public function dispatch()
{
    $paths = $this->paths();

    $requestMethod = $_SERVER['REQUEST_METHOD'] ?? 'GET';
    $requestPath = $_SERVER['REQUEST_URI'] ?? '/';

    $matching = $this->match($requestMethod, $requestPath);

    if ($matching) {
        $this->current = $matching;

        try {
            return $matching->dispatch();
        }
        catch (Throwable $e) {
            $result = null;

            if ($handler = config('handlers.exceptions')) {
                $instance = new $handler();

                if ($result = $instance->showThrowable($e)) {
                    return $result;
                }
            }

            return $this->dispatchError();
        }
    }

    if (in_array($requestPath, $paths)) {
        return $this->dispatchNotAllowed();
    }

    return $this->dispatchNotFound();
}

这是来自framework/Routing/Router.php

使用这种模式,我们已经成功地从路由中移除了大多数异常处理细节。它仍然需要知道将异常发送到哪里,但是我们可以通过创建一个App::handleException方法来进一步删除这个细节,该方法执行相同的处理程序转发。

我没有走到最后一步,因为我认为这与客观的责任划分关系不大,而与个人偏好关系更大。

重构提供程序

在前一章中,我提到了有多少提供者和工厂使用相似的代码来获得相同的结果——特别是

  1. 创建了一个因子实例

  2. 增加了一些驱动因素

  3. 库的已解析配置

都使用完全相同的代码。仔细回顾之后,我意识到我们仍然需要所有的工厂类(因为类型提示),但是提供者可以从单个基础继承:

namespace Framework\Support;

use Framework\App;

abstract class DriverProvider
{
    final public function bind(App $app): void
    {
        $name = $this->name();
        $factory = $this->factory();
        $drivers = $this->drivers();

        $app->bind($name, function ($app) use ($name, $factory, $drivers) {
            foreach ($drivers as $key => $value) {
                $factory->addDriver($key, $value);
            }

            $config = config($name);

            return $factory->connect($config[$config['default']]);
        });
    }

    abstract protected function name(): string;
    abstract protected function factory(): mixed;
    abstract protected function drivers(): array;
}

这是来自framework/Support/DriverProvider.php

现在,这个提供者的子类可以专注于提供一个兼容的工厂和要添加到其中的驱动程序列表,而不是重复这个模式。工厂界面如下所示:

namespace Framework\Support;

use Closure;

interface DriverFactory
{
    public function addDriver(string $alias, Closure $driver): static;
    public function connect(array $config): mixed;
}

这是来自framework/Support/DriverFactory.php

这意味着提供商可以精简,所有提供商看起来都像这样:

namespace Framework\Provider;

use Framework\Cache\Factory;
use Framework\Cache\Driver\FileDriver;
use Framework\Cache\Driver\MemcacheDriver;
use Framework\Cache\Driver\MemoryDriver;
use Framework\Support\DriverProvider;
use Framework\Support\DriverFactory;

class CacheProvider extends DriverProvider
{
    protected function name(): string
    {
        return 'cache';
    }

    protected function factory(): DriverFactory
    {
        return new Factory();
    }

    protected function drivers(): array
    {
        return [
            'file' => function($config) {
                return new FileDriver($config);
            },
            'memcache' => function($config) {
                return new MemcacheDriver($config);
            },
            'memory' => function($config) {
                return new MemoryDriver($config);
            },
        ];
    }
}

这是来自framework/Provider/CacheProvider.php

那感觉好多了!我确信我们可以为本章中创建的库重用该模式。

排队等候

排队就是在请求/响应周期之外进行缓慢的操作。如果有人正在与您的网站交互,并要求做一些需要时间做的事情(如发送电子邮件或生成报告),您可以让他们等待处理完成,或者您可以在后台完成并在完成时通知他们。

通知是如何发生的完全是另一回事,但一种方法是给他们发电子邮件。我们将在本章末尾展示一个例子。

这种排队可以通过多种方式实现,从将工作指令存储在文本文件中,到将它们放入缓存中,再到使用专门设计的服务来简化消息的存储和检索。

img/299823_2_En_11_Figa_HTML.jpg

排队任务

让我们构建一个基于数据库的排队系统,包括以下主要组件:

  1. 一个数据库表和提供者/工厂库,用于将内容放入数据库表

  2. 从数据库中提取任务并运行它们的终端命令

这是我能想到的最简单的界面:

app('queue')->push(function($user) {
    // send a mail to the user...
}, $user);

对于这种方法来说,push是一个有趣的名字,因为它描述了将任务放入队列的过程。类似于 PHP 的 array_push 方法,我们会构建一个对应的shift方法。

为了促进这一功能,我们需要一个存储序列化参数(如$user)和序列化闭包的迁移:

use Framework\Database\Connection\Connection;

class CreateJobsTable
{
    public function migrate(Connection $connection)
    {
        $table = $connection->createTable('jobs');
        $table->id('id');
        $table->text('closure');
        $table->text('params');
        $table->int('attempts')->default(0);
        $table->bool('is_complete')->default(false);
        $table->execute();
    }
}

这是来自database/migrations/010_CreateJobsTable.php

我们稍后会看到这些领域中的内容。不过,在此之前,有一个问题需要解决,这是我对提供者所做的更改的后续。

migrate 命令是我们手动创建新连接的地方之一。我们可以通过调用app('database')来切换所有的手工工作,但是我们设置App类的方式意味着在我们尝试分派路由之前,不会加载任何提供者。

我们应该把这些步骤分开,这样测试和command.php就能够使用提供者为我们配置的所有依赖项,而不需要分派一个路由:

public function prepare(): static
{
    $basePath = $this->resolve('paths.base');

    $this->configure($basePath);
    $this->bindProviders($basePath);

    return $this;
}

public function run(): Response
{
    return $this->dispatch($this->resolve('paths.base'));
}

这是来自framework/App.php

现在,我们可以更改 migrate 命令以使用已经配置好的数据库连接:

protected function execute(InputInterface $input, OutputInterface $output)
{
    $current = getcwd();
    $pattern = 'database/migrations/*.php';

    $paths = glob("{$current}/{$pattern}");

    if (count($paths) < 1) {
        $this->writeln('No migrations found');
        return Command::SUCCESS;
    }

    // $connection = $this->connection();
    $connection = app('database');

    if ($input->getOption('fresh')) {
        $output->writeln('Dropping existing database tables');

        $connection->dropTables();

        // $connection = $this->connection();
        $connection = app('database');
    }

    // ...rest of the migrate code
}

// private function connection(): Connection
// {
//     $factory = new Factory();

//     $factory->addConnector('mysql', function($config) {
//         return new MysqlConnection($config);
//     });

//     $factory->addConnector('sqlite', function($config) {
//         return new SqliteConnection($config);
//     });

//     $config = require getcwd() . '/config/database.php';

//     return $factory->connect($config[$config['default']]);
// }

这是来自framework/Database/Command/MigrateCommand.php

现在我们需要一组可以使用的类,将作业放入这个表中,然后再取出来。因为我们希望“存储”闭包以供以后执行,所以我们需要一种方法来序列化它。闭包通常不允许被序列化,但是有一些低级的库可以做到这一点。让我们安装其中一个:

composer require opis/closure

序列化闭包有点神奇。没有什么可以阻止我们发送字符串类名和方法名,这样就可以在没有序列化的情况下调用方法。这样做可能会更简单,但我认为演示这个魔术会很有趣。

接下来,我们需要建立通常的提供者/管理者/驱动者系统,这样我们的框架将能够支持存储和检索这些闭包的多种方法:

namespace Framework\Provider;

use Framework\Queue\Factory;
use Framework\Queue\Driver\DatabaseDriver;
use Framework\Support\DriverProvider;
use Framework\Support\DriverFactory;

class QueueProvider extends DriverProvider
{
    protected function name(): string
    {
        return 'queue';
    }

    protected function factory(): DriverFactory
    {
        return new Factory();
    }

    protected function drivers(): array
    {
        return [
            'database' => function($config) {
                return new DatabaseDriver($config);
            },
        ];
    }
}

这是来自framework/Provider/QueueProvider.php

这个提供程序依赖于一个工厂和一个数据库驱动程序。该工厂与我们的许多其他工厂一样:

namespace Framework\Queue;

use Closure;
use Framework\Queue\Driver\Driver;
use Framework\Queue\Exception\DriverException;
use Framework\Support\DriverFactory;

class Factory implements DriverFactory
{
    protected array $drivers;

    public function addDriver(string $alias, Closure $driver): static
    {
        $this->drivers[$alias] = $driver;
        return $this;
    }

    public function connect(array $config): Driver
    {
        if (!isset($config['type'])) {
            throw new DriverException('type is not defined');
        }

        $type = $config['type'];

        if (isset($this->drivers[$type])) {
            return $this->drivers$type;
        }

        throw new DriverException('unrecognised type');
    }
}

这是来自framework/Queue/Factory.php

每个驱动程序都需要几个方法:

  1. 将单个任务推入队列的push方法

  2. 从队列中取出单个任务的方法

namespace Framework\Queue\Driver;

use Closure;
use Framework\Queue\Job;

interface Driver
{
    public function push(Closure $closure, ...$params): int;
    public function shift(): ?Job;
}

这是来自framework/Queue/Driver/Driver.php

数据库驱动程序是有趣的地方!我们可以从实现这个接口的 shell 开始:

namespace Framework\Queue\Driver;

use Closure;
use Framework\Queue\Job;

class DatabaseDriver implements Driver
{
    public function push(Closure $closure, ...$params): int
    {
        // TODO
    }

    public function shift(): ?Job
    {
        // TODO
    }
}

这是来自framework/Queue/Driver/DatabaseDriver.php

让我们也制作一个数据库模型,用于存储和检索闭包和参数:

namespace Framework\Queue;

use Framework\Database\Model;

class Job extends Model
{
    public function getTable(): string
    {
        return config('queue.database.table');
    }
}

这是来自framework/Queue/Driver/DatabaseQueue.php

哦!我们应该用这个变量填充队列配置文件:

return [
    'default' => 'database',
    'database' => [
        'type' => 'database',
        'table' => 'jobs',
        'attempts' => 3,
    ],
];

这是来自config/queue.php

这个表名应该与我们在迁移中创建的表名相匹配。如果您更改了它,那么不要忘记在这里也进行更改。

使用这个模型和我们之前安装的库,我们可以序列化闭包和参数,并将它们存储在数据库中:

use Opis\Closure\SerializableClosure;

// ...

public function push(Closure $closure, ...$params): int
{
    $wrapper = new SerializableClosure($closure);

    $job = new Job();
    $job->closure = serialize($wrapper);
    $job->params = serialize($params);
    $job->attempts = 0;
    $job->save();

    return $job->id;
}

这是来自framework/Queue/Driver/DatabaseQueue.php

通常,当您将一个闭包传递给serialize时,您会看到一个错误。Opis 闭包SerializableClosure充当闭包的包装器,并使用反射将它们转换成可以存储为文本的东西。

我们传递给作业的参数仅限于可序列化的类型,无需特殊处理。例如,我们不能将Model实例或未包装的闭包作为参数传递。

相应的shift方法应该从 jobs 表中提取一个作业,以便可以尝试:

public function shift(): ?Job
{
    $attempts = config('queue.database.attempts');

    return Job::where('attempts', '<', $attempts)
        ->where('is_complete', false)
        ->first();
}

这是来自framework/Queue/Driver/DatabaseQueue.php

在这一点上,我们还需要做一些事情来使它正常工作。首先,我们需要一个终端命令来“处理”排队的作业。第二个是向Job模型添加助手,使其更容易运行,但我们很快就会谈到这一点…

终端命令可能是这样的:

namespace Framework\Queue\Command;

use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Exception;

class WorkCommand extends Command
{
    protected static $defaultName = 'queue:work';

    protected function configure()
    {
        $this
            ->setDescription('Runs tasks that have been queued')
            ->setHelp('This command waits for and runs queued jobs');
    }

    protected function execute(InputInterface $input, OutputInterface $output)
    {
        $output->writeln('<info>Waiting for jobs.</info>');

        while(true) {
            if ($job = app('queue')->shift()) {
                try {
                    $job->run();

                    $output->writeln("<info>Completed {$job->id}</info>");

                    $job->is_complete = true;
                    $job->save();

                    sleep(1);
                }
                catch (Exception $e) {
                    $message = $e->getMessage();
                    $output->writeln("<error>{$message}</error>");

                    $job->attempts = $job->attempts + 1;
                    $job->save();
                }
            }
        }
    }
}

这是来自framework/Queue/Command/WorkCommand.php

这个终端命令意味着在等待新的作业添加到队列中时,它将持续运行。请注意,除了作为模型的工作形式之外,它对数据库一无所知。

或许,我们可以更进一步,制作一种不需要用数据库模型表示的Job对象。或者,我们可以探索“虚拟”模型的概念——拥有熟悉的模型方法但只存储在内存中的对象。类似于寿司

作为一种替代方法,我们可以使用存储库模式。这将给我们一个数据对象的内存表示,而不需要将它存储在数据库中。不过,这是一个与活动记录非常不同的设计。

注意我们如何期望在Job模型上有一个run方法:

public function run(): mixed
{
    $closure = unserialize($this->closure);
    $params = unserialize($this->params);

    return $closure(...$params);
}

这是来自framework/Queue/Job.php

一旦我们向app/comands.php添加了工作命令,那么我们应该能够添加和处理排队的作业了!

尝试对来自未知或潜在错误来源的数据进行反序列化时要小心。可以改变序列化的字符串,这样就可以注入和执行任意代码。换句话说,不要取消序列化用户提交的数据。

即使您信任序列化字符串的来源,它也可能因复杂性而被破坏。总是尝试序列化到一个明确定义的简单规范,或者从该规范序列化。类似于 JSON 的东西非常容易验证和解释。用 JSON 编码原始值时很难出错。

img/299823_2_En_11_Figb_HTML.jpg

运行排队作业

记录

任务排队带来的一个问题是很难知道什么时候失败或成功,因为所有这些都发生在请求/响应周期之外。如果您在自己的计算机上运行queue:work命令,这是没问题的,但是当它在远程服务器上运行时呢?

一个潜在的解决方案是引入一种在后台任务处理期间记录失败和成功的方法。

日志记录是另一个我们可以自己解决的问题,但是不值得我们从头开始实现它。在 PHP 中,已经有了关于日志库如何工作的奇妙的开放标准,比如 PSR-3

除此之外,还有一些实现了 PSR 3 的库,比如 Monolog (来自 Composer 的创作者)。

让我们在 Monolog 上构建我们的日志库:

composer require monolog/monolog

像往常一样,我们需要创建提供者+工厂+驱动程序+配置组合,从提供者开始:

namespace Framework\Provider;

use Framework\Logging\Factory;
use Framework\Logging\Driver\StreamDriver;
use Framework\Support\DriverProvider;
use Framework\Support\DriverFactory;

class LoggingProvider extends DriverProvider
{
    protected function name(): string
    {
        return 'logging';
    }

    protected function factory(): DriverFactory
    {
        return new Factory();
    }

    protected function drivers(): array
    {
        return [
            'stream' => function($config) {
                return new StreamDriver($config);
            },
        ];
    }
}

这是来自framework/Provider/LoggingProvider.php

我们从一个StreamDriver开始(它将日志文件写入文件系统)。我不会用工厂实现来烦你——因为它和我们做的其他产品几乎完全一样。

可以在framework/Logging/Factory.php找到伐木工厂。

让我们跳到驱动程序界面:

namespace Framework\Logging\Driver;

interface Driver
{
    public function info(string $message): static;
    public function warning(string $message): static;
    public function error(string $message): static;
}

这是来自framework/Logging/Driver/Driver.php

这只是 PSR-3 和独白支持的一个子集,但它们是最常见的日志消息类型。如果你需要,可以随意添加更多的方法…

最后,配置文件和StreamDriver:

return [
    'default' => 'stream',
    'stream' => [
        'type' => 'stream',
        'path' => __DIR__ . '/../storage/app.log',
        'name' => 'App',
        'minimum' => \Monolog\Logger::DEBUG,
    ],
];

这是来自config/logging.php

namespace Framework\Logging\Driver;

use Monolog\Logger;
use Monolog\Handler\StreamHandler;

class StreamDriver implements Driver
{
    private array $config;
    private Logger $logger;

    public function __construct(array $config)
    {
        $this->config = $config;
    }

    public function info(string $message): static
    {
        $this->logger()->info($message);
        return $this;
    }

    private function logger()
    {
        if (!isset($this->logger)) {
            $this->logger = new Logger($this->config['name']);
            $this->logger->pushHandler(new StreamHandler($this->config['path'], $this->config['minimum']));
        }

        return $this->logger;
    }

    public function warning(string $message): static
    {
        $this->logger()->warning($message);
        return $this;
    }

    public function error(string $message): static
    {
        $this->logger()->error($message);
        return $this;
    }
}

这是来自framework/Logging/Driver/LoggingDriver.php

StreamDriver是 Monolog 的 StreamHandler 类的装饰器。它处理独白设置,用我们在config/logging.php中定义的配置变量填充它。

别忘了给config/providers.php加上LoggingProvider类,否则下一位代码就不行了。

我们现在可以在应用的任何地方记录失败和成功:

app('queue')->push(
    fn($name) => app('logging')->info("Hello {$name}"),
    'Chris',
);

app('logging')->info('Send a task into the background');

我建议在后台任务中详尽地记录日志,因为这将有助于发现代码中的错误,并在后台处理问题开始影响客户之前突出它们。

电子邮件

我们要做的最后一个库是发送电子邮件的库。发送电子邮件有几种不同的实用方法:

  1. 使用系统服务发送电子邮件,该服务与 web 服务器并行运行

  2. 通过第三方 API 发送邮件

我从个人经验中知道,建立一个可靠的邮件服务器是多么困难。电子邮件验证和安全领域发生了如此多的事情,除非你是配置专家,否则你的电子邮件很可能会进入垃圾邮件文件夹。

相比之下,有大量价格合理、配置专业的第三方电子邮件 API 可供选择。这个过程通常是调用一个带有目的电子邮件地址和电子邮件内容的 API:

curl -X POST
    https://mandrillapp.com/api/1.0/messages/send
    -H "Accept: application/json"
    -H "Content-Type: application/json"
    -d '{
            "key":"[API KEY]"
            "message": {
                "html": "<h1>Welcome to our website</h1>...",
                "text": "Welcome to our website...",
                "subject": "Registration Complete",
                "to": ["customer@domain.com"]
            }
        }'

这是来自山魈文档的。

我们可以用 PHP 复制这个,但是 Mailchimp 已经创建了一个 PHP 库来加速这个过程。用 PHP 请求发送相同的邮件看起来是这样的:

$client = new MailchimpTransactional\ApiClient();
$client->setApiKey('[API KEY]');

$client->messages->send([
    'message' => [
        'html' => '<h1>Welcome to our website</h1>...',
        'text' => 'Welcome to our website...',
        'subject' => 'Registration Complete',
        'to' => ['customer@domain.com'],
    ]
]);

邮戳有一个类似的 API,您也可以调用它来发送电子邮件:

curl -X POST
    "https://api.postmarkapp.com/email"
    -H "Accept: application/json"
    -H "Content-Type: application/json"
    -H "X-Postmark-Server-Token: [API KEY]"
    -d '{
            "From": "sender@domain.com",
            "To": "customer@domain.com",
            "Subject": "Registration Complete",
            "TextBody": "Welcome to our website...",
            "HtmlBody": "<h1>Welcome to our website</h1>...",
       }'

这是来自邮戳的文件

不出所料,邮戳也有一个 PHP 库,我们可以用来发送类似的电子邮件:

$client = new Postmark\PostmarkClient('[API KEY]');

$client->sendEmail(
    '[SENDER SIGNATURE]',
    'customer@domain.com',
    'Registration Complete',
    '<h1>Welcome to our website</h1>...',
    'Welcome to our website...'
);

还有电子邮件发送抽象(类似于 Flysystem 对文件系统的抽象),比如 SwiftMailer 。让我们遵循我们之前的策略:在 SwiftMailer 之上构建,但是使用我们更喜欢的 API。首先,我们需要安装 SwiftMailer:

composer require swiftmailer/swiftmailer

原来邮戳也有一个快捷邮件插件:

composer require wildbit/swiftmailer-postmark

接下来,让我们制作提供商+工厂+驱动程序+配置组合:

namespace Framework\Provider;

use Framework\Email\Factory;
use Framework\Email\Driver\PostmarkDriver;
use Framework\Support\DriverProvider;
use Framework\Support\DriverFactory;

class EmailProvider extends DriverProvider
{
    protected function name(): string
    {
        return 'email';
    }

    protected function factory(): DriverFactory
    {
        return new Factory();
    }

    protected function drivers(): array
    {
        return [
            'postmark' => function($config) {
                return new PostmarkDriver($config);
            },
        ];
    }
}

这是来自framework/Provider/EmailProvider.php

看看工厂的样子。和我们以前做的一样。驱动程序包含了一系列可链接的方法,类似于我们对QueryBuilder所做的:

namespace Framework\Email\Driver;

interface Driver
{
    public function to(string $to): static;
    public function subject(string $subject): static;
    public function text(string $text): static;
    public function html(string $html): static;
    public function send(): void;
}

这是来自framework/Email/Driver/Driver.php

该接口的实现与我们实现的日志驱动程序的工作方式类似,但是有更多的验证:

namespace Framework\Email\Driver;

use Framework\Email\Exception\CompositionException;
use Postmark\Transport;
use Swift_Mailer;
use Swift_Message;

class PostmarkDriver implements Driver
{
    private array $config;
    private Swift_Mailer $mailer;
    private string $to;
    private string $subject;
    private string $text;
    private string $html;

    public function __construct(array $config)
    {
        $this->config = $config;
    }

    public function to(string $to): static
    {
        $this->to = $to;
        return $this;
    }

    public function subject(string $subject): static
    {
        $this->subject = $subject;
        return $this;
    }

    public function text(string $text): static
    {
        $this->text = $text;
        return $this;
    }

    public function html(string $html): static
    {
        $this->html = $html;
        return $this;
    }

    public function send(): void
    {
        if (!isset($this->to)) {
            throw new CompositionException('to required');
        }

        if (!isset($this->text) && !isset($this->html)) {
            throw new CompositionException('text or email required');
        }

        $fromName = $this->config['from']['name'];
        $fromEmail = $this->config['from']['email'];

        $subject = $this->subject ?? "Message from {$fromName}";

        $message = (new Swift_Message($subject))
            ->setFrom([$fromEmail => $fromName])
            ->setTo([$this->to]);

        if (isset($this->text) && !isset($this->html)) {
            $message->setBody($this->text, 'text/plain');
        }

        if (!isset($this->text) && isset($this->html)) {
            $message->setBody($this->html, 'text/html');
        }

        if (isset($this->text, $this->html)) {
            $message
                ->setBody($this->html, 'text/html')
                ->addPart($this->text, 'text/plain');
        }

        $this->mailer()->send($message);
    }

    private function mailer()
    {
        if (!isset($this->mailer)) {
            $transport = new Transport($this->config['token']);
            $this->mailer = new Swift_Mailer($transport);
        }

        return $this->mailer;
    }
}

这是来自framework/Email/Driver/PostmarkDriver.php

我们也许可以做更多的验证,以确保$config数组的格式是我们期望的格式,但是我将把它作为一个练习留给你。理想的配置文件应该是这样的:

return [
    'default' => 'postmark',
    'postmark' => [
        'type' => 'postmark',
        'token' => env('EMAIL_TOKEN'),
        'from' => [
            'name' => env('EMAIL_FROM_NAME'),
            'email' => env('EMAIL_FROM_EMAIL'),
        ],
    ]
];

这是来自config/email.php

这就是说,一旦我们将EmailProvider添加到config/providers.php中,我们应该能够轻松发送电子邮件:

app('queue')->push(
    fn($name) => app('email')
        ->to('cgpitt@gmail.com')
        ->text("Hello {$name}")
        ->send(),
    'Chris',
);

在这个例子中,我在一个队列任务中发送电子邮件。发送电子邮件通常是一个缓慢的过程,所以最好在 HTTP 请求/响应周期之外做这类事情。

警告

有很多事情我们可以花更多的时间去尝试。这里有一些你可能有兴趣尝试的:

  1. 我们只为每个库设置了一个驱动程序。想象一个使用 Redis 或亚马逊 SQS 存储消息的队列驱动程序或一个 Slack 日志驱动程序。

  2. 我们对许多配置文件结构做了假设。我们不能总是相信开发人员会遵循文档,所以我们应该帮助他们发现配置格式何时无效。

  3. 流行的框架喜欢将基于驱动的依赖关系转移到建议的依赖关系中,并记录了这种方法。通常,当你想在 Laravel 中发送电子邮件时,你还需要安装像wildbit/swiftmailer-postmark这样的特定于供应商的库。这是一个很好的模式,因为这意味着开发人员不会自动为他们不使用的驱动程序安装依赖项。

  4. 我们可以支持多个异常处理程序,而不是只支持一个,第一个返回响应的处理程序成为“赢家”

摘要

这是最后一章代码。我们已经学到了很多关于构建框架代码和构建一套可靠的库来使用的知识。

花些时间回顾一下你写的代码和你学到的东西。我将在下一章开始谈论我想对框架和应用做的一些最后的改变。

十二、发布您的代码

是时候说说我们的框架做出来之后会发生什么了。写代码只是开始。难的是让人们使用它,并保持它的新鲜和有用。

我认为,在我乐意将它投入使用之前,通过谈论我仍然希望对我们的框架和示例应用做些什么,将有助于构建这部分内容…

收尾

每个流行的框架都有一个初学者工具包。这些是您可以复制、克隆或安装的项目,以便了解代码是如何工作的。这些初学者工具包是新应用的起点。它们应该是有帮助的和容易理解的。

考虑到这一点,在向全世界发布之前,我想补充一些东西。

#1:有用的主页

让主页更有帮助。用户看到的第一页应该让他们放心,一切都设置正确,这样他们就可以开始开发了。应该有文档和其他有用资源的链接(比如托管和调试选项)。

img/299823_2_En_12_Figa_HTML.jpg

Laravel 的有用主页

这是你启动 Laravel 的开发服务器时看到的第一个东西。要看到这一点,您只需要三个命令:

composer create-project laravel/laravel my-new-project
cd my-new-project
php artisan serve

从一个新的框架开始可能是一项艰巨的任务,所以这有很大的不同。“让事情运转起来”不需要任何配置如果您想开始使用模型(或其他数据库功能),那么您需要设置一个数据库。

通过默认配置 SQLite,这可能会简单一点。那么新用户不需要配置任何东西就可以使用框架 80%的功能。

#2:更多功能示例

我们构建了如此多的功能,但在示例应用中展示的却很少。这里有一些使用会话和缓存的小例子。没别的了。

即使这些例子被注释掉了,显示做普通事情的适当时间和方式仍然是有用的。我们可以展示如何发送电子邮件或在队列中做一些事情,或者如何将文件放在远程文件系统中。

这就是教程和好的文档做了大量繁重工作的地方。说到这个…

#3:良好的文档

好的文档(或缺少文档)是其他开发人员对你的框架的第一个也是最大的抱怨。他们想知道如何使用这个框架,如何配置它,在哪里支持它,以及它的局限性是什么。

你的文档可以有很大的范围。Laravel 简洁明了,专注于功能的关键部分。Laravel 社区(以及一般的 PHP 社区)擅长以书面和视频教程的形式填补空白。

另一方面,Symfony 拥有大量的文档,有些人可能会发现这些文档在数量和具体性方面令人应接不暇。关于如何使用 Symfony 组件,我从未有过文档中没有回答的问题。

关键是要给出足够的信息,说明你期望组件如何被最常用,并有链接指向好的社区资源,以进行专门的使用、优化和扩展。

我使用的第一个 PHP 框架是 CodeIgniter。(在写作的时候)它仍然活蹦乱跳,对我的职业生涯影响很大。不是因为它有最好的代码或最大的社区,而是因为文档告诉了我学习如何使用这个框架所需要知道的一切。

img/299823_2_En_12_Figb_HTML.jpg

CodeIgniter 2 文档目录

最近的版本取消了“介绍”,但我想现在有更多的方法来了解这些主题。

#4:完成测试

开发人员的另一个大问题(也是理所当然的)是,当一个新的框架出现时,没有一个全面有效的测试套件。如果你想发布一个新的框架,确保你有 90–100%的功能被自动化测试覆盖。

你不需要测试第三方代码,但是你应该测试你连接和使用它的方式。

即使使用正确的工具,测试也可能是一门很难学的学科,但是这不是你想要的捷径。参加相关课程或阅读相关书籍:

#5:确保这是你想做的事情

构建一个框架很难,但这是旅程中最容易的部分。如果你做得好的话,多年来维护一个框架会占据你生活的大部分时间。

您最好从不同的框架中取出许多较小的部分,并将它们组合成对您的目的有帮助的东西。

或者,更好的是,自己学习如何使用一个流行的、受到良好支持的框架。这些我们会在后记里多讲。

使用 Packagist

在本书中,我们已经使用 Composer 安装了许多库。这些库都托管在一个名为 Packagist 的网站上。如果您希望您的框架(或单个库)可以通过 Composer 安装,您需要将它提交给 Packagist。

如果您还没有,请创建一个帐户。如果您使用 GitHub 托管您的代码,您也可以使用 GitHub 登录 Packagist。

img/299823_2_En_12_Figc_HTML.jpg

使用 GitHub 登录

您的库需要有一个定义良好的composer.json文件。我不想深入探讨这个问题的细节,因为它本身就是一门艺术。

如果你现在想知道如何发布你自己的包,那就去看看像 PHP 包开发这样的视频课程。它涵盖了关于composer.json规范的细节,以及如何很好地构建独立的 Composer 包。

完整的composer.json规范需要花相当多的时间来理解,您可以在 Composer 文档网站上找到它。

一旦你完成了一个,通过 Packagist 的提交表格提交。

img/299823_2_En_12_Figd_HTML.jpg

提交包

然后,你(或其他任何人)将能够通过一个composer require终端命令来安装你的库。这就是事情的全部。

摘要

如果你不记得这一章的其他内容,就让它这样吧:维护一个框架是一项艰苦的工作。构建一个框架是一个必经之路,也是了解它们如何工作的好方法,但是拥有一个框架是一个重大的决定。这不是一项容易的任务。

希望这一章已经让你看到了一些等待你的挑战和你应该考虑的事情。我会在这本书的后记中见到你。

posted @ 2024-08-03 11:23  绝不原创的飞龙  阅读(0)  评论(0编辑  收藏  举报