PHP8 MVC 高级教程(一)
一、PHP 的使用方法
让我们来谈谈 PHP 的几种不同的使用方法。您可能会遇到的主要问题有
-
在网络服务器中运行脚本,制作网站
-
从命令行/终端运行脚本
让我们来看看这些的一些变体。除了在旅途中编码之外,我不打算深入介绍设置。等你拿到这本书的时候,那样的说明可能已经过时了。
你最好搜索类似“我如何在[你的操作系统上安装 PHP]”…
在终端中运行脚本
你看过电影里的电脑黑客吗?他们通常弓着背坐在键盘和屏幕前,疯狂地打字。有时他们穿着连帽衫。
事实是,编程和使用终端是正常的事情。如果你用的是苹果公司生产的电脑或者运行的是 Linux 系统,你可能比用 Windows 系统的电脑更习惯看到终端窗口。
终端窗口,有时也称为命令提示符或控制台,只是与计算机内部的直接通信。你可以用它们来安装新程序或运行你自己编写的脚本。
您不必使用终端窗口来运行您的脚本。如果你更喜欢视觉界面,直接跳到前面。
安装和使用 PHP 的步骤因您使用的操作系统而异。如果你使用的是 macOS,你可以使用 Homebrew 来安装 PHP 和一个数据库。
如果你使用的是 Linux,你可以使用一个内置的包管理器来安装同样的东西。
在 Windows 上,你可以尝试使用 Linux 的 Windows 子系统,它将提供与你在 Linux 计算机上找到的相同的终端界面。或者你可以选择像巧克力公司这样的包装经理。
官方 PHP 文档提供了如何在最常见的操作系统上安装 PHP 的最新说明列表。
题外话,我用的终端 app 叫超级终端。我喜欢它,因为我可以使用 JavaScript 来配置它,并且我还可以使用与我在代码编辑器中使用的相同的主题。
超级终端应用,在 macOS 上运行
通过图形用户界面运行网站
有些人喜欢用更直观的方式来运营他们的网站。有很多好的选项可供选择,但我推荐一款名为 XAMPP 的应用。
你可以在 XAMPP 网站上找到通用操作系统的可下载安装程序,以及如何使用安装程序的说明。与在终端窗口运行 PHP 脚本不同,XAMPP 会给你一个地方来存放由网络服务器运行的 PHP 文件。区别是微妙而重要的。
当我们直接运行脚本时,它们会一直运行,直到完成。如果它们没有完成,通常是因为脚本有问题,比如无限循环。
一些框架和库引入了长期运行脚本或服务器脚本的概念。我说的不是这种情况下的那些。我说的是我们可能为了一个简单的目的想要执行的脚本,或者作为服务器维护的一部分想要频繁运行的脚本。
常见的例子有重命名一堆文件的脚本、删除旧日志文件的脚本,甚至是运行一些预定任务的脚本。
当我们使用 web 服务器时,web 服务器获取请求的细节(标题、参数等)。),它执行脚本。在一天结束的时候,同样的代码运行,但是 web 服务器从我们手里拿走了一些工作。在某种程度上,它也让我们不用再处理输出和错误日志了。
稍后,当我要求您运行一个脚本时,您可能需要在您的 web 服务器的上下文中进行解释。如果我告诉你运行一个脚本,这可能意味着把它放在一个 web 服务器可以服务的文件中。我会告诉你什么时候做什么…
通过虚拟计算机运行网站
假设你想在你自己的计算机上运行你的代码,但是你不希望它阻塞你的文件系统或者导致各种各样的新东西被安装。那样的话,你可以用 VirtualBox 之类的。
VirtualBox 是您安装的一个程序,它允许您创建在您的计算机上运行的“虚拟计算机”。您可以决定允许他们访问多少资源。当它们被挂起时,它们不使用任何资源,除了它们需要的硬盘空间来记住它们之前在做什么。
设置和使用它们的过程与设置一台新的物理计算机没有什么不同。您需要首选操作系统的安装文件,然后您需要在这些文件上安装 PHP(和其他工具),就像它们是一台物理计算机一样。
这比在你的实际电脑上安装要多一点工作,但通常也要干净得多。
你可以使用基本的 VirtualBox 应用,或者你可以更进一步,使用一个叫做vagger的软件提供的自动设置帮助。这是一个工具,让您使用脚本来设置和维护 VirtualBox(和其他)虚拟计算机。你甚至可以使用别人做的食谱,这样你就不用自己做任何繁重的工作了。
当我了解更多关于流浪者的信息时,我推荐这些资源:
-
游民食谱会解释游民做什么,怎么用。
-
Phansible 会询问你想要安装什么,然后为你创建漫游脚本。
显见的。com 流浪者提供工具
在远程服务器上运行网站
在某些时候,你会希望其他人看到并使用你制作的网站。有一些方法可以让他们看到在你本地电脑上运行的网站,但这并不是一个永久的解决方案。
相反,许多公司提供他们喜欢称之为“云托管”或“虚拟服务器托管”的服务。有一些大公司,像亚马逊网络服务公司和谷歌云公司。还有一些更小的名字,如数字海洋和 Vultr 。我更喜欢小公司,因为他们的管理控制台更容易理解。
一旦您拥有 DigitalOcean 的帐户,您就可以登录并创建虚拟服务器。它类似于 VirtualBox 服务器,因为它不是物理机器。你仍然可以在上面运行流行的操作系统,比如 Ubuntu Linux。
在数字海洋上创建虚拟服务器
事实上,我提到的所有公司都允许你建立某种形式的虚拟服务器,运行 Linux。从那时起,你只需要按照你原本在个人电脑的终端窗口中所做的指示去做。
如果你喜欢别人为你做繁重的工作——就像我一样——你可以使用像 Laravel Forge 这样的服务来安装运行 PHP 应用所需的一切。
在本书中,我们会经常提到拉勒维尔。虽然 Laravel Forge 面向支持 Laravel 应用,但它可以托管为与其他框架一起工作而构建的网站,甚至是用其他语言编写的网站。
我在我的 Forge 服务器上托管了许多 NodeJS 网站,因为我仍然可以从这些网站使用 Forge 中获得所有的安全性和自动化。
Forge 的创始人 Taylor Otwell 慷慨地提供了 Forge 第一年 35%的优惠券。在添加付款方式后(但在订购前),您可以使用添加到您的账单资料中的优惠券代码 lHz71w7Z 。
在 Laravel Forge 上供应虚拟服务器
“在云端”托管不是免费的。其中一些公司会给你慷慨的试用账户,但你迟早会开始为他们的服务付费。幸运的是,你不需要支付任何费用就可以开始 PHP MVC 开发,只要你在你的个人电脑上做…
在沙盒中编码
当你想测试一些代码,但是你不在一台熟悉的计算机旁边时,你可以在沙箱中编码。沙盒网站允许你运行 PHP 代码和共享链接,这样你就可以向别人展示一些东西。
有两个我推荐你试试:
-
Laravel Playground 是为测试 Laravel 代码量身定制的,但是你可以在其中执行任何 PHP 代码。您还可以在另一个站点上嵌入一个操场(带有自定义代码),这对于在 wiki 或文档站点中记录您的 PHP 代码非常有用。
-
3v4l 是了解相同的代码如何在不同版本的 PHP 中运行的最佳场所。有一只奇怪的虫子?把代码放在那里,并在 Twitter 上分享它的链接。
移动编码
作为最后一点乐趣,我想谈谈在 iPad 上编码。我接触的许多开发人员都有 iPad,但他们并不知道它可以成为一个强大的移动编码工具。
如果你想探索这个话题,我推荐你试试以下几个应用…
第一个应用叫做 DraftCode。这是一个 PHP 代码编辑器,允许执行本地 PHP 代码,就像你在 XAMPP 这样的 GUI 中运行代码一样。在撰写本文时,它的价格是 4.99 美元。
代码编辑器
这是我能找到的为数不多的几个甚至试图在没有互联网连接的情况下执行代码的应用之一,这意味着你可以在火车或飞机上使用它。它对 WordPress 应用有很好的支持,过去我甚至让它运行过 Laravel 应用。
不幸的是,似乎维护者已经决定提供受支持的 PHP 版本(7.2 和 7.3)作为额外的应用内购买。你可以使用基础应用运行 PHP 5.6 代码,但你必须额外支付 3.99 美元或 5.99 美元才能解锁新版本。
或者,你可以试试一个叫 winphp 的应用。我没有太多使用它的经验,但它似乎提供了与 DraftCode 相同的功能,甚至更多。你可以免费下载,但你也可以花 4.99 美元在应用内购买,解锁大量额外的功能(并隐藏广告)。
winphp 代码编辑器
这两款应用都支持外置键盘和鼠标/触控板,前提是你能让它们与你的 iPad 兼容。我发现即使只有一个键盘盖也对我的编码有很大的帮助。毕竟,没有人喜欢在屏幕上敲一大堆文字…
接下来,有一个叫做工作副本的应用。这是一个很容易与 GitHub 集成的 Git 客户端。这个想法是,你可以使用工作副本来克隆你正在做的回购,然后在一个可以执行代码的应用中编辑它。虽然您可以在工作副本中编辑文本文件,但没有内置的功能来本地执行这些文件。
Git 客户端工作副本
自从我第一次尝试在 iPad 上编码以来,iOS(尤其是 iPadOS)已经有了很大的进步。除了苹果已经开始制作的伟大的新键盘和触控板外壳,文件应用使处理项目文件变得更加容易。
我要提到的最后一个 app 叫做 Termius 。这是一个用于 iPad 的 SSH 客户端。有许多这样的应用,但 Termius 很有趣,因为它有可以在桌面上使用的配套应用,所以你可以在它们之间共享设置。
如果您在 iPad 上完成了本地开发,并希望将您的网站部署到远程虚拟服务器,您将需要一种与该服务器通信的方法。宋承宪是方法。当然,您需要访问互联网来完成这一部分,但是如果您习惯于在 iPad 上工作,那么从 iPad 上部署可能适合您。
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 来介绍基础知识。在阅读本书中的代码时,您只需要几个命令:
-
git clonegit@github.com:assertchris/pro-php-mvc.git -
cd pro-php-mvc -
git branch -a如果你熟悉 GitHub 和 Git,请随意跳到本章中我们处理请求的部分。
这些命令在终端窗口当前所在的文件夹中创建源代码存储库的本地副本。然后,他们导航到包含这些文件的文件夹,并列出存储库中可用的分支。
分支就像房子里的不同房间,每个房间都松散地建立在前一个房间的基础上。假设我有一个包含三个文件的应用。我想添加第四个文件,但是在我处理它的时候,我不希望这个文件在存储库的主分支(或者房间)中。
我可以偏离主分支,在新的分支中处理我的第四个文件。我可以将这个变化合并回主分支,或者它可以永远存在于它的新分支中。我可以再一次偏离,或者离开主树枝,或者我做的这个新树枝。
这是我用来存储每章源代码的模式。每个分支都有前面所有章节的代码,而且还有以该分支命名的章节的代码。如果你在第五章中,你可以切换到名为chapter-5的分支,你会在那一章结束时看到代码是什么样的。
章节的分支
要切换到您想要查看的分支,使用命令git switch chapter-5,其中chapter-5是您想要切换到的分支的名称。如果你喜欢更直观的界面, GitHub 也有一个简洁的应用你可以试试。
可视化检查来自 GitHub 库的代码
如果你找不到你要找的代码,请随时在 Twitter 上联系我。
处理请求
本章的代码可以在 GitHub 上找到。正如你可能已经收集到的,这本书关注的是 PHP 8,这意味着你需要有那个版本,否则你可能会看到弹出的错误消息。除了学习如何制作我们自己的框架,我们还学习了所有可以用 PHP 8 编写的新代码…
你可以克隆回购协议,看看我是如何写的东西,或者如果你有一个讨厌的错误,你只是不能过去。我建议你开始一个单独的“新”项目,在那里你写你在本书中看到的代码,作为帮助你学习和记忆的一种方式。
打开您的代码编辑器,为您的 Whoosh 网站版本创建一个新的项目/空间。我个人最喜欢的是 Visual Studio 代码,但是你可以使用任何你喜欢的编辑器。
我提到过我喜欢在我的终端和代码编辑器中使用相同的主题。超级终端和 VS 代码都允许大量的定制。我使用的主题来自 rainglow.io。在那里,你可以找到如何在代码编辑器和终端中安装主题的说明链接。
每个 PHP 网站都以一个文件开始。甚至最流行的框架也是这样工作的。在深处的某个地方,有一个 web 服务器正在向其发送请求的index.php文件。
创建我们的第一个框架文件
马上,我们需要确保 PHP 处于工作状态。如果你要通过终端运行你的代码,你可以从同一个文件夹中使用类似于php -S 0.0.0.0:8000的命令,然后你应该能够在你的网络浏览器中打开http://0.0.0.0:8000。
检查 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 错误页面的唯一方法是首先检查他们是否应该看到站点中的其他页面。
但是如果错误不是因为他们寻找的页面丢失了呢?如果是代码库出了问题怎么办?
还有另外三种常见的错误:
-
URL 是对的,但是请求方法是错的。
-
URL 和请求方法是正确的,但是代码中有一个错误。
-
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 语句中声明可能的路径,而是预先定义了路径。我们没有为从POST到HEAD的请求定义任何路由,但是我们可能确实希望允许这些请求方法。
我们还需要生成一个可能路径的列表,这样我们就可以判断某人是否使用了正确的路径(或 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_handler和set_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:它们何时以及如何响应请求。
一些我们可以建立的功能
我希望我们关注几个核心特性:
-
将请求方法和路径与特定路由匹配
-
处理我们目前的所有错误
-
允许在路线中使用命名参数
-
从命名的路由和参数构建 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,都必须服从这个超时。
这意味着我们可以将文件放在app和framework文件夹中,并让它们的名称空间反映它们的加载位置。让我们通过创建几个反映主要概念的类来开始我们的路由:
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,这样静态分析工具至少可以在检测到变量/属性类型的问题时警告我们。
当我们了解更多关于测试和工具的知识时,我们将在第九章中看看这些工具。
除了新的app和framework文件夹,我还想使用一个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 名称,比如getPath和setPath。
我对这两种方式都没有强烈的感觉,但重要的是选择一种方法并坚持到底。路由的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/assertchris 。assertchris是 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全局数组中找到的路径——与一组未知的路径相匹配。我们可以使用以下规则来判断我们是否在处理可能的匹配:
-
如果路由有一个简单的路径(如
home),并且我们有一个与请求路径匹配的字符串,那么路由就是一个匹配。 -
如果路由没有任何参数(我们在路由路径中检查
*或?的那一点),那么它不可能是匹配的。请记住,我们知道,如果它达到这一点,它不是一个字面上的匹配。 -
如果路由路径模式与请求路径匹配(在名称被替换为无名的正则表达式位之后),那么我们可以假设它是匹配的。
我们可以使用 PHP 8.0 中添加的
str_ends_with函数,因为它比其他任何查找最后一个字符的方法都简单。
让我们看几个例子:
-
我们可以用路径
products/{id}/view定义一条路线。 -
向
products/1/view发出请求。 -
因为
products/{id}/view不是products/1/view的字面匹配,所以我们不会提前退出match。 -
normalisePath把products/{id}/view变成/products/{id}/view/,preg_replace_callback再变成/products/([^/]+)/view/。 -
preg_match_all认为这是与/products/1/view/的匹配。 -
id参数被赋值为'1',match函数返回true。
对于可选参数…
-
我们可以定义一条路径为
blog/{slug?}的路线。 -
向
blog/hello-world发出请求。 -
normalisePath把blog/{slug?}变成/blog/{slug?}/,preg_replace_callback再变成/blog/([^/]*)(?:/?)。 -
preg_match_all认为这是与/blog/hello-world/的匹配。 -
slug参数被赋值为'hello-world',match函数返回true。 -
对
/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 + endif和foreach + 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。
这引出了两个我想让我们探讨的问题:
-
我们如何构建我们的代码,使其易于使用,同时便于以后测试和重构?
-
为什么要添加或者省略
.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
du是dump-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。
概括地说,这些是我们已经改变的事情:
-
为了呈现视图,我们存储在属性中的数据都不存在于
View类之外。 -
当“注册”模板的布局时,我们检查调用这个方法的文件,并将布局模板名称分配给一个布局数组。这有点不可思议,但是它允许我们继续调用
$this->layout,而不需要其他的魔法将布局值存储在View对象中。DEBUG_BACKTRACE_IGNORE_ARGS和1有助于将回溯限制到它所能包含的最少信息量。 -
当我们呈现一个视图时,我们检查在
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>
);
}
}
这种编译器需要几件大事才能工作:
-
与 Composer 的自动加载系统深度集成
-
一个将
<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,
]);
如果我们聪明地管理这些组件,我们甚至可以让它们以类似于库 Livewire 和 Blazor 的方式与 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 />
太狂野了!并且会花费太多时间来涵盖细节。相反,我建议您查阅官方文档,了解关于这些组件如何工作的更多细节。
摘要
这一章是模板解析的奇妙旅程。我真的很喜欢构建所有这些示例,并且我确信其中有一个您会喜欢的模板解析器。
在下一章中,我们将研究如何构建一个验证库,我们还将创建一个更好的结构来组织与每条路线相关的代码,并介绍一种在开发过程中显示错误消息的更好方法。