FuelPHP-应用开发蓝图-全-
FuelPHP 应用开发蓝图(全)
原文:
zh.annas-archive.org/md5/a5a0d01b0da1edbe07dce8c2e54b802f译者:飞龙
前言
FuelPHP 应用程序开发蓝图背后的主要思想是通过构建不同复杂程度的各种项目来教你 FuelPHP 的基本和高级功能。它非常注重结果;在章节开头,我们指定了想要构建的应用程序,然后通过学习如何使用新的 FuelPHP 功能逐步实现它。因此,我们的方法将非常实用;许多概念将通过代码示例或直接集成到我们的项目中来解释。因此,重要的是要强调,这里将会有大量的代码,你应该对阅读和理解 PHP 和 HTML 感到舒适。由于我们不时会使用它们,对服务器/系统管理以及 JavaScript、jQuery 和 CSS 的基础知识将是一个额外的优势。
虽然这本书是为中级到高级的 Web 开发者编写的,但不需要对 FuelPHP 框架或任何其他 PHP 框架有先前的了解。为了理解这本书,你不需要了解常见的概念,如 MVC、HMVC 或 ORM。我们考虑到你们中的一些人可能存在的这种不足,并将解释重要的概念。不过,我们不会在第一章中解释所有这些概念,因为我们希望尽可能减少痛苦;相反,当它们对于完成项目成为必要的时候,我们会去处理它们。
FuelPHP 应用程序开发蓝图的最终目的是让你能够使用 FuelPHP 构建任何项目。到这本书的结尾,你当然不会了解框架的每一个细节,但希望你会拥有实施复杂和可维护项目所需的必要工具箱。
本书涵盖的内容
第一章, 构建您的第一个 FuelPHP 应用程序,涵盖了 FuelPHP 框架的非常基础的内容;如何安装它,如何配置它,它的组织结构以及其主要组件。在这个过程中,我们将使用 oil 工具生成我们的第一个 FuelPHP 应用程序并调整一些文件,以展示事物是如何运作的。
第二章, 构建待办事项列表应用程序,专注于 FuelPHP 的 ORM 和调试功能。我们将通过大量示例来展示这些功能,然后实现一个小型待办事项列表应用程序。我们还将使用一些 JavaScript 和 jQuery 来发送 AJAX 请求。
第三章, 构建博客应用程序,将教你如何轻松生成和调整管理界面,如何创建自己的模块和任务,如何轻松管理分页,以及如何使用 Auth 和 Email 包。我们将创建一个实现所有这些功能的博客应用程序。
第四章, 创建和使用包,将探讨 FuelPHP 包系统。这是一个相当简短的章节;我们首先尝试通过安装现有包来保护我们的网站免受垃圾邮件机器人的侵害,然后通过创建新包来创建我们自己的原创解决方案。
第五章, 构建自己的 RESTful API,涵盖了更高级的主题,如构建 JSON API、使用语言无关的模板引擎、允许用户订阅和实现单元测试。为了说明这一点,我们将创建一个具有公共 API 的响应式微型博客应用程序。
第六章, 使用 Novius OS 构建网站,将快速介绍 Novius OS,这是一个基于 FuelPHP 的内容管理系统。使用这样的系统可以大大加快复杂项目的实施。
你需要这本书的内容
本书中的应用程序基于 FuelPHP 1.7.2,它需要:
-
服务器:最常见的选择是 Apache
-
PHP 解释器:5.3.3 版本或更高
-
数据库:我们将使用 MySQL
FuelPHP 可以在 Unix-like 和 Windows 操作系统上运行。建议使用 mod_rewrite Apache 模块和一些额外的 PHP 扩展;完整列表可在fuelphp.com/docs/requirements.html找到。
这本书的适用对象
这本书是为中级到资深网络开发者而写的,他们想学习如何使用 FuelPHP 框架,并使用它来构建复杂的项目。你应该熟悉 PHP、HTML、CSS 和 JavaScript,但不需要关于 MVC 框架的先验知识。
规范
在这本书中,你会找到许多文本样式,用于区分不同类型的信息。以下是一些这些样式的示例及其含义的解释。
- 文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 昵称如下所示:"删除
APPPATH/classes/controller/welcome.php,因为我们不再需要这个控制器了。"
代码块设置如下:
<?php
echo Form::checkbox(
'still_here',
1,
Input::post(
'still_here',
isset($monkey) ? $monkey->still_here : true
)
);
?>
新术语和重要单词以粗体显示。你在屏幕上看到的单词,例如在菜单或对话框中,在文本中如下所示:"点击生成按钮。"
注意
警告或重要注意事项以如下框中的形式出现。
小贴士
小贴士和技巧以如下形式出现。
读者反馈
我们欢迎读者的反馈。告诉我们你对这本书的看法——你喜欢什么或不喜欢什么。读者反馈对我们很重要,因为它帮助我们开发出你真正能从中获得最大收益的标题。
要向我们发送一般反馈,只需发送电子邮件至 <feedback@packtpub.com>,并在邮件主题中提及书的标题。
如果您在某个主题上具有专业知识,并且您有兴趣撰写或为书籍做出贡献,请参阅我们的作者指南www.packtpub.com/authors。
客户支持
现在您是 Packt 书籍的骄傲拥有者,我们有多个方面可以帮助您从您的购买中获得最大收益。
下载示例代码
您可以从您在www.packtpub.com的账户下载示例代码文件,适用于您购买的所有 Packt 出版社的书籍。如果您在其他地方购买了这本书,您可以访问www.packtpub.com/support并注册,以便将文件直接通过电子邮件发送给您。
下载本书中的彩色图像
我们还为您提供了一个包含本书中使用的截图/图表的彩色图像的 PDF 文件。彩色图像将帮助您更好地理解输出的变化。您可以从以下链接下载此文件:www.packtpub.com/sites/default/files/downloads/5401OS.pdf。
勘误
尽管我们已经尽一切努力确保我们内容的准确性,但错误仍然会发生。如果您在我们的某本书中找到一个错误——可能是文本或代码中的错误——如果您能向我们报告这一点,我们将不胜感激。通过这样做,您可以节省其他读者的挫败感,并帮助我们改进本书的后续版本。如果您发现任何勘误,请通过访问www.packtpub.com/submit-errata,选择您的书籍,点击勘误提交表单链接,并输入您的勘误详情来报告它们。一旦您的勘误得到验证,您的提交将被接受,勘误将被上传到我们的网站或添加到该标题的勘误部分下的现有勘误列表中。
要查看之前提交的勘误表,请访问www.packtpub.com/books/content/support,并在搜索字段中输入书籍名称。所需信息将出现在勘误部分下。
盗版
互联网上对版权材料的盗版是一个跨所有媒体的持续问题。在 Packt,我们非常重视我们版权和许可证的保护。如果您在互联网上发现任何形式的我们作品的非法副本,请立即提供位置地址或网站名称,以便我们可以寻求补救措施。
请通过链接mailto:copyright@packtpub.com与我们联系,并提供疑似盗版材料的链接。
我们感谢您在保护我们作者和我们为您提供有价值内容的能力方面的帮助。
询问
如果您对本书的任何方面有问题,您可以通过链接mailto:questions@packtpub.com与我们联系,我们将尽力解决问题。
第一章。构建您的第一个 FuelPHP 应用程序
在整本书中,我们将使用FuelPHP框架构建不同类型的项目。本章的目标是让您尽快熟悉框架的基本知识并创建您的第一个项目。在本章中,我们不会创建任何特别的东西,并且代码量将非常少,但我们将从安装 FuelPHP 到在生产服务器上发布您的项目的过程进行讲解。您将学习其他项目所需的基本知识。
到本章结束时,您应该了解以下内容:
-
FuelPHP 应用程序的常见开发流程
-
如何安装 FuelPHP(最新版本或特定版本)
-
FuelPHP 的文件系统层次结构
-
配置 Apache 以访问您的应用程序的两种不同方式
-
如何配置 FuelPHP 以连接到数据库
-
oil 命令行及其如何用于构建您的应用程序的脚手架
-
应用程序如何响应用户请求的 URL
-
什么是 FuelPHP 模板
-
如何将您的项目发布到主机
由于本书面向中级开发者,我们假设您已经在系统上安装了 Apache 和 MySQL。一些关于 Git 和 Composer 的先验知识是一个额外的优势,因为您可能需要它们,但即使您不熟悉这些工具,在这本书中您也应该能够顺利。然而,对于需要多个开发者协作的高级应用程序,熟练掌握它们是非常推荐的。
在本章中,我们将从安装 FuelPHP 框架开始,直到拥有一个功能性的——尽管有限的——网络应用程序。由于我们的目标是尽快介绍框架并创建一个示例应用程序,因此我们不会涉及诸如 ORM 等重要主题,这些将在第二章构建待办事项列表应用程序中介绍。
关于 FuelPHP
丹·霍里根在 2010 年底启动了 FuelPHP 框架,后来菲尔·斯特林、杰尔默·施吕德、哈罗·韦顿、弗兰克·德·容格、史蒂夫·韦斯特和马克·萨吉-卡扎尔加入了进来。第一个稳定版本于 2011 年 7 月 31 日发布,本书基于 FuelPHP 1.7.2,这是撰写本书时的最新稳定版本。拥有超过 300 位贡献者,其社区庞大且活跃。
核心团队目前正在开发 FuelPHP 的第二个版本;已经发布了几个 alpha 版本。
如果您想了解更多关于 FuelPHP 团队和框架哲学的信息,我建议您阅读官方网站上的关于 FuelPHP部分:
您可以在其官方博客上阅读有关框架的最新消息:
官方文档可以在以下网址找到:fuelphp.com/docs/
如果您对 FuelPHP 有任何疑问或遇到任何问题,您可以在官方论坛(fuelphp.com/forums/)上搜索并开始新的讨论,如果您找不到任何答案。一般来说,官方网站(fuelphp.com)是一个极好的资源。
FuelPHP 应用程序的开发过程
FuelPHP 应用程序的开发过程通常包含以下图像中显示的步骤:

-
安装 FuelPHP: 由于我们正在使用这个框架,这一步非常明显。
-
Config (配置): 在开始时,您通常需要指定如何连接到数据库以及您将使用哪个包。稍后,您可能还需要创建和使用自己的配置文件来提高您应用程序的可维护性。
-
Scaffold: FuelPHP 的命令行工具允许您轻松生成可用的代码文件。这一步不是必需的,但在这本书中,我们经常会使用这一功能,因为它确实能加快您应用程序的实现速度。
-
Dev (开发): 这就是您作为开发者介入的地方。您将生成的代码定制为得到您想要的确切结果。当您想要添加新功能(例如,一个新的模型)时,您将回到 scaffold 步骤。
-
测试: 如果您想要大型应用程序保持可维护性,功能测试和单元测试非常重要。当发现错误时,您将回到开发步骤以修复它们。与其他步骤不同,为了简洁起见,我们不会在本章中讨论这个主题。它将在第五章 构建您的 RESTful API 中讨论。
-
Prod (生产): 在本地运行一个项目是件好事,但最终目标通常是将其发布到线上。我们将在本章末尾为您提供一些关于这一步骤的指导,但由于可用的托管服务种类繁多,我们不会深入细节。
为了明确起见,这是一个非常通用的指南,当然,步骤的顺序并不是固定的。例如,使用测试驱动开发过程的开发者可以将第四步和第五步合并,或者添加一个预生产步骤。开发过程应仅取决于每个开发者和机构的标准。
安装环境
FuelPHP 框架需要以下三个组件:
-
Web 服务器: 最常见的解决方案是 Apache
-
PHP 解释器: 5.3.3 版本或更高
-
数据库: 我们将使用 MySQL
FuelPHP 在 Unix-like 和 Windows 操作系统上运行,但这些组件的安装和配置过程将取决于所使用的操作系统。在接下来的章节中,我们将提供一些指导,帮助你开始,如果你不习惯安装你的开发环境。请注意,这些是非常通用的指南,所以你可能需要在网上搜索补充信息。关于这个主题有无数的资源。
Windows
一个完整且非常流行的解决方案是安装 WAMP。这将安装 Apache、MySQL 和 PHP,换句话说,你需要的一切来开始。它可以在 www.wampserver.com/en/ 访问。
Mac
PHP 和 Apache 通常安装在操作系统的最新版本上,所以你只需要安装 MySQL。为此,建议你阅读官方文档 dev.mysql.com/doc/refman/5.1/en/macosx-installation.html。
对于系统管理技能最少的人来说,一个非常方便的解决方案是安装 MAMP,这是 WAMP 的等价物,但适用于 Mac 操作系统。可以从 www.mamp.info/en/downloads/ 下载。
Ubuntu
由于这是最受欢迎的 Linux 发行版,我们将我们的说明限制在 Ubuntu 上。
你可以通过执行以下命令行来安装一个完整的环境:
# Apache, MySQL, PHP
sudo apt-get install lamp-server^
# PHPMyAdmin allows you to handle the administration of MySQL DB
sudo apt-get install phpmyadmin
# Curl is useful for doing web requests
sudo apt-get install curl libcurl3 libcurl3-dev php5-curl
# Enabling the rewrite module as it is needed by FuelPHP
sudo a2enmod rewrite
# Restarting Apache to apply the new configuration
sudo service apache2 restart
推荐的模块和扩展
Apache 的 mod_rewrite 模块和一些额外的 PHP 扩展也被推荐,但不是必需的:
fuelphp.com/docs/requirements.html(可以通过访问 FuelPHP 网站并通过导航到 DOCS | TABLE OF CONTENTS | FuelPHP | Basic | Requirements)访问)
获取 FuelPHP 框架
在撰写本书时,有四种常见的下载 FuelPHP 的方法:
-
下载并解压在 FuelPHP 网站上可以找到的压缩包。
-
执行 FuelPHP 的快速命令行安装器。
-
使用 Composer 下载和安装 FuelPHP。
-
克隆 FuelPHP 的 GitHub 仓库,这稍微复杂一些,但允许你选择你想要安装的确切版本(甚至提交)。
这些方法在网站上的安装说明页面上有很好的文档记录 fuelphp.com/docs/installation/instructions.html(可以通过访问 FuelPHP 网站并通过导航到 DOCS | TABLE OF CONTENTS | FuelPHP | Installation | Instructions)访问)
安装 FuelPHP 1.7.2
FuelPHP 始终在发展,并且即使在这本书出版后也会继续发展。由于我们在本书中使用了 FuelPHP 1.7.2,您可能想要安装相同的版本以防止任何冲突。您可以通过下载合适的 ZIP 文件、克隆 GitHub 仓库的 1.7/master 分支或使用 Composer 来实现这一点。
下载合适的 ZIP 文件
这是最简单的方法。您应该可以通过请求以下 URL 来下载它:fuelphp.com/files/download/28。
或者,您可以在 FuelPHP 网站上通过导航到DOCS | 目录 | FuelPHP | 安装 | 下载来访问所有重要的 FuelPHP 版本压缩包:fuelphp.com/docs/installation/download.html(通过 FuelPHP 网站导航到DOCS | 目录 | FuelPHP | 安装 | 下载可以访问)
使用 Composer
首先,如果您还没有这样做,您需要安装Composer。您可以通过阅读官方网站了解如何操作:getcomposer.org/。
主要操作系统的安装说明在入门指南中给出。请注意,您可以将 Composer 全局或本地安装。
从现在开始,我们将假设您已经全局安装了 Composer。如果 Composer 被安装到您的工作目录中,只要将composer替换为php composer.phar,我们的说明将同样适用。
为了具体安装 FuelPHP 1.7,您可以简单地执行以下命令行(将TARGET替换为您想要下载 FuelPHP 的目录):
composer create-project fuel/fuel:dev-1.7/master TARGET
更新 FuelPHP
如果您通过克隆 GitHub 仓库下载了 FuelPHP,或者您只是想更新 FuelPHP 及其依赖项,您必须在安装 FuelPHP 实例的位置输入以下命令行:
php composer.phar update
如您所见,Composer 已在本地的 FuelPHP 根目录中安装。
安装目录和 Apache 配置
现在您已经知道了如何在给定目录中安装 FuelPHP,我们将向您介绍两种主要的方法,您可以在环境中集成此框架。
最简单的方法
假设您已经激活了 Apache 模块的mod_rewrite,最简单的方法是在您的 Web 服务器的根目录中安装 FuelPHP(通常在 Linux 系统上的/var/www目录)。如果您在根目录的DIR目录(/var/www/DIR)中安装 FuelPHP,您将能够通过以下 URL 访问您的项目:
http://localhost/DIR/public/
然而,请注意,FuelPHP 尚未实现支持此功能,如果您以这种方式在生产服务器上发布项目,将会引入您必须处理的安全问题。在这种情况下,建议您使用我们将在下一节中解释的第二种方法,尽管,例如,如果您计划使用共享主机来发布项目,您可能没有选择。关于此问题的完整和最新文档可以在 FuelPHP 安装说明页面fuelphp.com/docs/installation/instructions.html(可以通过导航到 FuelPHP 网站上的DOCS | 目录 | FuelPHP | 安装 | 说明)找到。
通过设置虚拟主机
另一种方法是创建一个虚拟主机来访问您的应用程序。您需要一些 Apache 和系统管理的技能,但好处是它更安全,您将能够选择您的工作目录。您需要更改两个文件:
-
您的 Apache 虚拟主机文件,以便将虚拟主机链接到您的应用程序
-
您的系统主机文件,以便将所需的 URL 重定向到您的虚拟主机
在这两种情况下,文件的存储位置将取决于您使用的操作系统和服务器环境;因此,您将不得不自己找出它们的位置(如果您使用的是常见的配置,您不会在您首选的搜索引擎上找到任何问题)。
在以下示例中,我们将设置您的系统,以便在本地环境中请求my.app URL 时调用您的应用程序(推荐使用*nix 系统)。
让我们先编辑虚拟主机文件。在末尾添加以下代码:
<VirtualHost *:80>
ServerName my.app
DocumentRoot YOUR_APP_PATH/public
SetEnv FUEL_ENV "development"
<Directory YOUR_APP_PATH/public>
DirectoryIndex index.php
AllowOverride All
Order allow,deny
Allow from all
</Directory>
</VirtualHost>
然后,打开您的系统主机文件,并在末尾添加以下行:
127.0.0.1 my.app
根据您的环境,您可能需要在之后重启 Apache。现在您可以通过http://my.app/访问您的网站。

恭喜!您已成功安装 FuelPHP 框架。欢迎页面显示了一些推荐的继续项目的方向。
FuelPHP 基础知识
现在我们已经安装了一个 FuelPHP 的工作版本,让我们从非常基础的水平分析一下框架是如何工作的。我们不会在这里深入细节;目的是只了解使用框架所需的信息。在本节中,建议您跟随并检查我们关于您已安装实例的解释;不要犹豫去探索文件和文件夹,这将使我们在开始项目的实现时感到更加舒适。在本节中,我们将探讨以下内容:
-
FuelPHP 的文件系统层次结构
-
MVC、HMVC 及其在 FuelPHP 中的工作原理
-
Oil 工具
FuelPHP 的文件系统层次结构
让我们深入了解我们已安装 FuelPHP 的目录。你可能想使用文件浏览器来跟随。由于本书正在编写中,FuelPHP 的当前版本具有以下目录层次结构:
-
/``docs: 包含框架文档的 HTML 版本 -
/``fuel,其中包含:-
/``fuel/app: 与你的应用程序相关的所有内容。你将大部分时间在这里工作。我们将在即将到来的应用程序目录部分中查看此目录。 -
/``fuel/core: 核心类和配置。你不应该更改其中的任何内容,除非当然你想为 FuelPHP 核心做出贡献。 -
/``fuel/packages: 包是核心扩展,它们是包含可重用类和配置文件的包。使用 FuelPHP 默认配置,这是你可以安装包的唯一目录(包括你自己的以及来自外部来源的)。注意,已经有五个已安装的包。我们将在本书中使用它们中的每一个。 -
/``vendor: 此目录包含通常不是 FuelPHP 特定的第三方包和库。
-
-
/``public: 此目录可供外部访客访问。你希望在这里放置公开可用的文件,例如 CSS 或 JS 文件。
应用程序目录
如前所述,应用程序目录是你将大部分时间工作的地方。因此,你应该熟悉其层次结构,如下所示:
-
/``cache: 此目录用于存储提高应用程序性能的缓存文件。 -
/``classes: 供你的应用程序使用的类:-
/``classes/controller: 你必须实现你的控制器的地方(参见MVC, HMVC 以及如何在 FuelPHP 上工作部分) -
/``classes/model: 你必须实现你的模型的地方(参见MVC, HMVC 以及如何在 FuelPHP 上工作部分) -
/``classes/presenter: 你必须实现你的演示者(参见MVC, HMVC 以及如何在 FuelPHP 上工作部分)。
-
-
/``config: 每个配置文件。由于一些文件很重要,我们也将它们列出:-
/``config/config.php: 定义重要的 FuelPHP 配置项,例如激活的包或安全设置。 -
/``config/db.php: 定义数据库连接信息。 -
/``config/routes.php: 定义应用程序的路由(我们将在本章后面讨论它们)。 -
/``config/development,config/production,config/staging,config/test:config/ENV目录中的所有配置文件(ENV是当前环境)与config文件夹中的文件合并。例如,如果 FuelPHP 环境设置为development(默认情况下是这样),则config/development/db.php文件将递归地与config/db.php文件合并。具体来说,这意味着在config/ENV/db.php文件中定义的配置项将覆盖config/db.php文件中的配置项。我们将在油工具和油控制台部分通过一个示例来说明这一点。
-
-
/``lang: 包含翻译文件。 -
/
logs:包含日志文件。日志文件路径取决于其写入的日期。例如,如果您在 2015 年 7 月 1 日记录一条消息,它将被保存在logs/2015/07/01.php中的文件中。 -
/
migrations:包含迁移文件,这些文件允许您以结构化的方式轻松更改您的数据库。例如,如果有很多人在同一个项目上工作,或者有多个相同项目的实例(开发/生产),它们使数据库更改变得更容易。我们将在本书中经常使用它们。 -
/
modules:包含您的应用程序的模块。每个模块都可以描述为一组可以响应请求并易于在其他项目中重用的代码。我们将在 第三章,Building a Blog Application 中为博客项目创建一个模块。 -
/
tasks:包含任务文件,这些是可以通过命令行执行(例如,用于 cron 作业)的类。 -
/
tests:包含测试文件,这些文件可以用来自动测试您的应用程序。我们将在 第五章,Building Your Own RESTful API 中处理它们,以测试我们的应用程序。 -
/
tmp:包含临时文件。 -
/
vendor:此目录仅包含由您的应用程序使用的第三方库和包。 -
/
views:包含您的应用程序使用的视图文件(请参阅 MVC, HMVC, and how it works on FuelPHP 部分)。
包含文件
fuel/packages 目录包含五个默认包,当激活时,可以为 FuelPHP 添加有趣的功能:
-
auth包提供了一个用于用户身份验证的标准接口。我们将在 第五章,Building Your Own RESTful API 中使用此包。 -
email包提供了一个使用不同驱动程序发送电子邮件的接口。我们将在 第三章,Building a Blog Application 中使用此包。 -
oil包允许您通过生成代码文件、启动测试和任务或提供 CLI PHP 控制台来加速您应用程序的实现。我们将在这个包的所有章节中使用它,并在 The oil utility and the oil console 部分中探索其功能。 -
orm:此包是 FuelPHP 核心模型的改进;它允许它们执行复杂查询并定义它们之间的关系。我们将在 第二章,Building a To-do List Application 中使用此包。 -
parser:此包允许您的应用程序在常见的模板系统(如 Twig 或 Smarty)中渲染视图文件。我们将在 第五章,Building Your Own RESTful API 中使用此包。
我们还将在 第四章,Creating and Using Packages 中创建我们自己的包。
类名、路径和编码标准
在 FuelPHP 中,有五个常量定义了最重要的目录的位置,如下所示:
-
APPPATH: 应用目录(
fuel/app) -
COREPATH: 核心目录(
fuel/core) -
PKGPATH: 包含目录(
fuel/packages) -
DOCROOT: 公共目录(
public) -
VENDORPATH: 供应商目录(
fuel/vendor)
建议您阅读有关这些常量的官方文档,网址为 fuelphp.com/docs/general/constants.html(可以通过导航到 FuelPHP 网站上的DOCS | 目录 | FuelPHP | 通用 | 常量来访问)。
请记住,在本书中,我们经常会使用这些常量来缩短文件路径。
一个有趣的观点是,FuelPHP 允许您非常容易地更改文件夹结构:例如,您可以在 public/index.php 文件中更改我们刚刚介绍的常量的值,或者您可以通过更改 APPPATH/config/config.php 配置文件中的 module_paths 键来更改 FuelPHP 将加载模块的目录。
您可能也注意到了,类名与它们自己的路径相关,如下所示:
-
在应用目录中,
classes/controller/welcome.php类被命名为Controller_Welcome -
classes/model/welcome.php类被命名为Model_Welcome -
您可以注意到在
fuel/core目录中类的命名方式相同
这个结果并非偶然得到;FuelPHP 默认遵循 PSR-0 标准。建议您阅读有关此标准的官方文档,网址为 www.php-fig.org/psr/psr-0/。
MVC、HMVC 以及它们在 FuelPHP 中的工作原理
现在我们将探讨 FuelPHP 框架的一个主要方面——MVC 和 HMVC 软件架构模式。
什么是 MVC?
模型-视图-控制器(MVC)是一种软件架构模式,它指出代码应该分为三个类别:模型、视图和控制器。
对于不熟悉的人来说,我们可以通过以下示例来说明:

假设一个用户试图访问您的网站。以下是他/她可能请求的一些 URL:
http://my.app/
http://my.app/welcome/
http://my.app/welcome/hello
根据请求的 URL,您的网站通常需要返回一些 HTML 代码,有时还需要更新数据库,例如当您想要保存用户的评论时。
返回的 HTML 代码是由视图生成的,因为这是浏览器接收到的,并且用户间接看到的内容。
数据库通常通过模型进行更新。具体来说,我们不是通过执行原始 SQL 代码来访问和更新数据库,而是最佳实践是使用类和实例来这样做。每个类代表一个与特定表相关的模型:例如,car模型会访问cars表。每个类的实例是一个与表中特定行链接的模型实例:例如,你的汽车信息可以保存为一个car实例,该实例将与cars表中的特定行相关联。由于我们使用类而不是原始 SQL 代码,框架已经实现了频繁需要的功能,例如读取、创建、保存或删除模型实例。另一个优点是,由于我们使用了打包和良好实现的访问数据库的方法,它可以防止我们在使用原始 SQL 请求数据库时可能创建的大多数意外安全漏洞。
控制器允许网站通过选择正确的视图来处理用户的请求并返回(响应),并在必要时通过模型更新数据库。控制器处理网站的特定部分:例如,car控制器将处理与汽车相关的一切。控制器通过动作细分,动作将处理特定的功能:例如,car控制器的list动作将返回一个汽车的列表。在实践中,控制器是类,动作是方法。
当用户请求一个 URL 时,框架将选择控制器中的一个动作来处理它。这些通常是通过约定选择的;例如,当请求http://my.app/welcome/hello时,框架将选择welcome控制器中的hello动作。有时,它们也可以通过一个路由配置文件来选择,该文件将 URL 与动作和控制器匹配。
视图有时需要访问模型;例如,当我们想要显示汽车列表时,我们需要访问car模型的实例。然而,视图不应更新模型或数据库;只有控制器和最好是模型应该这样做。
请注意,可以添加额外的代码组件作为辅助工具或演示者以简化开发过程,但如果你理解了这一部分,你就掌握了最重要的要点。
在 FuelPHP 中的工作原理
让我们通过测试我们新创建的网站来说明它是如何工作的。我们假设你的应用程序在以下 URL 上可用:
http://my.app/
动作和控制器
如果你请求一个随机的 URL,你可能会得到一个 404 异常。例如:
http://my.app/should_display_404
但是,如果你请求以下 URL,你将显示与主页相同的页面:
http://my.app/welcome/index
如果你请求以下 URL,你将显示不同的页面:
http://my.app/welcome/hello
让我们先解释一下最后两个请求是如何工作的。你可以注意到,这两个 URL 在基本 URL 之后都包含 welcome 这个词。你还可以在文件名 fuel/app/classes/controller/welcome.php 中找到这个词;结果是 welcome 是一个控制器。现在,使用你喜欢的文本编辑器打开这个文件。然后你会读到以下内容:
//...
class Controller_Welcome extends Controller
{
//...
public function action_index()
{
//...
}
//...
public function action_hello()
{
//...
}
//...
}
你可以注意到 action_index 和 action_hello 方法。这些函数被称为动作。现在,正如你可能已经猜到的,当你请求 http://my.app/welcome/index 时,将调用 action_index 方法。更一般地说,如果你请求 http://my.app/CONTROLLER/ACTION,将调用 CONTROLLER 控制器的 action_ACTION 方法。让我们来测试一下。编辑 action_index 函数,在开始处添加一个简单的 echo 语句:
public function action_index()
{
echo 'Test 1 - Please never print anything inside an action';
//...
}
现在,如果你请求 http://my.app/welcome/index,你将阅读网页开头的打印内容。尽管这是一种测试事物如何工作的简单方法,但永远不要在你的动作或控制器中打印任何内容。当你打印一条消息时,你已经在实现视图实体;因此,在控制器中打印内容会破坏 MVC 模式。
视图
但然后页面是如何渲染的呢?让我们分析 index 动作中的唯一一行代码:
public function action_index()
{
return Response::forge(View::forge('welcome/index'));
}
View::forge('welcome/index') 返回一个由 fuel/app/views/welcome/index.php 视图文件生成的 View 对象。我们将在本章和本书中多次使用此函数,并将涵盖所有其参数,但你可以在 FuelPHP 网站上阅读其官方文档:fuelphp.com/docs/classes/view.html。
fuelphp.com/docs/classes/view.html#/method_forge。(可以通过在 FuelPHP 网站上导航到DOCS | TABLE OF CONTENTS | Core | View来访问)。
Response::forge(View::forge('welcome/index')); 返回一个由 View 对象创建的响应对象。额外的参数允许我们更改头部或页面状态。响应对象包含将被发送到浏览器所需的所有信息:头部和主体(通常是 HTML 代码)。建议你阅读 FuelPHP 网站上的官方文档fuelphp.com/docs/classes/response.html#method_forge(可以通过在 FuelPHP 网站上导航到DOCS | TABLE OF CONTENTS | Core | Response来访问)。
由于视图是从 fuel/app/views/welcome/index.php 文件生成的,打开它以查看其内容。你可以注意到这与请求 URL 时显示的 HTML 代码相同。在 <h1>Welcome!</h1> 之后,添加 <p>This is my first view change.</p>。现在,如果你刷新浏览器,你将看到这条消息出现在 Welcome! 标题下。
参数
可以向动作和视图指示参数。例如,将你的 index 动作替换为以下代码:
public function action_index($name = 'user', $id = 0)
{
return Response::forge(
View::forge(
'welcome/index',
array(
'name' => $name,
'id' => $id,
)
)
);
}
并且在fuel/app/views/welcome/index.php视图文件中,替换
<h1>Welcome!</h1>
通过
<h1>Welcome <?php echo ($name.' (id: '.$id.')'); ?>!</h1>
现在,如果你请求以下 URL:
http://my.app/welcome/index
标题将显示 欢迎用户(id: 0)!
如果你请求以下 URL:
http://my.app/welcome/index/Jane
标题将显示 欢迎简妮(id: 0)!
如果你请求以下 URL:
http://my.app/welcome/index/Jane/34
标题将显示 欢迎简妮(id: 34)!
你可能已经理解了,如果你请求以下 URL:
http://my.app/CONTROLLER/ACTION/PARAM_1/PARAM_2/PARAM3
CONTROLLER的action_ACTION方法将使用PARAM_1、PARAM_2和PARAM_3参数被调用。如果 URL 中定义的参数少于方法中所需的参数,要么,如果已定义,参数将采用其默认值(如前所述),要么,如果没有定义默认值,它将触发 404 错误。
你可以注意到我们替换了
View::forge('welcome/index')
通过
View::forge('welcome/index', array(
'name' => $name,
'id' => $id,
)
)
视图参数是通过\View::forge的第二个参数以关联数组的形式发送的。在这里,关联数组有两个键,name和id,它们的值可以通过视图文件中的$name和$id变量访问。
以更通用的方式,如果你调用以下:
View::forge('YOUR_VIEW', array(
'param_1' => 1,
'param_2' => 2,
)
)
当视图文件被执行时,参数将通过$param_1和$param_2变量可用。
路由
尽管我们之前观察到的解释了标准情况是如何操作的
http://my.app/CONTROLLER/ACTION
尽管我们没有解释为什么以下两个 URL 返回内容,尽管找不到相关的控制器和动作:
http://my.app/
http://my.app/should_display_404
为了理解为什么我们必须打开fuel/app/config/routes.php配置文件:
<?php
return array(
'_root_' => 'welcome/index', // The default route
'_404_' => 'welcome/404', // The main 404 route
'hello(/:name)?' => array('welcome/hello', 'name' => 'hello'),
);
你首先可以注意到以下两个特殊键:
-
_root_:这定义了在请求网站根 URL 时应调用哪个控制器和动作。请注意,值是welcome/index,你现在可以理解为什么http://my.app和http://my.app/welcome/index返回相同的内容。 -
_404_:这定义了在抛出 404 错误时应调用哪个控制器和动作。
除了特殊键之外,你可以定义你想要处理的自定义 URL。让我们在数组的末尾添加一个简单的示例:
'my/welcome/page' => 'welcome/index',
现在,如果你请求以下 URL:
http://my.app/my/welcome/page
它将显示与以下 URL 相同的内容:
http://my.app/welcome/index
你可能已经注意到还有一个已经定义好的键:hello(/:name)?。路由系统相当先进,要完全理解它,建议查看官方文档:
fuelphp.com/docs/general/routing.html(可以通过通过 FuelPHP 网站导航到DOCS | 目录 | FuelPHP | 路由来访问)
演示者
你可能已经注意到,hello动作没有使用View类来显示其内容,而是使用了Presenter类:
public function action_hello()
{
return Response::forge(Presenter::forge('welcome/hello'));
}
让我们分析一下这种情况中发生了什么。首先,你可以注意到,对于视图来说,存在一个视图文件,其路径为fuel/app/views/welcome/hello.php。如果你打开这个文件,你会看到代码与请求 URL http://my.app/welcome/hello时显示的代码相同,除了一个微小的差异。你可以找到以下代码:
<h1>Hello, <?php echo $name; ?>!
在一个普通视图中,我们通常需要定义name参数,但在这里我们没有定义。尽管如此,当显示网页时,这个参数似乎有一个已定义的值(它显示Hello, World!)。那么它在哪里定义的呢?
再深入一点,你可以在fuel/app/classes/presenter/welcome/hello.php位置找到另一个文件。它包含以下内容:
class Presenter_Welcome_Hello extends Presenter
{
//...
public function view()
{
$this->name = $this->request()->param('name', 'World');
}
}
此文件包含一个Presenter类。在渲染视图之前会调用view函数,这就是设置name参数的地方。它试图从请求参数name中获取名称,但如果它未定义,则默认值为World。
如果你想知道如何更改此参数,请参考路由。例如,请求 URL http://my.app/hello/Jane。
那么有人可能会想知道Presenter类的作用,因为我们本可以将之前的代码改为更经典的视图和控制器方法。
让我们通过一个示例来展示它的实用性。假设你创建了一个内部网站,用于管理你公司的客户。每个客户都与一个客户类别相关联。因此,在你的创建、编辑和其他表单中,你显示一个客户类别选择列表。每次你显示完全相同的可选择的列表,尽管你通过不同的控制器和动作访问它。你可以想出三种解决方案:
-
你可以为你的选择列表创建一个经典视图,在每一个动作中加载客户类别列表,并将此列表传递给每个视图,直到你到达想要显示列表的位置。问题是这会导致大量代码重复。
-
你可以在视图中创建一个经典视图,并在其中加载客户列表。这样,你就不需要传递必要的参数。问题是这会打破 MVC 模式,因为模型和视图被混合在一起。
-
你可以创建一个
Presenter类,在Presenter类中加载列表,在视图文件中使用它,并使用Presenter::forge显示视图文件。这种解决方案是最好的,因为它不混合视图和模型,但仍然限制了代码重复。
什么是 HMVC?
FuelPHP 是一个分层模型-视图-控制器(HMVC)框架,这意味着它允许你从你的应用程序中请求内部控制器。具体来说,以下代码:
echo Request::forge('welcome/index')->execute();
将打印出以下 URL 返回的确切内容:
http://my.app/welcome/index
虽然我们建议你适度使用这个功能,但在你需要实现和显示多个网页上的小部件时,它可能很有用。
如果你想要了解更多关于这个模式的信息,建议你阅读以下资源:
en.wikipedia.org/wiki/Hierarchical_model-view-controller
stackoverflow.com/questions/2263416/what-is-the-hmvc-pattern
oil 工具和 oil 控制台
oil 工具是一个非常实用的命令行工具。作为 Ruby on Rails 的 rails 工具,oil 允许你做以下事情:
-
轻松生成代码文件:模型、控制器、迁移和整个脚手架
-
运行任务和迁移
-
轻松安装、更新或删除包
-
使用 PHPUnit 测试或实时控制台测试你的代码
-
甚至运行一个 PHP 内置的 web 服务器来托管你的 FuelPHP 应用程序(对于 PHP >= 5.4)
虽然我们将在本书中使用所有这些功能(除了最后一个),但我们建议你查看以下官方文档:
fuelphp.com/docs/packages/oil/intro.html(可以通过导航到 FuelPHP 网站的 DOCS | 目录 | Oil | 简介 来访问)
在本节中,我们将使用 oil 控制台,这是如果你想测试你的网站,或者在这个例子中测试 FuelPHP 功能时,一个重要的工具。
首先,打开你的命令行工具,转到你的网站目录的根目录。然后,输入以下行:
php oil console
小贴士
如果你使用像 WAMP 或 MAMP 这样的网络开发平台,建议你使用平台目录内的 PHP 可执行文件来启动 oil 工具(否则可能不起作用)。在我写这本书的时候,这个可执行文件位于 WAMP 的 WAMP_DIRECTORY\bin\php\phpVERSION\php.exe,MAMP 的 MAMP_DIRECTORY/bin/php/phpVERSION/bin/php(VERSION 取决于你安装的 PHP 版本,最好是使用文件浏览器自行检查这个目录)。
这将打开 oil 提供的命令行界面。当你按下 Enter 时,应该会看到类似以下的内容:
Fuel 1.7.2 - PHP 5.4.24 (cli) (Jan 19 2014 21:18:21) [Darwin]
>>>
你现在可以输入任何 PHP 代码,它将被执行。让我们从一些简单的东西开始:
>>> $a = 2
如果你按下 Enter,将不会打印任何内容,但 $a 变量将被设置为 2。现在,如果你想检查一个变量的值,只需输入其名称,然后按下 Enter:
>>> $a
2
它也适用于更复杂的变量:
>>> $a = array('a' => 'b', 'c' => 'd')
>>> $a
array (
'a' => 'b',
'c' => 'd',
)
但请注意,你可能会有困难显示复杂对象。
现在我们来测试一个 FuelPHP 功能。早些时候,在讨论 app 目录结构时,我们解释了 fuel/app/config 目录中的配置文件与 fuel/app/config/ENV 目录中具有相同文件名的文件合并,ENV 是 FuelPHP 的当前环境。我们现在将测试这种行为。
首先,让我们检查 FuelPHP 的当前环境:
>>> Fuel::$env
development
环境应设置为 development。
现在,创建一个位于 fuel/app/config/test.php 的 PHP 文件,你将在其中编写以下内容:
<?php
return array(
'this_is_the_root_config_file' => true,
);
然后在 fuel/app/config/development/test.php 创建另一个 PHP 文件,并编写以下内容:
<?php
return array(
'this_is_the_dev_config_file' => true,
);
并在 fuel/app/config/production/test.php 添加一个额外的文件,你将在其中编写以下内容:
<?php
return array(
'this_is_the_prod_config_file' => true,
);
现在,如果你回到命令行界面,你可以通过编写以下内容来加载 test 配置文件:
>>> $conf = Config::load('test', true)
建议你阅读 Config::load 的官方文档以获取更多信息:
fuelphp.com/docs/classes/config.html#/method_load。(可以通过导航到 FuelPHP 网站的 DOCS | TABLE OF CONTENTS | Core | Config 来访问)
如前所述,返回的值将是 fuel/app/config/test.php 和 fuel/app/config/development/test.php 配置文件的混合:
>>> $conf
array (
'this_is_the_root_config_file' => true,
'this_is_the_dev_config_file' => true,
)
如果我们将 FuelPHP 环境更改为 production:
Fuel::$env = 'production'; // only do that for testing purposes
再次加载 test 配置文件:
>>> Config::load('test', true, true)
array (
'this_is_the_root_config_file' => true,
'this_is_the_prod_config_file' => true,
)
合并将会使用 production 文件夹中的配置文件。
注意
你可能已经注意到,我们为 Config::load 添加了第三个参数。此参数允许你清除配置缓存。如果我们没有将其设置为 true,该方法将返回我们在 development 环境中加载的旧配置。
但是,当 fuel/app/config/production/test.php 和 fuel/app/config/test.php 配置文件包含相同的键时会发生什么?控制台可以为我们找到答案。
将 fuel/app/config/test.php 配置文件的内容更改为以下内容:
<?php
return array(
'complex_value' => array(
'root' => true,
),
'this_is_the_root_config_file' => true,
);
并将 fuel/app/config/production/test.php 配置文件的内容更改为以下内容:
<?php
return array(
'complex_value' => array(
'prod' => true,
),
'this_is_the_root_config_file' => false,
'this_is_the_prod_config_file' => true,
);
让我们现在按照以下方式重新加载 test 配置文件:
>>> Config::load('test', true, true)
array (
'complex_value' =>
array (
'root' => true,
'prod' => true,
),
'this_is_the_root_config_file' => false,
'this_is_the_prod_config_file' => true,
)
分析前面两个配置文件是如何合并的很有趣:
-
两个配置文件共有的
this_is_the_root_config_file键在两种情况下都与一个简单值相关联。在最终配置中,它是由生产文件中的值决定的。 -
在这两种情况下,
complex_value键都与一个数组相关联。这两个数组似乎在最终配置中被合并了。
这是因为配置文件不是通过 array_merge 原生 PHP 函数合并的,而是通过 Arr::merge FuelPHP 函数递归合并数组。建议你查看其官方文档,网址为 fuelphp.com/docs/classes/arr.html#/method_merge(可以通过导航到 FuelPHP 网站的 DOCS | TABLE OF CONTENTS | Core | Arr 来访问)
现在应该很清楚,控制台是一个伟大的工具,它允许您测试您的应用程序。它也可以用作文档的绝佳补充,因为您可以在不更改应用程序中的任何文件的情况下尝试 FuelPHP 方法和它们的参数。
构建您的第一个应用程序
现在我们对 FuelPHP 框架有了快速的了解,让我们构建我们的第一个小型应用程序。
假设您是动物园管理员,您想跟踪您照顾的猴子。对于每只猴子,您想保存以下信息:
-
它的名字
-
如果它还在动物园里
-
它的高度
-
一个描述输入,您可以输入自定义信息
您想要一个非常简单的界面,具有以下五个主要功能:
-
您想创建一个新的猴子
-
您想编辑现有的文件
-
您想列出所有猴子
-
您想查看每个猴子的详细文件
-
您想从系统中删除猴子
前面的五个主要功能,在计算机应用程序中非常常见,是 创建、读取、更新和删除 (CRUD) 基本操作的一部分。这是一个使用 oil 工具生成脚手架的完美示例。Oil 将快速为我们生成控制器、模型、视图和迁移来处理我们的猴子。然后,我们只需要对生成的代码进行精炼,并适应我们的需求。
数据库配置
由于我们将我们的猴子存储到 MySQL 数据库中,现在是时候配置 FuelPHP 以使用我们的本地数据库了。如果您打开 fuel/app/config/db.php,您将看到的是一个空数组,但正如我们在 FuelPHP 基础知识 部分所展示的,此配置文件会合并到 fuel/app/config/ENV/db.php,其中 ENV 是当前 FuelPHP 的环境,在这种情况下是 development。
因此,您应该打开 fuel/app/config/development/db.php:
<?php
//...
return array(
'default' => array(
'connection' => array(
'dsn' => 'mysql:host=localhost;dbname=fuel_dev',
'username' => 'root',
'password' => 'root',
),
),
);
这是生成的默认配置,您应该将其适应到您本地的配置中,特别是数据库名称(目前设置为 fuel_dev)、用户名和密码。您必须手动创建您项目的数据库。
脚手架
现在数据库配置已设置,我们将能够生成脚手架。我们将使用 oil 工具的 generate 功能。
打开命令行工具并转到您的网站根目录。要为新的模型生成脚手架,您需要输入以下行:
php oil generate scaffold/crud MODEL ATTR_1:TYPE_1 ATTR_2:TYPE_2 ...
其中:
-
MODEL是模型名称 -
ATTR_1,ATTR_2… 是模型的属性名称 -
TYPE_1,TYPE_2… 是属性类型
在我们的情况下,它应该是这样的:
php oil generate scaffold/crud monkey name:string still_here:bool height:float description:text
在这里,我们告诉 oil 为具有以下属性的 monkey 模型生成脚手架:
-
name: 猴子的名字。其类型是字符串,相关的 MySQL 列类型将是 VARCHAR(255)。 -
still_here: 猴子是否仍然在设施中。其类型是布尔型,相关的 MySQL 列类型将是 TINYINT(1)。 -
height: 猴子的高度。其类型是浮点数,相关的 MySQL 列类型将是 FLOAT。 -
description:猴子的描述。其类型为文本,相关的 MySQL 列类型将是 TEXT。
你可以使用 oil generate 功能做更多的事情,例如生成模型、控制器、迁移、任务、包等。我们将在本书的后面部分看到一些这些内容,但建议你查看官方文档fuelphp.com/docs/packages/oil/generate.html(可以通过通过导航到 FuelPHP 网站的 DOCS | 目录 | Oil | 生成 来访问)(它可以通过 FuelPHP 网站访问)
当你按下 Enter 键时,你会看到以下行出现:
Creating migration: APPPATH/migrations/001_create_monkeys.php
Creating model: APPPATH/classes/model/monkey.php
Creating controller: APPPATH/classes/controller/monkey.php
Creating view: APPPATH/views/monkey/index.php
Creating view: APPPATH/views/monkey/view.php
Creating view: APPPATH/views/monkey/create.php
Creating view: APPPATH/views/monkey/edit.php
Creating view: APPPATH/views/monkey/_form.php
Creating view: APPPATH/views/template.php
Oil 为我们生成了九个文件,具体如下:
-
包含创建模型相关表所需所有信息的迁移文件
-
模型
-
一个控制器
-
五个视图文件和一个模板文件
我们将在下一节中更详细地查看这些文件。
备注
你可能已经注意到我们使用了 scaffold/crud 命令,如果你阅读了官方文档,我们只需输入 scaffold。这是因为可以生成两种类型的脚手架:scaffold/crud,它使用简单的模型,以及 scaffold/orm 别名 scaffold,它使用 orm 模型。由于使用 FuelPHP 的原生 ORM 不在本章的范围内,而且我们不需要使用复杂模型功能,如关系,所以我们选择了使用 scaffold/crud。
迁移
生成的文件之一是 APPPATH/migrations/001_create_monkeys.php。这是一个迁移文件,包含创建我们猴子表所需的所有信息。请注意,文件名结构为 VER_NAME, 其中 VER 是版本号,NAME 是迁移的名称。
如果你执行以下命令行:
php oil refine migrate
所有尚未执行的迁移文件将从最老版本到最新版本(001、002、003 等)执行。一旦所有迁移文件都执行完毕,oil 将显示最新版本号。
执行后,如果你查看你的数据库,你会观察到创建了不止一个表:
-
monkeys:正如预期的那样,已经创建了一个表来处理你的猴子。请注意,表名是我们用于生成脚手的单词的复数形式;这种转换是通过使用Inflector::pluralize方法内部完成的。该表将包含指定的列(name、still_here),id列,以及created_at和updated_at。这些列存储对象创建和更新的时间,并且在每次生成模型时默认添加。可以通过使用--no-timestamp参数来不生成它们。 -
迁移:这个表是在你第一次执行迁移时自动创建的。它跟踪已执行的迁移。如果你查看其内容,你会看到它已经包含了一行;这就是你刚刚执行的迁移。你可以注意到,这一行不仅指明了迁移的名称,还指明了一个类型和一个名称。这是因为迁移文件可以放置在许多地方,如模块或包(参见第三章,构建博客应用程序)。
注意
需要注意的是,迁移表并不是 FuelPHP 跟踪已执行迁移的唯一位置。这些信息也存储在fuel/app/config/ENV/migrations.php中,其中ENV是 FuelPHP 的环境。如果你决定编辑迁移表,你可能还想编辑或删除此文件,因为它可能会阻止你的迁移执行。
oil 的refine migrate功能让你对迁移有更多的控制,而不仅仅是执行所有新的迁移。例如,你可以使用以下命令行回退到之前的版本:
php oil refine migrate:down
或者使用以下命令行回退到指定版本:
php oil refine migrate --version=3
或者甚至可以使用--modules或--package参数来选择你想要更新的模块或包。为了有一个完整的概述,建议你查看官方文档fuelphp.com/docs/general/migrations.html(可以通过导航到 FuelPHP 网站上的DOCS | 目录 | FuelPHP | 迁移来访问)。
但迁移文件是如何允许这样的复杂操作的?让我们打开位于APPPATH/migrations/001_create_monkeys.php的迁移文件来了解。你应该会看到以下内容:
<?php
namespace Fuel\Migrations;
class Create_monkeys
{
public function up()
{
\DBUtil::create_table('monkeys', array(
'id' => array(
'constraint' => 11,
'type' => 'int',
'auto_increment' => true,
'unsigned' => true
),
'name' => array(
'constraint' => 255,
'type' => 'varchar'
),
'still_here' => array(
'type' => 'bool'
),
'height' => array(
'type' => 'float'
),
'description' => array(
'type' => 'text'
),
'created_at' => array(
'constraint' => 11,
'type' => 'int',
'null' => true
),
'updated_at' => array(
'constraint' => 11,
'type' => 'int',
'null' => true
),
), array('id'));
}
public function down()
{
\DBUtil::drop_table('monkeys');
}
}
文件包含一个名为Create_monkeys的类,它有以下两个方法:
-
up:此方法定义了如何更新你的数据结构。请注意,此迁移文件使用DBUtil::create_table方法创建猴子表,但你完全可以执行一个手动的 SQL 请求来完成这个任务。尽管迁移通常用于更新你的数据库,但你也可以使用它们来更新自定义数据文件或旧配置文件。提示
在某些情况下,如果你想实现自己的迁移,你可能会觉得使用应用程序的方法(在模型或助手中)的想法很有吸引力。尽管它可以让你限制代码重复,但并不推荐这样做。这是因为出于兼容性的原因,迁移文件打算无限期地留在你的应用程序中,而你的应用程序代码可能会发生很大的变化。因此,通过更改或删除应用程序中的方法,你可能会意外地破坏一些迁移文件(使用此方法),而你甚至可能没有注意到,这使得未来安装你的应用程序变得复杂。
-
down:此方法定义了如何取消由up方法所做的所有更改。假设你意识到这个功能是一个错误,并且你想恢复到旧版本:这就是这个方法将被执行的时候。在我们的例子中,这个方法简单地删除了猴子表。小贴士
如果表中的信息很重要,那么将表格移动到归档数据库中可能是个好主意。否则,人为错误可能会产生灾难性的后果。
迁移文件是一个强大的工具,随着实例数量和在同一项目上工作的开发者数量的增加,其有用性会增长十倍。从头开始使用它们始终是一个好决定。
使用您的应用程序
现在我们已经生成了代码并迁移了数据库,我们的应用程序准备就绪,可以使用了。你可能已经注意到在生成过程中,在APPPATH/classes/controller/monkey.php创建了一个控制器,并且路由配置文件没有更改,这意味着控制器必须可以通过默认 URL 访问。
然后,让我们请求 URL http://my.app/monkey。
正如你所注意到的,这个网页旨在显示所有猴子的列表,但由于还没有添加任何猴子,列表是空的:

然后,通过点击添加新猴子按钮来添加一个新的猴子。以下网页应该会出现:

你可以在这里输入你的猴子信息。然而,有几个不一致的地方:
-
所有字段都是必填的,这意味着你不能留下任何字段为空,否则会触发错误,阻止你添加猴子。这并不是我们可能想要的描述字段的情况。
-
尽管你可以在高度字段中输入任何你想要的内容而不会触发任何错误,但如果输入的不是浮点数,它将被替换为 0。在这种情况下,我们可能希望触发一个错误。
-
仍在只能有两个值:0 或 1(假或真)。尽管相关数据库列的类型是正确的,但生成的表单使用了一个标准输入,我们可能希望有一个复选框。
表单当然不是完美的,但这是一个很好的开始。我们只需要稍微改进一下代码。
一旦添加了几个猴子,你还可以再次查看如下所示的列表页面:

再次强调,这是一个很好的开始,尽管我们可能还想稍微改进一下:对于仍在列,分别显示是和否而不是1和0,并删除描述列,因为可能显示的文本太多。
列表中的每个条目都有三个相关操作:查看、编辑和删除。
让我们首先点击查看:

再次强调,这是一个很好的开始,尽管我们还将改进这个网页。
你可以通过点击返回回到列表,或者通过点击编辑来编辑猴子。无论是从列表页面还是查看页面访问,它将显示与创建新猴子时相同的表单,当然,表单将被预先填充。
最后,如果你点击删除,将弹出一个确认框以防止任何误点击:

精炼应用程序
现在我们已经查看了一下我们的界面,让我们改进我们的应用程序,使其更加用户友好。在本节中,我们将探索 oil 生成的文件,并尝试使它们适应我们的需求。
精炼猴子列表
在上一节中,两个小问题困扰了我们关于猴子的列表:
-
我们希望
Still here列的值比 0 和 1 更明确 -
我们希望删除
描述列
我们知道列表在请求以下 URL 时出现:
http://my.app/monkey
你可能已经注意到,在这个 URL 中我们指明了一个控制器,但没有动作。重要的是要知道,默认情况下,并且没有涉及任何路由配置,这个 URL 等同于http://my.app/monkey/index
因此,实际上我们正在调用monkey控制器的index动作。如果我们打开在APPPATH/classes/controller/monkey.php生成的控制器,我们将读到以下内容:
<?php
class Controller_Monkey extends Controller_Template{
//...
}
首先,你可以注意到Controller_Monkey扩展了Controller_Template而不是Controller,正如我们在Controller_Welcome中之前看到的。Controller_Template是Controller的扩展,它增加了模板支持。想法是,大多数时候你的网页将具有相同的布局:头部、底部和菜单通常保持不变,无论你在哪个网页。模板允许你通过限制代码重复来实现这一点。
默认情况下,Controller_Template与由 oil 生成的APPPATH/views/template.php模板相关联。如果你打开这个文件,你会看到它生成围绕页面内容的 HTML 代码。你也会可能注意到它打印了$title和$content变量。我们将通过探索index动作来找出如何设置它们的值。如果你回到Monkey控制器,action_index方法应该包含以下内容:
public function action_index()
{
$data['monkeys'] = Model_Monkey::find_all();
$this->template->title = "Monkeys";
$this->template->content = View::forge('monkey/index', $data);
}
第一行将所有猴子的实例存储到$data['monkeys']变量中。一般来说,MODEL::find_all()返回一个模型的所有实例,但这绝对不是检索实例的唯一方法。这些方法将在第二章构建待办事项列表应用程序中更详细地讨论。
第二行和第三行设置了在模板文件中显示的$title和$content变量。如果你将第二行改为$this->template->title = "My monkeys";然后刷新网页,你会看到标题相应地改变。
第三行将$content变量设置为视图实例,从我们在前几节中观察到的,它执行位于APPPATH/views/monkey/index.php的视图文件,并将$monkey变量设置为所有猴子的实例。让我们打开这个视图文件。您应该看到以下内容:
<h2>Listing Monkeys</h2>
<br>
<?php if ($monkeys): ?>
<table class="table table-striped">
<thead>
<tr>
<th>Name</th>
<th>Still here</th>
<th>Height</th>
<th>Description</th>
<th></th>
</tr>
</thead>
<tbody>
<?php foreach ($monkeys as $item): ?> <tr>
<td><?php echo $item->name; ?></td>
<td><?php echo $item->still_here; ?></td>
<td><?php echo $item->height; ?></td>
<td><?php echo $item->description; ?></td>
<td>
<?php /* Action buttons */ ?>
</td>
</tr>
<?php endforeach; ?> </tbody>
</table>
<?php else: ?>
<p>No Monkeys.</p>
<?php endif; ?><p>
<?php /* Add new Monkey button */ ?>
</p>
我们已经找到了表格显示的位置,因此现在是时候进行我们的更改了。
首先,通过删除以下内容来删除描述**列:
<th>Description</th>
和
<td><?php echo $item->description; ?></td>
然后,让我们通过替换以下内容来细化如何显示仍在**列属性:
<td><?php echo $item->still_here; ?></td>
通过
<td><?php echo $item->still_here ? 'Yes' : 'No'; ?></td>
仍在列现在应显示是和否,分别代替1和0。
精炼猴子详细视图
在列表中,当点击一个项目的查看链接时,会出现猴子的详细视图。我们希望在这里更改两个细节:
-
如前所述,为仍在属性显示更明确的值
-
目前,如果您保存一个多行描述的猴子,它只会在一行上显示
首先,如果您在一个详细视图页面上,您会注意到 URL 类似于http://my.app/monkey/view/1
这意味着我们正在调用monkey控制器的view动作,其中第一个和唯一的参数设置为1。view动作与index动作非常相似,如以下代码片段所示:
public function action_view($id = null)
{
is_null($id) and Response::redirect('monkey');
$data['monkey'] = Model_Monkey::find_by_pk($id);
$this->template->title = "Monkey";
$this->template->content = View::forge('monkey/view', $data);
}
第一行简单地检查动作的参数(与$id变量相关联)是否实际设置,如果没有设置,则使用Response::redirect方法将用户重定向到列表页面。
第二行将 ID 为$id的猴子存储到$data['monkey']变量中。模型的一个实例通过其主键找到其实例的find_by_pk(pk代表主键)方法。正如我们之前解释的,模型的方法将在第二章中更详细地讨论,构建待办事项列表应用程序。
备注
为了完全清楚,请求 URL http://my.app/monkey/view/ID将加载id = ID的猴子实例。
第三行和第四行,与前一节一样,设置了模板变量。模板内容设置为位于APPPATH/views/monkey/view.php的视图。
<h2>Viewing #<?php echo $monkey->id; ?></h2>
<p>
<strong>Name:</strong>
<?php echo $monkey->name; ?></p>
<p>
<strong>Still here:</strong>
<?php echo $monkey->still_here; ?></p>
<p>
<strong>Height:</strong>
<?php echo $monkey->height; ?></p>
<p>
<strong>Description:</strong>
<?php echo $monkey->description; ?></p>
<?php /* Edit button */ ?> |
<?php /* Back button */ ?>
是时候做一些更改了。
替换:
<?php echo $monkey->still_here; ?>
通过:
<?php echo $monkey->still_here ? 'Yes' : 'No'; ?>
并替换:
<?php echo $monkey->description; ?>
通过:
<div><?php echo nl2br($monkey->description); ?></div>
允许空描述
我们之前指出的一个问题之一是,描述字段是必需的,尽管我们希望能够输入空值。
首先,打开您的浏览器并请求以下 URL:
http://my.app/monkey
点击添加新猴子按钮,您会看到您被重定向到http://my.app/monkey/create
如果您查看页面源代码,您会发现表单的action属性实际上是相同的 URL:
<form class="form-horizontal" action="http://my.app/monkey/create" accept-charset="utf-8" method="post">
这意味着无论我们是打开猴子的创建表单还是提交它,我们都会始终调用monkey控制器的create动作。因此,我们应该阅读这个动作是如何实现的:
public function action_create()
{
if (Input::method() == 'POST')
{
$val = Model_Monkey::validate('create');
if ($val->run())
{
// Saves the model (out of this chapter scope)
}
else
{
Session::set_flash('error', $val->error());
}
}
$this->template->title = "Monkeys";
$this->template->content = View::forge('monkey/create');
}
如您所注意到的,动作能够通过使用 Input::method() 来判断是否是通过 POST 请求访问的。建议您查看 Input 类的官方文档fuelphp.com/docs/classes/input.html(可以通过导航到 FuelPHP 网站的 DOCS | TABLE OF CONTENTS | Core | Input)来访问)
Model_Monkey::validate('create') 返回一个对象,该对象似乎定义了对象是否可以被保存(取决于 $val->run() 返回的结果)。这是一个来自 Monkey 模型的方法,因此我们应该深入研究。打开 APPPATH/classes/model/monkey.php:
<?php
class Model_Monkey extends Model_Crud
{
protected static $_table_name = 'monkeys';
public static function validate($factory)
{
$val = Validation::forge($factory);
$val->add_field('name', 'Name', 'required|max_length[255]');
$val->add_field('still_here', 'Still Here', 'required');
$val->add_field('height', 'Height', 'required');
$val->add_field('description', 'Description', 'required');
return $val;
}
}
该文件包含扩展 Model_Crud 的 Model_Monkey 类,使我们能够处理猴子实例。
首先,您可以注意到定义对象保存的表名的 $_table_name 静态属性(在这里,我们所有的猴子都保存在 monkeys 表中)。
然后是我们要找的 validate 静态方法。它返回一个 Validation 对象,在我们的情况下,将检查以下内容:
-
name属性不为空且长度小于 255 个字符 -
still_here、height和description都不为空
关于这个类的更多详细信息,建议您阅读官方文档fuelphp.com/docs/classes/validation/validation.html(可以通过导航到 FuelPHP 网站的 DOCS | TABLE OF CONTENTS | Core | Validation | Introduction)来访问)
在我们的情况下,只需注释或删除以下行:
$val->add_field('description', 'Description', 'required');
注意
您可能在 Controller_Monkey 控制器中多次读取 Session::set_flash,在模板中多次读取 Session::get_flash。会话闪存变量具有非常有限的生命周期,通常用于存储临时信息,例如显示给用户的提示或错误。
检查高度是否为浮点数
现在很容易检查高度是否为浮点数。因为我们知道猴子通常不会超过 4 英尺高,我们甚至可以添加一个数值约束。在 Model_Monkey 的 validate 方法中,替换以下行:
$val->add_field('height', 'Height', 'required');
by
$val->add_field(
'height',
'Height',
'required|numeric_between[0,6]'
);
使用复选框而不是输入框来设置 still_here 属性
这个更改将更加复杂。首先,仍然在 Model_Monkey 的 validate 方法中,删除以下行,因为我们不需要这个验证:
$val->add_field('still_here', 'Still Here', 'required');
现在,如果您回到 Controller_Monkey 中的 create 动作(位于 APPPATH/classes/controller/monkey.php),您将看到模板内容被设置为位于 APPPATH/views/monkey/create.php 的视图。如果您查看文件内容,它相当简单:
<h2>New Monkey</h2>
<br>
<?php echo render('monkey/_form'); ?>
<p><?php echo Html::anchor('monkey', 'Back'); ?></p>
供你参考,render 方法是 View::render 的别名,在这种情况下等同于 View::forge。这表明在视图内部渲染视图是可能的。这可以方便地防止代码重复;位于 APPPATH/views/monkey/edit.php 的视图也渲染了相同的视图(monkey/_form),这是有意义的,因为显示的表单完全相同,无论是创建一个新的猴子还是编辑现有的一个。
由于我们想要将 still_here 输入替换为复选框,请打开位于 APPPATH/views/monkey/_form.php 的视图,并替换以下行:
<?php
echo Form::input(
'still_here',
Input::post(
'still_here',
isset($monkey) ? $monkey->still_here : ''
),
array(
'class' => 'col-md-4 form-control',
'placeholder' => 'Still here'
)
);
?>
通过
<?php
echo Form::checkbox(
'still_here',
1,
Input::post(
'still_here',
isset($monkey) ? $monkey->still_here : true
)
);
?>
注意
在上面的代码中,第一个参数是复选框的名称属性。第二个参数是复选框的值属性。第三个参数确定复选框是否被勾选。你可以注意到,当我们创建一个新的猴子(因此没有猴子被设置)时,复选框将默认勾选。建议你阅读官方文档以获取有关 Form 类的更多信息,请访问 fuelphp.com/docs/classes/form.html(可以通过导航到 FuelPHP 网站的 DOCS | 目录 | 核心 | 表单 来访问)(It can be accessed through the FuelPHP website by navigating to DOCS | TABLE OF CONTENTS | Core | Form)
最后,你可能知道,当提交表单时,如果复选框未被勾选,still_here POST 属性将不会被定义。因此,在检索 still_here POST 属性时,我们不仅需要在 create 动作中定义默认值,还需要在 edit 动作中定义。在这两种方法中,替换以下内容:
Input::post('still_here')
通过
Input::post('still_here', 0)
小贴士
我们的方法是有效的,但在大多数情况下,硬编码默认值不是一个好主意。在指示请求参数或配置项的默认值时,最佳做法是在集中式配置文件中定义此值,并从那里加载它。始终避免硬编码常量,即使是对于默认值。
设置自定义路由
最后但同样重要的是,我们不希望在请求根 URL 时显示 FuelPHP 的欢迎屏幕,而是显示猴子的列表。为此,我们需要更改位于 APPPATH/config/routes.php 的路由配置文件。
替换:
'_root_' => 'welcome/index',
通过:
'_root_' => 'monkey/index',
当请求时:
http://my.app/
你现在应该能看到你的猴子列表。
移除无用的路由和文件
现在我们的项目按预期工作,清理它可能是个好主意:
-
移除
APPPATH/classes/controller/welcome.php,因为我们不再需要这个控制器了 -
移除
APPPATH/classes/presenter文件夹 -
移除
APPPATH/views/welcome文件夹 -
并且从位于
APPPATH/config/routes.php的路由配置文件中移除_404_、hello(/:name)?、my/welcome/page键。
关于部署你的应用程序的一些注意事项
现在你已经有一个运行中的应用程序,你可能想要将其发布到主机上。处理这一点相当简单,较长的部分是发送项目的文件(使用 FTP、Git 或任何其他根据你的托管服务选择的工具),但有一些事情你应该知道。
首先,你必须将你的 apache FUEL_ENV环境设置为production。一个简单的方法是编辑public/.htaccess并取消注释第二行:
SetEnv FUEL_ENV production
请记住,在这种情况下,你将在本地环境和生产环境之间有两个不同的文件,因此容易出错。建议您阅读官方文档fuelphp.com/docs/general/environments.html(可以通过导航到 FuelPHP 网站上的DOCS | 目录 | FuelPHP | 环境来访问)
如果你使用的是共享托管解决方案,请记住,正如在最简单的方法部分所解释的,你应该采取额外的安全预防措施
摘要
在本章中,我们了解了 FuelPHP 框架的非常基础的内容,并构建了我们的第一个项目。我们学习了如何安装 FuelPHP,使用 oil 命令行生成代码文件并迁移我们的应用程序,理解了路由是如何工作的,并看到了模型、视图、演示者和控制器是如何相互交互的。
虽然你现在能够创建一个应用程序并实现基本功能,但你可能还没有准备好进行更复杂的项目。在下一章中,我们将通过使用 FuelPHP 的对象关系映射器(ORM)来提高你的技能。
第二章. 构建待办事项列表应用程序
在上一章中,我们看到了 FuelPHP 框架的一些基础知识,但还有许多东西需要学习才能感到舒适。在这里,我们将创建我们的第一个真实世界应用程序,以便更深入地了解 FuelPHP 的主要功能。我们将创建一个待办事项列表应用程序,这是介绍框架时的常见训练示例。再次强调,这不会是一个非常复杂的应用程序,但这个项目将作为介绍 FuelPHP 基本组件的基础。
到本章结束时,你应该了解以下内容:
-
实体关系图(ER)是什么
-
分析器是什么以及如何使用它
-
如何使用
Debug类 -
对象关系映射器(ORM)是什么以及如何在项目中使用它
-
如何使用
Model_Crud和Model_Orm的基本操作 -
ORM 关系
-
观察者是什么以及如何使用它们
-
如何处理 Ajax 请求
我们在这里假设你已经阅读了第一章,构建你的第一个 FuelPHP 应用程序,因为框架的基本内容已经在那里解释过了。我们还将使用 JavaScript 和 jQuery 来改进待办事项列表的用户界面。由于本书面向中级网络开发者,我们假设你对这些技术有一定的了解。如果不是这样,不要担心,我们将非常轻量地使用它们,你可以在网上找到大量关于这些工具的资源。
规范
首先,让我们定义我们的最终应用程序应该期望的内容如下:
-
创建一个待办事项列表来监控项目的进度。一个项目通过名称描述,并具有许多任务(待办事项列表)。我们在这里假设用户可能有多个同时进行的项目,因此可以创建和管理尽可能多的项目。每个项目也可以被删除。
-
一个任务通过名称和布尔状态(“完成”或“未完成”)来描述。
-
任务在项目中排序,并且用户应该能够通过拖放轻松地在列表中移动项目。
这仍然是一个简单的应用程序,我们不会支持任何隐私功能,例如身份验证(这将在第三章,构建博客应用程序和第五章,构建你自己的 RESTful API中解决)。
概念
此步骤应该从规范阶段开始非常直接。我们将生成以下两个模型:
-
项目:此模型将只具有一个名称属性。
-
任务:此模型将具有名称、状态和排名属性。一个项目包含许多任务,每个任务都与一个项目相关联,因此我们将在这里添加一个额外的列,命名为
project_id。此列将包含与每个任务关联的项目的 ID。
我们可以用以下ER图来表示我们的模型:

实体关系图(最小-最大表示法)
一个 ER 图可以帮助你描述应用程序的数据结构。正如你所注意到的,我们之前写的几乎所有内容都可以在图中找到:
-
模型(在 ER 图中称为实体),由矩形表示
-
模型的属性(称为属性),由省略号表示(主键被下划线标注)并通过线与模型相连
-
模型之间的关系,由一个模型到另一个模型的线表示
我们使用了最小-最大表示法来表示关系。以下是理解它的方法:
在连接 Project 模型和 Task 模型的线上,你可以看到 Project 矩形旁边写着 (0, N) Has many,而 Task 矩形旁边写着 Belongs to (1, 1)。图上你可以读到的 (0, N) 和 (1, 1) 代表了一个实例可以链接的最小和最大元素数量:在我们的例子中,一个项目可以链接到任意数量的任务(介于 0 和 N 之间),而一个任务只能链接到一个项目(介于 1 和 1 之间)。(0, N) 和 (1, 1) 旁边的文本是关系的名称。在这里,我们简单地使用了我们将要使用的 FuelPHP 关系类型(我们将在 ORM 关系 部分解释这些关系)。即使是非程序员也可以通过以下方式阅读它:“每个项目都有多个任务”,“每个任务属于一个项目”。
如果你难以理解如何组织你的数据,画一个 ER 图可能很有用。我们将在接下来的章节中使用这个图,如果你想要理解具有许多模型及其之间关系的复杂数据结构,特别推荐你阅读。建议你在此主题上阅读更多内容,请参阅 en.wikipedia.org/wiki/Entity-relationship_model。
FuelPHP 的安装和配置
你首先需要:
-
安装一个新的 FuelPHP 实例
-
配置 Apache 和你的主机文件以处理它:在本章中,我们将通过请求 URL
http://mytodolists.app来访问我们的应用程序 -
如有必要,更新 Composer
-
为你的应用程序创建一个新的数据库
-
并配置 FuelPHP 以允许你的应用程序访问此数据库
这些步骤已在 第一章,构建你的第一个 FuelPHP 应用程序 中介绍,所以你可能想看看它。
此项目还需要 ORM 包,由于它已经安装,我们只需要启用它。为此,只需打开 APPPATH/config/config.php 文件,并在返回数组的末尾插入以下内容:
'always_load' => array(
'packages' => array(
'orm',
),
),
或者,你可以取消注释适当的行。这将每次加载 FuelPHP 实例时都加载 ORM 包。
注意
你也可以使用 Package::load 方法以临时方式加载一个包。这将在 第三章,构建博客应用程序 中介绍。
脚手架
我们现在将生成,正如在第一章中看到的,构建你的第一个 FuelPHP 应用程序,处理我们的对象所需的必要代码。
让我们先生成项目模型的结构:
php oil generate scaffold/orm project name:string
命令应打印以下输出:
Creating migration: APPPATH/migrations/001_create_projects.php
Creating model: APPPATH/classes/model/project.php
Creating controller: APPPATH/classes/controller/project.php
Creating view: APPPATH/views/project/index.php
Creating view: APPPATH/views/project/view.php
Creating view: APPPATH/views/project/create.php
Creating view: APPPATH/views/project/edit.php
Creating view: APPPATH/views/project/_form.php
Creating view: APPPATH/views/template.php
注意,我们在第一章中使用了scaffold/orm而不是scaffold/crud:这样,Oil 将生成使用 ORM 包的代码文件。例如,我们将在后面看到生成的模型将扩展Orm\Model而不是Model_Crud。
我们现在需要生成管理我们的任务所需的模型。我们在这里不会使用 scaffold,因为我们计划在项目的可视化页面上管理任务,所以我们只需要模型。
php oil generate model/orm task name:string status:boolean rank:int project_id:int
此命令应打印以下输出:
Creating model: APPPATH/classes/model/task.php
Creating migration: APPPATH/migrations/002_create_tasks.php
如你所见,我们在这里只生成了模型和迁移文件。你现在需要做的就是执行迁移文件:
php oil refine migrate
路由配置
你现在可以通过请求 URL http://mytodolists.app/project来管理你的项目。
由于这是我们的人口点,我们希望在请求根 URL http://mytodolists.app/时访问此页面。
正如我们在第一章中看到的,构建你的第一个 FuelPHP 应用程序,你只需要编辑APPPATH/config/routes.php配置文件。将'_root_' => 'welcome/index'替换为'_root_' => 'project/index'。
分析器
由于我们将在下一节需要分析器,所以我们在这里介绍它。FuelPHP 提供了一个分析器,它允许你在请求网页时了解正在发生的事情。它确实可以显示许多性能指标、执行的 SQL 请求、当前日志、会话以及 POST / GET 变量。
你将需要激活它。在开发模式下仅使用此工具是明智的,因为否则可能会出现严重的安全问题。为此,你首先需要创建APPPATH/config/development/config.php配置文件,并写入以下内容:
<?php
return array(
'profiling' => true,
);
你还需要编辑APPPATH/config/development/db.php配置文件,以便查看数据库查询(否则分析器不会显示它们):在default数组的末尾添加'profiling' => true,。
如果你现在请求你的根 URL http://mytodolists.app/,你将在屏幕的右下角看到一个标记为代码分析器的黑色矩形。如果你点击它,你应该看到以下内容:

以下描述了你可以访问的几个标签页:
-
注意控制台(NB代表日志数量):此标签页显示所有日志。例如,如果你在项目控制器的索引操作开始处添加
Log::info('Index Action', 'This is a test');,然后刷新网页,你应该在这个标签页中看到一个新条目出现。 -
TIME 加载时间(TIME 表示网页总加载时间):此选项卡显示与时间标记相关的日志。请注意,这些日志也出现在第一个选项卡中。例如,如果你在项目控制器的索引动作的开始处添加
Profiler::mark('Index Action');,你应该会看到此选项卡中出现一个新项目。 -
NB 查询数据库(NB 表示查询数量):此选项卡显示在加载网页时执行的数据库查询。对于每个查询,其分析和调用跟踪都会显示。还会显示重复次数,你可以通过查看 Speed 旁边的 DUPLICATE 单词来识别似乎重复前一个查询的查询。
-
SIZE 内存使用量(SIZE 表示使用的内存量):此选项卡显示与内存标记相关的日志。请注意,这些日志也出现在第一个选项卡中。例如,如果你在项目控制器的索引动作的开始处添加
Profiler::mark_memory($this, 'Controller_Project 对象');,你应该会看到此选项卡中出现一个新项目。 -
NB 包含的文件(NB 表示文件数量):此选项卡显示为显示网页而加载的所有文件(代码或配置)。
-
NB 配置项已加载(NB 表示项目数量):此选项卡显示已加载的配置项(而非文件)。例如,如果你加载一个包含 5 个键的关联数组的配置文件,此选项卡中将出现 5 个新项目。
-
NB 会话变量已加载,NB GET 变量已加载,NB POST 变量已加载:这些选项卡显示请求和会话变量。例如,如果你请求
http://mytodolists.app/?param=test,NB GET 变量已加载 选项卡中应该会出现一个新项目。
模型、关系和 ORM
我们现在已经完成了初步步骤:我们安装了 FuelPHP,进行了配置,生成了用于管理项目的脚手架,并创建了任务模型。但我们没有连接这两个模型,我们还没有在任何地方显示任务。更重要的是,我们还没有解释如何加载对象,直到现在。本节的目标就是这一点。
CRUD 和 ORM 之间的区别
如我们之前所述,我们使用油来生成代码,但与第一章中使用的 scaffold/crud 不同,我们使用了 scaffold/orm 和 model/orm。如果你查看文件(控制器、视图和模型),你将只会看到一些细微的变化,除了模型文件:
-
$_table_name属性不再声明。尽管如此,它仍然被Orm\Model使用,但它采用一个默认值,该值取决于模型名称,因此如果你想要使用自定义表名,你仍然可以定义它。 -
已添加
$_properties属性。此属性包含模型必须管理的所有属性(与表列相关联)。定义此属性不是强制性的,但如果不这样做可能会降低网站性能,因为 FuelPHP 需要将模型与表结构同步。请注意,Model_Crud也使用此属性,但油生成的代码简单地没有定义它。 -
还添加了
$_observers属性。此属性定义了使用的观察者和它们的参数。我们将在下一节中解释观察者是什么以及如何使用它们。
FuelPHP ORM
ORM 代表对象关系映射器。它允许开发者完成以下两件事:
-
它将表行映射到对象。为此,ORM 提供了几个函数来提取特定的表行并将它们转换为 PHP 对象。其他方法也存在,允许开发者将对象保存到表行中。
find和save方法都是例子。 -
它允许你在模型之间建立关系。在本章的项目中,我们创建了两个模型,项目和任务,它们之间存在一种关系:一个项目可以有多个任务,每个任务都与一个项目相关联。当将这些关系定义给 ORM 时,它将启用方法,使开发者能够更轻松地访问项目任务,例如。
简而言之,ORM(对象关系映射)的目的在于简化开发者的工作。除了前面提到的两个主要点之外,ORM 还将处理一些安全问题(例如 SQL 注入)以及可能影响某些属性保存方式的观察者。一般来说,FuelPHP 的 ORM 紧密遵循活动记录模式。
数据库和 ORM 基础
注意,我们将要使用的大多数方法都是在Orm/Model以及Model_Crud上工作的。
首先,我们需要创建一个 PHP 文件来测试我们的代码。在这里,我们可以使用油控制台,在大多数情况下你应该这样做,但在这个例子中我们不会这样做,因为我们想看到执行的 SQL 请求(我们计划使用分析器来完成这项工作)。请注意,此文件不应推送到生产环境中。创建一个位于public/test.php的文件,内容如下:
<?php
// Fuel initialization (inspired from index.php)
define('DOCROOT', __DIR__.DIRECTORY_SEPARATOR);
define('APPPATH', realpath(__DIR__.'/../fuel/app/')
.DIRECTORY_SEPARATOR);
define('PKGPATH', realpath(__DIR__.'/../fuel/packages/')
.DIRECTORY_SEPARATOR);
define('COREPATH', realpath(__DIR__.'/../fuel/core/')
.DIRECTORY_SEPARATOR);
defined('FUEL_START_TIME') or define('FUEL_START_TIME',
microtime(true));
defined('FUEL_START_MEM') or define('FUEL_START_MEM',
memory_get_usage());
require COREPATH.'classes'.DIRECTORY_SEPARATOR.'autoloader.php';
class_alias('Fuel\\Core\\Autoloader', 'Autoloader');
require APPPATH.'bootstrap.php';
echo 'FuelPHP is initialized...';
之前的代码初始化了 FuelPHP(当你有一个位于公共文件夹中的 PHP 脚本并且你想使用 FuelPHP 功能时是必要的)。此脚本应在请求以下 URL 时可用,并应显示FuelPHP 已初始化...:http://mytodolists.app/test.php。
接下来的所有示例都必须逐个追加到文件中。对于那些还没有使用任何 ORM 的您,建议您在每个部分内追加代码,刷新,并深入查看网页输出和性能分析器中的执行查询。请注意,这只是一个介绍;要了解更多关于 ORM 的信息,建议您阅读官方文档fuelphp.com/docs/packages/orm/intro.html。
不使用 ORM 执行查询
您首先应该知道,您可以在不使用 ORM 的情况下执行查询。使用 ORM 是为了简化您的生活,但它不是强制性的。在某些情况下,例如影响多行的更改,您甚至不应该使用 ORM。另一个例子是当您想要清空一个表时:
// --- Executing queries without the ORM
\DB::query('TRUNCATE TABLE `projects`;')->execute();
\DB::query('TRUNCATE TABLE `tasks`;')->execute();
// \DBUtil::truncate_table('projects'); is also possible
创建新对象
以下示例展示了如何创建新的项目:
// --- Creating new objects
$project = Model_Project::forge(); // = new Model_Project()
$project->name = 'First project';
$project->save();
// You can also set properties when calling the forge method
$project = Model_Project::forge(
array('name' => 'Second project')
);
$project->save();
查找特定对象
这就是如何在表中找到第一个对象:
// --- Finding specific objects
$project = Model_Project::find('first');
\Debug::dump('first', $project);
如果您现在刷新您的网页,您应该会看到一个以下灰色框:

如果您通过点击 ↵ 展开Model_Project,然后点击 _data,您应该会看到这确实是第一个项目:

如果您查看控制台,您也可以通过看到执行的 SQL 请求来确认这一点:
SELECT … FROM `projects` AS `t0` ORDER BY `t0`.`id` ASC
LIMIT 1
您也可以加载最后一个对象:
$project = Model_Project::find('last');
\Debug::dump('last', $project);
执行的请求应该是这样的:
SELECT … FROM `projects` AS `t0` ORDER BY `t0`.`id` DESC
LIMIT 1
或者,您可以通过指定 ID 来加载一个项目:
$project = Model_Project::find(1);
\Debug::dump('with id = 1', $project);
您可以在此处注意到没有执行任何请求。这是因为 ORM 缓存了加载的对象,项目已经在之前的请求中加载过了。否则,以下请求应该被执行:
SELECT … FROM `projects` AS `t0` WHERE `t0`.`id` = 1
LIMIT 1
更新对象
这里是如何更新现有对象(在这里,我们更改 id = 1 的项目名称):
// --- Updating an object
$project = Model_Project::find(1); // Load project with id = 1
$project->name = 'First one';
$project->save();
执行请求:
UPDATE `projects` SET `name` = 'First one' WHERE `id` = '1'
删除对象
我们可以通过调用以下方式删除一个项目:
// --- Deleting an object.
$project = Model_Project::find(1); // Load project with id = 1
$project->delete();
执行请求:
DELETE FROM `projects` WHERE `id` = '1' LIMIT 1
加载多个对象
我们可以一次性加载多个对象。以下示例展示了如何加载所有项目实例:
// --- Loading several objects
// First creating an additional project for a more interesting
// result
$project = Model_Project::forge();
$project->name = 'Third project';
$project->save();
// Finding all projects
$projects = Model_Project::find('all');
\Debug::dump('all', $projects);
在这种情况下,$projects 将是一个包含项目的关联数组,键是项目的 ID,值是相关联的项目。执行的请求如下:
SELECT … FROM `projects` AS `t0`
使用方法链
query 方法是 find 方法的等价物,但它允许您使用方法链获取对象。
这里是如何使用 query 方法找到所有项目实例的:
$projects = Model_Project::query()->get();
\Debug::dump('all (using query)', $projects);
执行的请求与之前执行的相同:
SELECT … FROM `projects` AS `t0`
更复杂的请求
您也可以执行更复杂的请求。首先,让我们添加各种任务:
// Creating sample tasks
Model_Task::forge(array('name' => 'Marketing plan',
'status' => 0, 'rank' => 0, 'project_id' => 2))->save();
Model_Task::forge(array('name' => 'Update website',
'status' => 1, 'rank' => 1, 'project_id' => 2))->save();
Model_Task::forge(array('name' => 'Improve website template',
'status' => 1, 'rank' => 2, 'project_id' => 2))->save();
Model_Task::forge(array('name' => 'Contact director',
'status' => 0, 'rank' => 0, 'project_id' => 3))->save();
Model_Task::forge(array('name' => 'Buy a new computer',
'status' => 1, 'rank' => 1, 'project_id' => 3))->save();
现在我们已经创建了各种任务,我们将能够测试 find 方法的第二个参数。
让我们获取 project_id = 2 的第一个任务:
$task = Model_Task::find('first',
array(
'where' => array(
array('project_id' => 2)
)
)
);
\Debug::dump('first with project_id = 2', $task);
或者使用 query 方法:
$task = Model_Task::query()
->where('project_id', 2)
->order_by('id', 'asc') // Will be introduced shortly
->get_one();
\Debug::dump('first with project_id = 2 (using query)', $task);
执行请求:
SELECT … FROM `tasks` AS `t0` WHERE (`t0`.`project_id` = 2)
ORDER BY `t0`.`id` ASC LIMIT 1
现在,让我们获取所有 project_id = 2 的任务:
$tasks = Model_Task::find('all',
array(
'where' => array(
array('project_id' => 2)
)
)
);
\Debug::dump('project_id = 2', $tasks);
或者使用 query 方法:
$tasks = Model_Task::query()
->where('project_id', 2)
->get();
\Debug::dump('project_id = 2 (using query)', $tasks);
执行请求:
SELECT … FROM `tasks` AS `t0` WHERE (`t0`.`project_id` = 2)
也可以通过 project_id = 2 和 status = 1 获取所有任务:
$tasks = Model_Task::find('all',
array(
'where' => array(
array('project_id' => 2),
array('status' => 1)
)
)
);
\Debug::dump('project_id = 2 & status = 1', $tasks);
或者使用 query 方法:
$tasks = Model_Task::query()
->where('project_id', 2)
->where('status', 1)
->get();
\Debug::dump('project_id = 2 & status = 1 (using query)', $tasks);
执行的请求:
SELECT … FROM `tasks` AS `t0` WHERE (`t0`.`project_id` = 2)
AND (`t0`.`status` = 1)
这是我们获取所有 project_id > 2 和 status = 1 的任务的示例:
$tasks = Model_Task::find('all',
array(
'where' => array(
array('project_id', '>', 2),
array('status' => 1)
)
)
);
\Debug::dump('project_id > 2 & status = 1', $tasks);
或者使用 query 方法:
$tasks = Model_Task::query()
->where('project_id', '>', 2)
->where('status', 1)
->get();
\Debug::dump('project_id > 2 & status = 1 (using query)', $tasks);
执行的请求:
SELECT … FROM `tasks` AS `t0` WHERE `t0`.`project_id` > 2
AND (`t0`.`status` = 1)
这是我们获取 project_id > 2 或 status = 1 的任务的示例:
$tasks = Model_Task::find('all',
array(
'where' => array(
array('project_id' => 2),
'or' => array('status' => 1)
)
)
);
\Debug::dump('project_id = 2 or status = 1', $tasks);
或者使用 query 方法:
$tasks = Model_Task::query()
->where('project_id', '=', 2)
->or_where('status', 1)
->get();
\Debug::dump('project_id = 2 or status = 1 (query)', $tasks);
执行的请求:
SELECT … FROM `tasks` AS `t0` WHERE (`t0`.`project_id` = 2) OR ((`t0`.`status` = 1))
这是我们获取名称包含单词 website 的任务的示例:
$tasks = Model_Task::find('all',
array(
'where' => array(
array(
'name',
'LIKE',
'%website%'
),
)
)
);
\Debug::dump('name contains "website"', $tasks);
或者使用 query 方法:
$tasks = Model_Task::query()
->where('name', 'LIKE', '%website%')
->get();
\Debug::dump('name contains "website" (using query)', $tasks);
执行的请求:
SELECT … FROM `tasks` AS `t0` WHERE `t0`.`name` LIKE
'%website%'
你也可以指定一个排序:
$tasks = Model_Task::find('all',
array(
'where' => array(
array(
'name',
'LIKE',
'%website%'
),
),
'order_by' => array(
'rank' => 'ASC'
),
)
);
\Debug::dump(
'name contains "website" ordered by the rank column',
$tasks
);
或者使用 query 方法:
$tasks = Model_Task::query()
->where('name', 'LIKE', '%website%')
->order_by('rank', 'ASC')
->get();
\Debug::dump(
'name contains "website" ordered by the rank column (query)',
$tasks
);
执行的请求:
SELECT … FROM `tasks` AS `t0` WHERE `t0`.`name` LIKE
'%website%' ORDER BY `t0`.`rank` ASC
你可能已经注意到,使用 query 方法通常可以使你编写更简洁、更易读的代码。因此,建议你在复杂请求中使用 query 方法。然而,在大多数情况下,使用 find 或 query 并没有太大区别,所以请根据你的最佳判断来使用。
如前所述,这只是一个对 ORM 的非常简单的介绍。除了 where 和 order_by 之外,还有很多其他键,我们稍后会看到一些(例如 related 键)。建议你查看 ORM 包的官方文档,该文档可在以下链接找到:fuelphp.com/docs/packages/orm/intro.html。
我们还使用了 Debug 和 DB 类。了解它们可能会有所帮助。再次提醒,你可以自由地阅读它们的官方文档,这些文档可以在以下链接找到:
ORM 关系
是时候定义任务和项目之间的关系了。正如我们之前解释的,定义它们可以激活有用的功能,使开发者的工作变得容易并提高性能。关系必须在模型内部定义。例如,我们将在 Project 模型中定义一个关系,以便访问每个项目的任务,我们还将也在 Task 模型中定义另一个关系,以便访问每个任务关联的项目。有 4 种关系类型:
-
属于:当你定义模型 A 对模型 B 的 属于 关系时,每个模型 A 的实例只能与一个模型 B 的实例相关联。你通常需要在模型 A 的表中创建一个列,用于连接实例。在本章中,Task 模型与 Project 模型有一个 属于 关系。确实,每个任务只与一个项目相关联,而任务表中的
project_id列用于连接每个实例。一个具体的例子是,project_id = 1的任务将属于id = 1的项目。 -
拥有多个:当你在模型 A 中对模型 B 定义一个 拥有多个 关系时,每个模型 A 的实例可以与多个模型 B 的实例相关联。你通常需要在模型 B 的表中创建一个用于连接实例的列。在本章中,项目模型与任务模型有一个 拥有多个 关系;确实,每个项目可以有多个任务,并且任务表中的
project_id列用于连接每个实例。一个具体的例子是,id = 1的项目可以有多个project_id = 1的任务。 -
拥有一个:理解这种关系的一种方式是将其视为一个 拥有多个 关系的特例,除了每个模型 A 的实例只能与一个模型 B 的实例相关联。如果我们定义了项目模型对任务模型的 拥有一个 关系(而不是拥有多个),我们仍然需要在任务表中定义
project_id列,但在那种情况下,每个项目只能关联一个任务。 -
多对多:当你在模型 A 中对模型 B 定义一个 多对多 关系时,每个模型 A 的实例可以与多个模型 B 的实例相关联,并且每个模型 B 的实例也可以与多个模型 A 的实例相关联。在这种情况下,你需要创建一个中间表。
建议您阅读关于关系的官方文档,链接为 fuelphp.com/docs/packages/orm/relations/intro.html。
在模型内定义关系
现在我们已经介绍了不同类型的关系,让我们在我们的模型中定义它们。
首先,打开 APPPATH/classes/model/task.php 并在 Model_Task 类中添加以下属性:
protected static $_belongs_to = array('project');
注意,这等同于以下代码:
protected static $_belongs_to = array(
'project' => array(
'model_to' => 'Model_Project',
'key_from' => 'project_id',
'key_to' => 'id',
'cascade_save' => true,
'cascade_delete' => false,
)
);
在第一种情况下,model_to、key_from 和 key_to 键是从数组值('project')推断出来的。如果没有定义,cascade_save 的默认值是 true,而 cascade_delete 的默认值是 false。这些键定义了以下关系特征:
-
model_to键:关系(模型 B) -
key_from键:用于连接实例的模型(模型 A)的列 -
key_to键:关系(模型 B 的)列中用于连接实例的模型 -
cascade_save键:如果为真,每次保存模型实例时,相关的实例也将被保存 -
cascade_delete键:如果为真,每次删除模型实例时,相关的实例也将被删除。请注意此功能,因为您可能会删除比实际想要的更多信息。
现在,打开 APPPATH/classes/model/project.php 并在 Model_Project 类中添加以下属性:
protected static $_has_many = array('tasks');
与 belongs_to 关系一样,它等同于以下代码:
protected static $_has_many = array(
'tasks' => array(
'model_to' => 'Model_Task',
'key_from' => 'id',
'key_to' => 'project_id',
'cascade_save' => true,
'cascade_delete' => false,
)
);
测试关系
为了更好地说明这一点,让我们通过在我们的 public/test.php 文件中添加代码来测试这些关系的工作方式。
获取对象的关联
首先让我们加载一个id = 1的任务实例,然后加载其相关项目。你可以注意到我们设置了from_cache参数为false。这样做是为了防止 FuelPHP 从缓存中加载实例,因为我们想显示所有已执行的请求。在大多数情况下,不建议使用此参数。
$task = Model_Task::find(1, array('from_cache' => false));
$project = $task->project;
\Debug::dump('Project of task with id = 1', $project);
在第二行,我们通过访问project属性加载了任务的项目。这是我们声明在Model_Task类中的关系名称。一般来说,如果你想通过关系RELATION_NAME访问相关实例,你可以使用$item->RELATION_NAME来获取它。
你可以看到以下两个请求被执行了:
-
第一次请求是在
find方法中执行的,目的是加载id = 1的任务。SELECT … FROM `tasks` AS `t0` WHERE `t0`.`id` = 1 LIMIT 1 -
第二次请求是在获取
$task->project时执行的:在这种情况下,检索了id = $task->project_id的项目:SELECT … FROM `projects` AS `t0` WHERE `t0`.`id` = '2' LIMIT 1
让我们加载一个id = 2的项目实例,然后加载其相关任务:
$project = Model_Project::find(
2,
array('from_cache' => false)
);
$tasks = $project->tasks;
\Debug::dump('Tasks of project with id = 2', $tasks);
执行了两次请求:
-
第一次请求加载项目的实例:
SELECT … FROM `projects` AS `t0` WHERE `t0`.`id` = 2 LIMIT 1 -
第二次请求加载了项目关联的任务:
SELECT … FROM `tasks` AS `t0` WHERE `t0`.`project_id` = '2'
当定义关系时,find方法允许你通过减少 SQL 请求的数量来提高性能。例如,如果你这样做:
$projects = Model_Project::find(
'all',
array('from_cache' => false)
);
foreach ($projects as $project) {
\Debug::dump(
'LOOP 1: Tasks of project with id = '.$project->id,
$project->tasks
);
}
将执行三次请求,如下所示:
-
一次加载所有项目
SELECT … FROM `projects` AS `t0` -
对于
$projects中的每个项目,加载$project->tasks两次SELECT … FROM `tasks` AS `t0` WHERE `t0`.`project_id` = '2'SELECT … FROM `tasks` AS `t0` WHERE `t0`.`project_id` = '3'
三次请求看起来并不多,但如果你要加载 100 个项目,这意味着你将执行 101 次请求,这可能导致严重的性能问题。
find方法允许你通过related键来解决这个问题:
$projects = Model_Project::find(
'all',
array(
'related' => 'tasks'
)
);
foreach ($projects as $project) {
\Debug::dump(
'LOOP 2: Tasks of project with id = '.$project->id,
$project->tasks
);
}
这里,只执行了一次请求。FuelPHP 的 ORM 在执行find方法时通过在请求中连接任务表来加载关系。
执行的请求:
SELECT … FROM `projects` AS `t0` LEFT JOIN
`tasks` AS `t1` ON (`t0`.`id` = `t1`.`project_id`)
更新对象的关联
如果你想要更新一个关系,你可以简单地更新支持它的列。例如,以下代码加载了id = 1的任务,并使其属于id = 3的项目:
$task = Model_Task::find(1, array('from_cache' => false));
$task->project_id = 3;
$task->save();
将执行两次请求,如下所示:
-
第一次请求加载了任务:
SELECT … FROM `tasks` AS `t0` WHERE `t0`.`id` = 1 LIMIT 1 -
第二次更新了任务的
project_id列:UPDATE `tasks` SET `project_id` = '3', `updated_at` = 1404729671 WHERE `id` = '1'
虽然它执行了更多的 SQL 请求,但我们也可以编写以下内容:
$task = Model_Task::find(1, array('from_cache' => false));
$task->project = Model_Project::find(
3,
array('from_cache' => false)
);
$task->save();
将执行四次请求,如下所示:
-
第一次请求加载了任务(由
Model_Task::find(...)执行):SELECT … FROM `tasks` AS `t0` WHERE `t0`.`id` = 1 LIMIT 1 -
第二次请求加载了我们想要关联到任务上的项目(由
Model_Project::find(...)执行):SELECT … FROM `projects` AS `t0` WHERE `t0`.`id` = 3 LIMIT 1 -
第三次请求加载与任务关联的现有项目(由
$task->project执行):SELECT … FROM `projects` AS `t0` WHERE `t0`.`id` = '2' LIMIT 1 -
第四次请求更新任务的
project_id列(由$task->save()执行):UPDATE `tasks` SET `project_id` = '3', `updated_at` = 1404729671 WHERE `id` = '1'
还有可能将新项目关联到任务上:
$task = Model_Task::find(1, array('from_cache' => false));
$task->project = Model_Project::forge();
$task->project->name = 'Fourth project';
$task->save();
在那种情况下,ORM 将创建一个新的项目,然后为project_id属性分配正确的 ID。
将执行四次请求,如下所示:
-
第一次请求加载任务(通过
Model_Task::find(...)执行):SELECT … FROM `tasks` AS `t0` WHERE `t0`.`id` = 1 LIMIT 1 -
第二次请求加载与任务关联的现有项目(通过
$task->project执行):SELECT … FROM `projects` AS `t0` WHERE `t0`.`id` = '3' LIMIT 1 -
第三次请求创建新项目(通过
$task->save()执行):INSERT INTO `projects` (`name`, `created_at`, `updated_at`) VALUES ('Fourth project', 1404729796, 1404729796) -
第四次请求更新任务的
project_id列(通过$task->save()执行):UPDATE `tasks` SET `project_id` = '4', `updated_at` = 1404729796 WHERE `id` = '1'
同样,也可以将新任务关联到项目中:
$project = Model_Project::find(
2,
array('from_cache' => false)
);
$task = Model_Task::forge();
$task->name = 'Buy a new mouse';
$task->status = 0;
$task->rank = 2;
$project->tasks[] = $task;
$project->save();
将执行三个请求,具体如下:
-
第一次请求加载项目(通过
Model_Project::find(...)执行):SELECT … FROM `projects` AS `t0` WHERE `t0`.`id` = 2 LIMIT 1 -
第二次请求加载与项目关联的现有任务(通过
$project->tasks执行):SELECT … FROM `tasks` AS `t0` WHERE `t0`.`project_id` = '2' -
第三次请求创建新任务(通过
$project->save()执行):INSERT INTO `tasks` (`name`, `status`, `rank`, `project_id`, `created_at`, `updated_at`) VALUES ('Buy a new mouse', 0, 2, '2', 1404731559, null)
如果你仔细查看$project->tasks,你会注意到它是一个关联数组,键是实例 ID,值是实例。因此,这就是如何通过关系更新特定任务的方法:
$project = Model_Project::find(
2,
array('from_cache' => false)
);
$project->tasks[6]->name = 'Buy an optical mouse';
$project->save();
它将任务(id = 6)的名称更改为'Buy an optical mouse'(如果此任务存在且其project_id等于 2)。
将执行三个请求,具体如下:
-
第一次请求加载项目(通过
Model_Project::find(...)执行):SELECT … FROM `projects` AS `t0` WHERE `t0`.`id` = 2 LIMIT 1 -
第二次请求加载与项目关联的现有任务(通过
$project->tasks执行):SELECT … FROM `tasks` AS `t0` WHERE `t0`.`project_id` = '2' -
第三次请求更新任务的
name列(通过$project->save()执行):UPDATE `tasks` SET `name` = 'Buy an optical mouse', `updated_at` = 1404732349 WHERE `id` = '6'
还可以断开两个相关项的连接。这个例子不适合或对我们项目没有用,但重要的是要知道你可以这样做。让我们尝试将任务(id = 4)从项目(id = 3)中断开连接:
$project = Model_Project::find(
3,
array('from_cache' => false)
);
unset($project->tasks[4]);
$project->save();
执行的请求:
-
第一次请求加载项目(通过
Model_Project::find(...)执行):SELECT … FROM `projects` AS `t0` WHERE `t0`.`id` = 3 LIMIT 1 -
第二次请求加载与项目关联的现有任务(通过
$project->tasks执行):SELECT … FROM `tasks` AS `t0` WHERE `t0`.`project_id` = '3' -
第三次请求尝试将任务(
id = 4)从项目中断开连接(通过$project->save()执行):UPDATE `tasks` SET `project_id` = null, `updated_at` = 1404803182 WHERE `id` = '4'
如果你查看执行的请求,这段代码并不适合我们的项目,因为:
-
为了正确工作,我们应该允许
project_id列可以为空,但目前并非如此 -
在我们的应用程序中,不属于任何项目的任务是没有意义的
在其他情况下,这样做可能是合法的。再次强调,这只是一个 ORM 关系的简要介绍,建议阅读官方文档fuelphp.com/docs/packages/orm/relations/intro.html。
观察器和事件
你可能已经注意到在前一节中,在保存对象时,即使我们没有指定任何内容,也会在created_at和updated_at列中保存一些额外的值。例如:
INSERT INTO `projects` (`name`, `created_at`, `updated_at`) VALUES ('Third project', 1404729796, 1404729796);
这是因为在模型中定义的观察者导致的。观察者覆盖了一些模型行为;例如,它们可以在将更改提交到数据库之前更改属性值,或者在满足某些条件时阻止对象被保存。让我们看看在Model_Project中定义的观察者:
protected static $_observers = array(
'Orm\Observer_CreatedAt' => array(
'events' => array('before_insert'),
'mysql_timestamp' => false,
),
'Orm\Observer_UpdatedAt' => array(
'events' => array('before_save'),
'mysql_timestamp' => false,
),
);
你可以注意到定义了两个观察者:Orm\Observer_CreatedAt和Orm\Observer_UpdatedAt。它们分别处理created_at和updated_at列,在对象创建或更新时将它们的值设置为当前时间戳。观察者可以有自定义参数,例如mysql_timestamp,它定义是否保存 MySQL 时间戳而不是 UNIX 时间戳。
events参数对所有观察者都是通用的,它定义了它们应该连接到哪些事件。事件是在行为中某个对象发生某些事情时调用的方法;例如,当你保存一个对象时,ORM 会在更改提交到数据库之前尝试调用其行为的before_save方法。有几个事件,如after_create或after_save。你可以在官方文档的完整描述列表中阅读,请参阅fuelphp.com/docs/packages/orm/observers/creating.html#/event_names
为了了解更多信息,还建议你阅读官方文档中关于观察者的介绍fuelphp.com/docs/packages/orm/observers/intro.html
待办事项列表的实现
现在我们已经解释了基本的 ORM 特性和实现了我们的关系,现在是时候构建我们的待办事项列表了。本节假设您至少执行了完整的public/test.php脚本一次。
允许用户查看和更改任务的状态
首先,我们将显示查看项目详情时关联的任务。例如,这个网页应该在项目名称之后显示id = 2的项目的所有任务:
http://mytodolists.app/project/view/2
为了做到这一点,我们可以像在第一章中做的那样,构建您的第一个 FuelPHP 应用程序,分析 URL 并推断出,在这种情况下,Project控制器的视图操作被执行。该操作显示project/view;因此,我们必须编辑APPPATH/views/project/view.php。在显示项目名称的第一段下方添加以下代码:
<?php echo render('task/list', array('project' => $project)); ?>
render(...)方法是对View::forge(...)->render()的别名,因此前面的代码显示了task/list视图。创建APPPATH/views/task/list.php视图文件(你必须创建task文件夹),并将其内容设置为:
<ul id="todo_list" data-project_id="<?php echo $project->id; ?>">
<?php foreach ($project->tasks as $task) {
$input_id = 'todo_item_'.$task->id;
?>
<li>
<input
type="checkbox"
autocomplete="off"
id="<?php echo $input_id; ?>"
data-task_id="<?php echo $task->id; ?>"
<?php echo $task->status ? 'checked' : ''; ?>
>
<label for="<?php echo $input_id; ?>">
<?php echo $task->name; ?>
</label>
</li>
<?php } ?>
</ul>
这段代码应该相当直观;它显示了一个 HTML 列表,列出了项目的任务。每个条目都显示了一个标签,其中包含一个复选框,显示其状态。我们可以做出以下两个观察:
-
我们在
ul元素内部定义了data-project_id属性。稍后我们的 JavaScript 代码将使用它来轻松检索项目的 ID。对于每个复选框的data-task_id属性也是如此。 -
您可以注意到我们没有对
$task->name进行转义就打印了它。您可能会认为这是一个安全漏洞,因为$task->name可能包含 HTML 标签,如<script>标签,因此容易受到 XSS 注入的影响。然而,这并不是问题,因为当您使用View::forge方法时,所有参数(甚至模型属性)默认都会被处理(转义)以防止此类安全漏洞。尽管如此,您仍然可以禁用此行为(我们将在 第三章 中看到,有时我们必须这样做),在这种情况下,FuelPHP 提供了e方法来手动转义变量。
注意
建议将您的视图分成小的视图文件,每个文件显示网页的特定区域。我们通过创建一个额外的视图来显示任务列表来实现这一点。我们甚至可以更进一步,创建一个显示单个任务的视图文件,然后为任务列表中的每个项目渲染它。
我们现在可以看到我们的待办事项。但如果我们点击复选框,它不会与我们的服务器同步,如果我们刷新网页,我们可以看到项目回到了它们旧的状态。我们将使用一点 JavaScript 和 jQuery 来同步复选框与网站。
在 public/assets/js/website.js 创建一个 JavaScript 文件。
我们首先必须在模板中包含它。打开 APPPATH/views/template.php 并在 head 标签的末尾之前添加以下内容:
<?php
echo Asset::js(array(
'http://code.jquery.com/jquery-1.11.2.min.js',
'http://code.jquery.com/ui/1.11.2/jquery-ui.min.js',
'website.js'
));
?>
以下代码将三个 JavaScript 文件包含到模板中:
-
前两行是我们将需要的 jQuery 和 jQuery UI 脚本。
-
第三行是包含我们 JavaScript 代码的脚本。请注意,您不必写出完整的路径。
Asset::js方法会自动在public/assets/js文件夹中搜索文件。您应该知道,如果需要,可以使用Asset::add_path方法指定额外的目录来搜索 JavaScript 和 CSS 文件。建议您阅读官方文档,网址为fuelphp.com/docs/classes/asset/usage.html。
由于我们需要在 JavaScript 代码中知道我们的基本 URL 以发送 AJAX 请求,因此请在我们之前添加的代码之前添加以下内容:
<script type="text/javascript">
<?php
echo 'var uriBase = '.Format::forge(Uri::base())->to_json().';';
?>
</script>
此代码创建了一个名为 uriBase 的 JavaScript 变量,它包含从 Uri::base() 获取的基本 URL,并通过 Format::forge(...)->to_json() 编码成 JavaScript 字符串。建议您阅读以下 URL 上的关于这些类的官方文档:
注意
这个uriBase变量是为了那些在您的网站根目录内创建项目而没有使用虚拟主机的您而实现的:在这种情况下,仅使用相对 URL 发送 AJAX 请求将导致问题。一个替代方案是使用基础 HTML 标签,正如我们将在第五章中看到的,构建您自己的 RESTful API。
现在我们已经将 JavaScript 文件及其依赖项包含在模板中,我们必须实现复选框同步。打开我们之前创建的位于public/assets/js/website.js的 JavaScript 文件,并将其内容设置为:
$(document).ready(function() {
// Checkbox synchronization
$('#todo_list input[type=checkbox]').change(function() {
var $this = $(this);
$.post(
uriBase + 'project/change_task_status',
{
'task_id': $this.data('task_id'),
'new_status': $this.is(':checked') ? 1 : 0
}
);
});
});
对于不熟悉 jQuery 的人来说,代码执行以下操作:
-
当文档 DOM 准备好时,脚本将在我们的待办事项列表中查找复选框并跟踪其变化。
-
当复选框被更改时,它将向
project/change_task_status操作发送一个带有任务 ID 及其新状态的POST请求。 -
它不处理错误;如果存在连接问题,用户会认为网页与服务器同步,尽管实际上并没有。这可能是改进的一个轴。
现在,我们必须在服务器端处理这个请求,因此我们需要在项目控制器中创建change_task_status操作。
注意
注意,为了简化,我们决定在项目控制器中创建操作,尽管它处理任务,因此应该在一个任务控制器中创建。对于你的真实项目,强烈建议不要这样做。很容易陷入只有一个控制器处理整个网站的陷阱,尽管对于小型项目可能“可以”,但随着功能的增加,你将面临严重的可维护性问题。
打开项目控制器并添加以下操作:
public function action_change_task_status()
{
if (Input::is_ajax()) {
$task = Model_Task::find(intval(Input::post('task_id')));
$task->status = intval(Input::post('new_status'));
$task->save();
}
return false; // we return no content at all
}
你可以注意到我们使用了Input::post而不是全局变量$_POST;它获取相同的值,但你可以在Input::post的第二个参数中定义一个默认值,以防键未定义。同样适用于Input::get和$_GET。
我们还检查了是否使用Input::is_ajax来确定是否是 Ajax 请求。但请注意,没有安全的方法来检测请求是否是通过 Ajax 发出的(永远不要相信来自客户端的数据)。
现在同步应该可以工作;任何状态更改都应该在刷新网页时保存并保留。
允许用户添加任务
现在我们可以看到并更改项目任务的状况,添加新的任务可能很有用。我们将在待办事项列表下添加一个表单来完成这个操作。
首先打开APPPATH/views/task/list.php,并在末尾添加以下内容:
<?php echo render('task/create', array('project' => $project)); ?>
然后创建一个位于APPPATH/views/task/create.php的视图文件,并设置其内容为:
<h3>Create a new task:</h3>
<?php
echo Form::open();
echo Form::input(
'task_name',
null,
array('placeholder'=>'Task name')
);
echo Form::submit('task_submit', 'Create');
echo Form::close();
?>
这里没有什么特别之处,我们只是显示一个带有文本输入(用于任务标题)和创建按钮的表单。我们使用Form类来完成这个任务,但我们也可以用 HTML 代码来写。有关此类的更多详细信息,建议您阅读官方文档fuelphp.com/docs/classes/form.html。
注意,没有参数传递给Form::open;结果是,表单将提交信息到当前 URL(这就是我们将知道新任务必须关联到哪个项目的方式)。因此,我们必须在项目控制器的视图操作中处理表单。在操作内部,添加以下内容:
// Checking first if we received a POST request
if (Input::method() == 'POST')
{
// Getting the task name. If empty, we display an
// error, otherwise we attempt to create the new
// task
$task_name = Input::post('task_name', '');
if ($task_name == '') {
// Setting the flash session variable named
// error. Reminder: this variable is displayed
// in the template using Session::get_flash
Session::set_flash(
'error',
'The task name is empty!'
);
} else {
$task = Model_Task::forge();
$task->name = $task_name;
$task->status = 0;
$task->rank = 0; // temporary
$data['project']->tasks[] = $task;
$data['project']->save();
// When the task has been saved, we redirect
// the browser to the same webpage. This
// prevents the form from being submitted
// again if the user refreshes the webpage
Response::redirect('project/view/'.$id);
}
}
之前:
$this->template->title = "Project";
如果你阅读了注释,我们所做的更改应该是相当直接的。
允许用户更改任务的顺序
返回到位于public/assets/js/website.js的 JavaScript 文件,并在$(document).ready回调方法末尾添加以下内容:
var $todoList = $('#todo_list');
$todoList.sortable();
$todoList.disableSelection();
现在,如果你请求一个项目视图页面,你应该能够通过拖动标签来更改任务的顺序。这是使用sortable方法完成的。disableSelection方法阻止用户在列表中选择文本,因为有时在拖动项目时可能会引起用户界面问题。
然而,顺序没有同步,所以如果你刷新网页,你的自定义顺序将会被遗忘。为了保存更改,将$todoList.sortable()替换为以下内容:
$todoList.sortable({
// The stop event is called when the user drop an item
// (when the sorting process has stopped).
'stop': function() {
// Collecting task ids from checkboxes in the
// new order.
var ids = [];
$todoList.find('input[type=checkbox]').each(function() {
ids.push($(this).data('task_id'));
});
// Sending the ordered task ids to the server.
$.post(
uriBase + 'project/change_tasks_order',
{
'project_id': $todoList.data('project_id'),
'task_ids': ids
}
);
}
});
更多信息,建议您阅读 jQuery UI 的sortable方法的官方文档api.jqueryui.com/sortable/。
我们现在必须处理发送到项目控制器change_tasks_order操作的请求。向控制器添加以下方法:
public function action_change_tasks_order() {
if (Input::is_ajax()) {
$project = Model_Project::find(
intval(Input::post('project_id'))
);
// Changing the rank property according to the
// list of ids received by the controller
$task_ids = Input::post('task_ids');
for ($i = 0; $i < count($task_ids); $i++) {
$task_id = intval($task_ids[$i]);
$project->tasks[$task_id]->rank = $i;
}
$project->save();
}
return false; // we return no content at all
}
在视图操作中,替换以下内容:
$task->rank = 0; // temporary
如下所示:
// Appending the task at the end of the to-do list
$task->rank = count($data['project']->tasks);
如果你检查任务表,当将任务拖动到新位置时,任务的排名列现在会更新。但是,如果你刷新网页,顺序仍然会丢失;这是因为我们在显示项目任务时没有对项目任务进行排序。为了做到这一点,在视图操作中替换以下内容:
if ( ! $data['project'] = Model_Project::find($id))
如下所示:
$data['project'] = Model_Project::find($id, array(
'related' => array(
'tasks' => array(
'order_by' => 'rank',
),
),
));
if ( ! $data['project'])
如前节所述,related键允许开发者在检索对象时加载关系。这不仅允许你提高你网站的性能,还允许你对关系进行排序或添加条件。你甚至可以再次添加一个related键来加载你关系的关联关系。
改进轴
应用程序中还可以添加许多功能。你可以实现它们来提高你的技能:
-
允许用户删除一个任务。这可以通过在每个任务旁边添加一个删除图标来完成。
-
添加一个仪表板,使用户能够对项目及其剩余任务有一个总体了解。
-
改进视觉界面。
-
稍微有点复杂:为多用户环境添加支持。例如,如果两个用户同时更改任务顺序会发生什么?如何防止信息丢失?
摘要
在本章中,我们已经构建了我们第一个真正的项目,并学习了使用重要的 FuelPHP 特性,如 ORM 和调试工具。你应该开始对实现简单项目感到自信。在下一章,我们将使用更高级的 FuelPHP 特性,如模块和演示者。
第三章:构建博客应用程序
在前几章中我们已经看到了 FuelPHP 的基本特性,现在是时候使用更高级的特性了。在本章中,我们将构建一个典型的博客应用程序,通过一个安全的行政界面进行管理。我们将将其实现为一个模块,因为在 FuelPHP 中这是一种提高代码重用性的便捷方式。
到本章结束时,你应该知道:
-
如何生成管理界面
-
如何创建你自己的模块
-
CSRF 攻击是什么,以及如何保护你的网站免受其侵害
-
如何创建和使用任务
-
如何以及何时使用演示者
-
如何轻松创建分页
-
如何使用 slug 观察者
-
Auth和Email包是什么,以及如何使用它们
-
如何解析 Markdown
-
如何使用WYSIWYG编辑器和显示其内容
本章的目标也是巩固你获得的知识,因此实现将比通常更长、更重复。请花时间分析和理解每个部分是如何工作的,并通过调整或添加功能来尝试。
规范
首先,让我们定义我们最终应用程序中应该期望什么:
-
一个博客显示帖子。帖子由标题、小描述(作为摘要)、帖子内容、类别、发布日期和作者描述。
-
博客的主页显示帖子的分页列表。如果用户点击标题,他应该能看到帖子的完整版本。
-
点击帖子类别时,应显示一个类似列表,但只显示属于此类别的帖子。
-
帖子和类别只能由认证用户在管理界面中创建和编辑。
-
帖子的小描述长度应限制在 200 个字符内,并使用 Markdown 语法进行编辑。
-
内容应使用 WYSIWYG 插件进行编辑。
-
管理员应该能够管理评论。
-
每当有人写评论时,应向帖子的作者发送电子邮件。
-
我们希望能够轻松地将新的博客安装到其他网站上。
概念
让我们尝试根据前面的规范确定我们的模型。显然,帖子是一个模型,因为它是我们博客的主要功能(我们显示帖子)。每个帖子都是由认证用户创建和更新的,这意味着用户必须保存到数据库中;因此,我们还有一个用户模型。可能存在没有评论的帖子,以及没有帖子的类别,这意味着它们属于不同的模型;因此,还有一个类别和评论模型。
这总共是四个模型:

实体关系图(Min-Max 表示法)
-
帖子:此模型具有以下属性:标题、简短描述、内容和发布日期。帖子链接到一个唯一的类别,每个类别有许多帖子,因此我们将在这里添加一个额外的列,命名为
category_id。同样,每个帖子属于一个用户(作者),因此我们还将添加user_id列。 -
类别:此模型只有一个名称属性。
-
注释:此模型具有以下属性:名称、电子邮件、状态和内容。由于评论属于一个唯一的帖子,每个帖子可以有多个评论,因此我们还将添加一个
post_id列。当访客发表评论时,其状态值将是待审阅,因为它尚未被审查。管理员可以通过在管理面板中将状态更改为已发布或未发布来发布或隐藏每个评论。
我们将不会生成用户模型。我们将使用 Auth 包中的模型,该模型将为我们管理用户及其认证。
初步步骤
您首先需要:
-
安装一个新的 FuelPHP 实例
-
配置 Apache 和您的 hosts 文件以处理它:在本章中,我们将通过请求
http://myblog.appURL 来访问我们的应用程序。 -
如有必要,更新 Composer
-
为您的应用程序创建一个新的数据库
-
然后配置 FuelPHP 以允许您的应用程序访问此数据库
这些步骤已在第一章中介绍,构建您的第一个 FuelPHP 应用程序,因此您可能想查看它。
此项目还需要 ORM 和 Auth 包。我们已经使用了 ORM 包,如前所述,Auth 包将允许我们管理我们的用户及其认证。由于这两个包已经安装,我们只需要启用它们。为此,只需打开APPPATH/config/config.php文件,在返回数组的末尾插入以下代码:
'always_load' => array(
'packages' => array(
'orm',
'auth',
),
),
或者,您可以取消注释适当的行。这将每次加载 FuelPHP 实例时都加载ORM和Auth包。
注意
您还可以使用Package::load方法以临时方式加载包。这将在本章后面讨论,当我们使用Email包时。
我们还需要更改Auth包的一些配置项。首先,将PKGPATH/auth/config/auth.php配置文件复制到APPPATH/config/auth.php(此配置文件将覆盖Auth包中的配置文件)并替换:
'driver' => 'Simpleauth',
作者:
'driver' => 'Ormauth',
注意
我们选择使用Ormauth驱动程序的一个原因是它比Simpleauth驱动程序具有更细粒度的 ACL 系统。Ormauth更灵活,管理用户、组、角色和权限,而Simpleauth只管理用户、组和角色。另一个原因是Ormauth已经包含了管理所有这些组件的迁移和模型。简而言之,我们主要选择这个驱动程序是因为它易于设置,并展示了所有可能的功能范围。然而,重要的是要指出,我们只会使用其非常小的一部分功能,我们本可以仅限于使用Simpleauth驱动程序。
最后,将PKGPATH/auth/config/ormauth.php配置文件复制到APPPATH/config/ormauth.php,并将login_hash_salt的值设置为随机字符串(出于安全考虑)。
构建帖子脚手架
现在,我们将像在第一章和第二章中做的那样,生成必要的代码来处理我们的帖子。由于帖子应该只由认证管理员在管理面板中创建和编辑,我们将使用admin(别名admin/orm)生成脚手架:
php oil generate admin post title:string slug:string small_description:string[200] content:text category_id:int user_id:int
命令应该输出以下内容:
Creating controller: APPPATH/classes/controller/base.php
Creating controller: APPPATH/classes/controller/admin.php
Creating views: APPPATH/views/admin/template.php
Creating views: APPPATH/views/admin/dashboard.php
Creating views: APPPATH/views/admin/login.php
Creating migration: APPPATH/migrations/001_create_posts.php
Creating model: APPPATH/classes/model/post.php
Creating controller: APPPATH/classes/controller/admin/post.php
Creating view: APPPATH/views/admin/post/index.php
Creating view: APPPATH/views/admin/post/view.php
Creating view: APPPATH/views/admin/post/create.php
Creating view: APPPATH/views/admin/post/edit.php
Creating view: APPPATH/views/admin/post/_form.php
Creating view: APPPATH/views/template.php
你会注意到与scaffold/orm相比,创建了额外的文件。这些文件可以分为两大类:
-
前五个生成的文件是为了以通用方式处理管理面板(认证和布局)。
-
另外一些文件(除了最后一个),是为了专门处理帖子管理。
你可以注意到我们还没有生成分类和评论,我们稍后会回到这一点。我们现在的优先级是让管理面板工作,看看我们正在处理什么。
迁移第一部分
现在,执行生成的迁移文件:
php oil refine migrate
如果你请求 URL http://myblog.app/admin并尝试登录,将会抛出一个错误,因为没有处理我们用户的表。为了创建这个表(以及为Ormauth驱动程序所需的其它所有表),你必须执行Auth包迁移。这可以通过以下命令完成:
php oil refine migrate --packages=auth
oil refine migrate命令允许你指定你想要迁移的模块和包。你甚至可以选择执行所有迁移(来自你的应用程序、模块和包)的以下命令:
php oil refine migrate -all
虽然在我们的情况下这没有区别,但请注意,它将执行配置文件APPPATH/config/config.php中未定义在always_load.packages键的包的迁移。有些人可能期望这种行为,但我们认为这是一个重要的要点要强调。
管理面板
迁移执行完毕后,请求以下 URL:
http://myblog.app/admin
在迁移过程中,Auth 包创建了一个具有以下凭据的默认用户:
-
用户名:admin
-
密码:admin
如果你使用这些凭据登录,管理面板的欢迎页面将显示,如下面的截图所示:

它与默认的欢迎页面非常相似;主要区别是顶部的导航栏。正如你在 APPPATH/views/admin/template.php 生成的文件中所看到的,导航栏会自动检测 APPPATH/classes/controller/admin 文件夹中的控制器,并为它们的索引动作创建链接。由于已经生成了 Controller_Admin_Post 控制器,因此有一个指向帖子列表的链接。如果你点击它,你应该会看到一个与 scaffold/orm 生成的类似的结构化 CRUD 框架:

Auth 包
如果你现在查看你的数据库,你应该会看到许多以 users 关键字为前缀的表已经创建:
-
用户
-
用户客户端
-
用户组
-
用户组权限
-
用户组角色
-
用户元数据
-
用户权限
-
用户提供者
-
用户角色
-
用户角色权限
-
用户作用域
-
用户会话
-
用户会话作用域
-
用户用户权限
-
用户用户角色
Auth 包的 Ormauth 驱动程序管理这些表,其中一些与位于 PKGPATH/auth/classes/model/auth 的模型相关联。如前所述,该驱动程序提供的解决方案比简单的认证系统要完整得多,因为它管理用户、组、角色和权限。
需要指出的是,存在另外两个驱动程序:
-
Simpleauth,它比Ormauth简单得多,只管理用户、组和角色。 -
Opauth,允许用户使用OAuth或OpenID提供商(包括 Facebook、Twitter 或 Google)进行连接。
由于我们只会使用该包的一小部分,对其进行全面解释超出了范围。有关更多详细信息,建议您阅读官方文档fuelphp.com/docs/packages/auth/intro.html(可以通过通过 FuelPHP 网站导航到DOCS | TABLE OF CONTENTS | Auth | Introduction来访问)
修改您的管理员密码是一个好习惯,因为当前的设置(用户名和密码都设置为 admin)在将项目发布到生产服务器时将是一个重大的安全漏洞。您可以使用 Auth::change_password 方法来更改它,并且建议您阅读该方法的官方文档fuelphp.com/docs/packages/auth/ormauth/usage.html#/method_change_password(可以通过通过 FuelPHP 网站导航到DOCS | TABLE OF CONTENTS | Auth | Ormauth | Usage来访问)。
我们建议您在Oil的控制台或迁移文件中执行此方法(如果您想将更改传播到其他实例,这样做更好)。
您也可以通过使用Auth::create_user方法添加新用户。然而,请注意,从长远来看,创建或使用用户管理系统可能是一个好主意。
创建博客模块
通过创建帖子管理界面,我们现在已经完成了项目的第一步。在生成和实施我们的其他功能之前,重要的是要记住,一个额外的目标是能够通过重用相同的代码轻松地将博客安装到其他网站上。为了做到这一点,我们将创建一个博客模块,这就是我们应该实现代码的地方。
将文件移动到博客模块
第一步是告诉 FuelPHP 在哪里查找模块。在APPPATH/config/config.php配置文件的返回数组末尾添加(或取消注释适当的行):
'module_paths' => array(
APPPATH.'modules'.DS
),
我们接下来需要创建我们的博客模块文件夹。在APPPATH/modules/blog位置创建一个文件夹,并包含以下子文件夹:
-
classes -
classes/controller -
classes/controller/admin -
classes/model -
config -
migrations -
views -
views/admin
您也可以使用以下oil命令行生成所有这些文件夹:
php oil generate module blog –folders=classes/controller/admin,classes/model,config,migrations,views/admin
下一步是将我们之前生成的文件移动到博客模块。由于这可能需要一点时间来完成(一些代码也需要更改),我们为此实现了一个开源任务。存储库可以在以下位置找到:
github.com/sdrdis/move_scaffold_to_module
要安装此任务,只需保存:
raw.githubusercontent.com/sdrdis/move_scaffold_to_module/master/movescaffoldtomodule.php
进入APPPATH/tasks/存储库。
在执行任务和移动所有文件之前,重要的是要强调,我们还将001_create_posts.php迁移文件移动到博客模块。因此,oil实用程序将考虑这个迁移文件是一个新的,并尝试执行它。我们可以让它保持原样;由于迁移在尝试创建posts表之前会检查该表是否存在,所以它将成功执行,尽管它不会做任何事情。但是,oil实用程序将保存一个001_create_posts.php迁移已在应用程序文件夹中执行的信息,所以这不是最干净的方法。由于我们现在还没有输入任何相关的帖子,让我们首先通过执行以下操作撤销此迁移:
php oil refine migrate:down
然后执行以下命令行:
php oil r moveScaffoldToModule -scaffold=post -module=blog
命令应该输出(BLOGPATH是博客模块的路径):
Creating controller: BLOGPATH/classes/controller/admin/post.php
Deleting controller: APPPATH/classes/controller/admin/post.php
Creating model: BLOGPATH/classes/model/post.php
Deleting model: APPPATH/classes/model/post.php
Creating view: BLOGPATH/views/admin/post/create.php
Creating view: BLOGPATH/views/admin/post/edit.php
Creating view: BLOGPATH/views/admin/post/index.php
Creating view: BLOGPATH/views/admin/post/view.php
Creating view: BLOGPATH/views/admin/post/_form.php
Deleting views: APPPATH/views/admin/post
Creating migration: BLOGPATH/migrations/001_create_posts.php
Deleting migration: APPPATH/migrations/001_create_posts.php
注意
这个任务是为了在实施此项目时使您的生活更轻松。请注意,它假设代码是直接从oil实用程序生成的,并且您没有对其进行任何修改。它当然可以改进。
希望在 FuelPHP 1.8 中不再需要它,因为 --module 选项可能会在 oil generate scaffold 和 oil generate admin 命令中实现,允许开发者直接在模块内生成脚手架。
现在,让我们在博客模块中执行迁移文件:
php oil refine migrate --modules=blog
改进导航栏
你可能已经注意到,尽管我们的帖子管理面板可以通过请求以下 URL 访问:
http://myblog.app/blog/admin/post
它不再出现在上方的导航栏中。如果我们查看位于 APPPATH/views/admin/template.php 的管理模板,我们可以看到那些链接是由以下代码生成的:
<?php
$files = new GlobIterator(APPPATH.'classes/controller/admin/*.php');
foreach($files as $file)
{
$section_segment = $file->getBasename('.php');
$section_title = Inflector::humanize($section_segment);
?>
<li class="<?php echo Uri::segment(2) == $section_segment ? 'active' : '' ?>">
<?php echo Html::anchor('admin/'.$section_segment, $section_title) ?>
</li>
<?php
}
?>
如你所见,链接目前是根据位于 APPPATH/classes/controller/admin/ 的文件创建的。然而,我们希望支持通过在每个模块的 classes/controller/admin 子目录中查找文件来支持模块。为此,将此代码替换为以下代码:
<?php
// Get the navigation bar's links from an helper. We moved
// the code there because it is a bit long.
$links = Helper::get_navigation_bar_links();
foreach ($links as $link) {
// A link will be active if the current url starts with
// its url. For instance, we want the post link to be
// active when requesting these urls:
// http://myblog.app/blog/admin/post
// http://myblog.app/blog/admin/post/create
// http://myblog.app/blog/admin/post/view/1
// ...
$active = Str::starts_with(
Uri::current(),
Uri::base().$link['url']
);
?>
<li class="<?php echo $active ? 'active' : '' ?>">
<?php echo Html::anchor(
$link['url'],
$link['title']
) ?>
</li>
<?php
}
?>
在 APPPATH/classes/helper.php 位置创建辅助器,并添加以下内容(阅读注释以获取更多信息):
<?php
class Helper {
static function get_navigation_bar_links() {
// This method will return a list of links. Each
// link will contain a title and a url.
$links = array();
// For all admin controllers of our application
$files = new GlobIterator(
APPPATH.'classes/controller/admin/*.php'
);
foreach($files as $file)
{
// Url and title are deducted from the file
// basename
$section_segment = $file->getBasename('.php');
$links[] = array(
'title' => Inflector::humanize(
$section_segment
),
'url' => 'admin/'.$section_segment,
);
}
// Currently, only one path is defined:
// APPPATH/module. But this could to change.
$module_paths = \Config::get('module_paths');
foreach ($module_paths as $module_path) {
// For each admin controller of each module
$files = new GlobIterator(
$module_path
.
'*/classes/controller/admin/*.php'
);
foreach($files as $file)
{
// We get the module name from the path...
$exploded_path = explode(
'/',
$file->getPath()
);
$module = $exploded_path[
count($exploded_path) - 4
];
$section_segment = $file->getBasename('.php');
$links[] = array(
'title' => Inflector::humanize(
$section_segment
),
'url' => $module.'/admin/'.$section_segment,
);
}
}
return $links;
}
}
备注
注意,上述代码假设所有包含至少一个后台控制器的模块都可以被请求。
如果你刷新你的管理面板,帖子链接应该出现在上方的导航工具栏中。
这个解决方案的一个缺点是,如果你想显示导航工具栏中的 帖子 链接,你必须对每个新的项目执行相同的更改。然而,这个解决方案是通用的,因为如果你添加其他模块和后台控制器,它们的链接将自动出现。此外,如果你不使用这个解决方案,你仍然可以通过以下 URL 管理帖子:
http://myblog.app/blog/admin/post
脚手架项目的其余部分
现在帖子管理面板已经工作,并且位于博客模块中,是时候生成我们的其他模型了。
脚手架分类
让我们先处理分类模型。
生成文件
这一步相当直接;就像我们之前做的那样,我们将使用 oil 命令来生成我们的脚手架:
php oil generate admin/orm category name:string -s
注意,我们添加了 -s (s 代表跳过)参数,因为一些文件之前已经被生成,我们不希望替换它们。这个命令行应该输出:
Creating migration: APPPATH/migrations/002_create_categories.php
Creating model: APPPATH/classes/model/category.php
Creating controller: APPPATH/classes/controller/admin/category.php
Creating view: APPPATH/views/admin/category/index.php
Creating view: APPPATH/views/admin/category/view.php
Creating view: APPPATH/views/admin/category/create.php
Creating view: APPPATH/views/admin/category/edit.php
Creating view: APPPATH/views/admin/category/_form.php
不要启动生成的迁移;我们首先将代码移动到我们的博客模块中。
将分类移动到博客模块
让我们使用 moveScaffoldToModule 任务将分类脚手架移动到博客模块:
php oil r moveScaffoldToModule -scaffold=category -module=blog
命令应该输出(BLOGPATH 是博客模块的路径):
Creating controller: BLOGPATH/classes/controller/admin/category.php
Deleting controller: APPPATH/classes/controller/admin/category.php
Creating model: BLOGPATH/classes/model/category.php
Deleting model: APPPATH/classes/model/category.php
Creating view: BLOGPATH/views/admin/category/create.php
Creating view: BLOGPATH/views/admin/category/edit.php
Creating view: BLOGPATH/views/admin/category/index.php
Creating view: BLOGPATH/views/admin/category/view.php
Creating view: BLOGPATH/views/admin/category/_form.php
Deleting views: APPPATH/views/admin/category
Creating migration: BLOGPATH/migrations/002_create_categories.php
Deleting migration: APPPATH/migrations/002_create_categories.php
迁移
现在我们只需执行我们的迁移文件。为此,输入以下命令行:
php oil refine migrate --modules=blog
如果你访问你的管理面板,你现在应该能够管理分类。
脚手架注释
这一节与上一节相当相似。首先,生成脚手架:
php oil generate admin/orm comment name:string email:string content:text status:string post_id:integer -s
此命令应该输出以下内容:
Creating migration: APPPATH/migrations/002_create_comments.php
Creating model: APPPATH/classes/model/comment.php
Creating controller: APPPATH/classes/controller/admin/comment.php
Creating view: APPPATH/views/admin/comment/index.php
Creating view: APPPATH/views/admin/comment/view.php
Creating view: APPPATH/views/admin/comment/create.php
Creating view: APPPATH/views/admin/comment/edit.php
Creating view: APPPATH/views/admin/comment/_form.php
然后,将脚手架移动到博客模块:
php oil r moveScaffoldToModule -scaffold=comment -module=blog
此命令应输出以下内容:
Creating controller: BLOGPATH/classes/controller/admin/comment.php
Deleting controller: APPPATH/classes/controller/admin/comment.php
Creating model: BLOGPATH/classes/model/comment.php
Deleting model: APPPATH/classes/model/comment.php
Creating view: BLOGPATH/views/admin/comment/create.php
Creating view: BLOGPATH/views/admin/comment/edit.php
Creating view: BLOGPATH/views/admin/comment/index.php
Creating view: BLOGPATH/views/admin/comment/view.php
Creating view: BLOGPATH/views/admin/comment/_form.php
Deleting views: APPPATH/views/admin/comment
Creating migration: BLOGPATH/migrations/003_create_comments.php
Deleting migration: APPPATH/migrations/002_create_comments.php
在启动迁移文件之前,我们将通过将status列类型更改为ENUM来改进它,因为只有三个可能的值:not_published、pending和published。为此,编辑BLOGPATH/migrations/003_create_comments.php文件并替换以下行:
'status' => array('constraint' => 11, 'type' => 'int'),
通过:
'status' => array(
'constraint' => "'not_published','pending','published'",
'type' => 'enum',
'default' => 'pending'
),
最后,使用 oil 启动迁移文件:
php oil refine migrate --modules=blog
现在应该在管理界面中可以管理评论。
前端帖子脚手架
为了有一个起点,我们将为前端生成帖子脚手架。当然,我们会大量修改控制器,因为我们不希望访客编辑和创建帖子。
注意
在进行任何操作之前,请检查在APPPATH/views/template.php(在我写的时候,oil generate admin/orm似乎在该位置生成了一个错误的文件)。如果是这种情况,请删除该文件:稍后 oil 会重新生成它。
输入以下命令:
php oil generate scaffold/orm post title:string slug:string small_description:string[200] content:text category_id:int user_id:int
应该输出:
Creating migration: APPPATH/migrations/002_create_posts.php
Creating model: APPPATH/classes/model/post.php
Creating controller: APPPATH/classes/controller/post.php
Creating view: APPPATH/views/post/index.php
Creating view: APPPATH/views/post/view.php
Creating view: APPPATH/views/post/create.php
Creating view: APPPATH/views/post/edit.php
Creating view: APPPATH/views/post/_form.php
现在通过输入以下命令将脚手架移动到博客模块:
php oil r moveScaffoldToModule -scaffold=post -module=blog
这应该打印以下输出:
Creating controller: BLOGPATH/classes/controller/post.php
Deleting controller: APPPATH/classes/controller/post.php
Deleting model: APPPATH/classes/model/post.php
Creating view: BLOGPATH/views/post/create.php
Creating view: BLOGPATH/views/post/edit.php
Creating view: BLOGPATH/views/post/index.php
Creating view: BLOGPATH/views/post/view.php
Creating view: BLOGPATH/views/post/_form.php
Deleting views: APPPATH/views/post
Deleting migration: APPPATH/migrations/002_create_posts.php
注意,由于与类似文件名迁移文件已经在博客模块中,任务只是简单地删除了应用程序目录中的那个(而没有将其复制到博客模块中)。这是预期的行为,因为创建帖子表的迁移已经在模块中存在。
您应该可以通过请求以下 URL 来访问脚手架:
http://myblog.app/blog/post
精炼管理面板
现在所有脚手架都已创建,是时候精炼我们的管理面板了:
-
由于分类是非常简单的模型(它们只有一个
name属性),所以视图链接不会比列表提供更多信息,因此我们将移除它。我们还将显示与每个分类关联的帖子数量;这将给我们一个关于最常用分类的印象。 -
我们不需要在管理面板中创建新的评论,因此我们需要移除相关的链接和操作。我们还需要在编辑表单和列表中进行一些改进。
-
对于帖子也是如此;在列出帖子时,我们将移除大多数列,我们将在帖子创建和编辑表单中添加一个所见即所得编辑器、一个 Markdown 编辑器和分类选择框。
注意,可能会有很多其他的改进。建议您添加您认为必要的更改。
精炼帖子管理面板
让我们从帖子管理面板开始。您可能想添加一些分类以供测试。请注意,您应该在每个部分的末尾再次测试您的应用程序。
改进帖子创建和编辑表单
我们将从创建/编辑表单开始。我们已经生成了它,正如我们在前面的章节中看到的,管理此表单的视图可以在以下位置找到:BLOGPATH/views/admin/post/_form.php。
删除并自动填充别名
slug 属性应仅依赖于标题,它将用于 URL 中以提高 SEO。其值将自动从标题中填充,因此我们不需要在表单中包含其相关字段。因此,移除带有 form-group 类的第二个 div 以及其内容(其中包含 slug 输入)。
为了自动填充其值,我们将使用观察者(如 created_at 和 updated_at 列);Orm\Observer_Slug。在模型实例中,此观察者将属性值保存到第二个属性中的 slug 版本。在默认情况下,没有任何额外配置,它将取 title 的值并将其 slug 版本保存到 slug。这正是我们的情况,所以它将非常简单,但建议您阅读官方文档以获取更多信息:
fuelphp.com/docs/packages/orm/observers/included.html#os_slug
(可以通过 FuelPHP 网站访问,通过导航到 DOCS | 目录 | ORM | 观察者+ | 包含的观察者)
打开位于 BLOGPATH/classes/model/post.php 的 Post 模型,并在 $_observers 属性的末尾添加以下内容:
'Orm\\Observer_Slug',
最后,我们必须移除所有与 Slug 字段处理相关的元素。
首先,在 Post 模型的 validate 方法中,移除:
$val->add_field('slug', 'Slug', 'required|max_length[255]');
然后,打开位于 BLOGPATH/classes/controller/admin/post.php 的 Post 控制器,并移除:
'slug' => Input::post('slug'),
并且:
$post->slug = Input::post('slug');
并且:
$post->slug = $val->validated('slug');
将简短描述输入框改为 textarea
我们希望将简短描述输入框改为 textarea,因为尽管其长度限制为 200 个字符,但标准输入并不友好。替换为:
<?php echo Form::input('small_description', ... ); ?>
由:
<?php
echo Form::textarea(
'small_description',
Input::post(
'small_description',
isset($post) ? $post->small_description : ''
),
array(
'class' => 'col-md-4 form-control',
'placeholder' => 'Small description',
'rows' => 4,
'maxlength' => 200,
)
);
?>
我们希望使用 Markdown 语法编写内容(如果您不熟悉,可以查看 en.wikipedia.org/wiki/Markdown),并在我们的前端显示格式化后的简短描述,但我们现在不需要更改任何其他内容,因为此格式化过程将在我们的前端视图中发生。尽管如此,您可以在这里添加一个 JavaScript Markdown 插件,使这个 textarea 更加用户友好。
使用 WYSIWYG 编辑器编辑帖子内容
下一个表单项是内容,我们希望使用 WYSIWYG 编辑器来编辑它。我们只需要添加一个 JavaScript 插件。我们将使用 TinyMCE,这是一个知名的开放源代码 WYSIWYG 编辑器。
首先,您需要包含 TinyMCE JavaScript 文件。打开位于 APPPATH/views/admin/template.php 的模板文件,并添加:
'//tinymce.cachefly.net/4.1/tinymce.min.js'
在 Asset::js 首个数组参数的末尾。
注意
注意,我们使用了在本书编写时 TinyMCE 推荐的 CDN 上托管的 JavaScript 文件。根据您阅读本书的时间和您的需求,您可能希望使用不同的 URL 或在您的服务器上托管 TinyMCE。
接下来,我们需要指定 TinyMCE 哪个 textarea 需要转换为 WYSIWYG。在同一个模板中,在第一个 script 标签的末尾添加以下内容:
// Transforms textareas with the wysiwyg class to wysiwygs
tinymce.init({selector:'textarea.wysiwyg'});
最后,我们需要将 wysiwyg 类添加到我们的内容 textarea 中。返回位于 APPPATH/views/admin/post/_form.php 的文件,搜索 Form::textarea('content' 并在这次方法调用中替换:
'class' => 'col-md-8 form-control'
作者:
'class' => 'col-md-8 form-control wysiwyg'
用选择框替换分类输入
表单中的下一个项目是 分类 id。手动设置分类 id 对管理员来说不友好;最好的做法是显示一个选择框,以便可以通过标题选择分类。
首先,在 BLOGPATH/views/admin/category/selector.php 创建一个视图文件,并添加以下内容:
<?php
/*
Loading the list of all categories here, since it doesn't
depend on the post being created / edited. (Temporary)
*/
$categories = \Blog\Model_Category::find('all');
$options = array();
foreach ($categories as $category) {
$options[$category->id] = $category->name;
}
echo Form::select('category_id', $category_id, $options);
然后,回到 BLOGPATH/views/admin/post/_form.php 视图文件,通过替换以下内容来修复分类字段标题:
Form::label('Category id', 'category_id'
修改内容:
Form::label('Category', 'category_id'
然后通过以下方式包含我们的选择框:
<?php echo Form::input('category_id', ... ) ?>
修改内容:
<div>
<?php
$select_box = \View::forge('admin/category/selector');
// Other way to set a view parameter; sets the $category_id
// variable.
$select_box->set(
'category_id',
Input::post(
'category_id',
isset($post) ? $post->category_id : null
)
);
echo $select_box;
?>
</div>
如果您测试表单,选择框应该可以正常工作。但存在一个小问题;当我们创建选择视图时,我们在视图中加载了分类列表。这不符合 MVC 模式,因为我们正在视图中加载模型。但是,将这些对象加载到 Post 控制器中也没有意义,因为视图实际上不依赖于任何文章;我们总是加载所有分类,无论上下文如何。正如之前在 第一章 中所述,“构建您的第一个 FuelPHP 应用程序”,在这种情况下我们应该使用一个展示者。幸运的是,我们不需要做很多修改。
首先,在 BLOGPATH/classes/presenter/admin/category/selector.php 创建展示者文件,并添加以下内容:
<?php
namespace Blog;
class Presenter_Admin_Category_Selector extends \Presenter
{
public function view()
{
$this->categories = Model_Category::find('all');
}
}
然后,编辑 BLOGPATH/views/admin/post/_form.php 视图文件,替换以下行:
$select_box = \View::forge('admin/category/selector');
通过:
$select_box = \Presenter::forge('admin/category/selector');
最后,编辑 BLOGPATH/views/admin/category/selector.php 视图并删除以下行:
$categories = \Blog\Model_Category::find('all');
虽然我们不会立即需要它们,但我们将添加文章和分类模型之间的关系。由于每篇文章只能有一个分类,而每个分类可以与多个文章相关联,因此文章和分类之间存在 belongs_to 关系,分类和文章之间存在 has_many 关系。
首先,打开位于 BLOGPATH/classes/model/post.php 的 Post 模型,并在类中添加以下代码:
protected static $_belongs_to = array('category');
然后,打开位于 BLOGPATH/classes/model/category.php 的 Category 模型,并在类中添加以下代码:
protected static $_has_many = array('posts');
将 user_id 字段替换为作者
我们表中的最后一个字段是 user_id 字段。我们将用作者字段替换此字段。该字段不可编辑;文章的作者将简单地是创建它的认证用户。
我们首先需要添加文章和用户之间的关系;由于每篇文章只能与一个用户相关联,而用户可以有任意数量的文章,因此关系类型是 belongs_to。
打开位于BLOGPATH/classes/model/post.php的帖子模型,并在$_belongs_to数组末尾添加以下内容:
'author' => array(
'model_to' => 'Auth\Model\Auth_User',
'key_from' => 'user_id',
'key_to' => 'id',
'cascade_save' => true,
'cascade_delete' => false,
),
接下来,我们将更改在创建/编辑表单中显示字段的方式。打开BLOGPATH/views/admin/post/_form.php,并首先替换以下内容:
<?php echo Form::label('User id', ... ); ?>
By:
<?php echo Form::label('Author'); ?>
然后替换为:
<?php echo Form::input('user_id', ... ); ?>
By:
<div>
<?php
/*
This field is not editable, so we simply display the author.
current_user is a global variable that defines the current
logged user.
*/
$author = isset($post) ? $post->author : $current_user;
echo $author->username;
?>
</div>
最后,我们需要让帖子控制器反映这种行为。为此,我们首先更改创建和编辑操作中user_id属性的保存方式。打开位于BLOGPATH/classes/controller/admin/post.php的帖子控制器,并在创建操作内部替换以下内容:
'user_id' => Input::post('user_id'),
By:
'user_id' => $this->current_user->id,
在编辑操作中,只需简单地删除以下行:
$post->user_id = Input::post('user_id');
以及:
$post->user_id = $val->validated('user_id');
尽管,你现在仍然无法创建新的帖子,因为会出现以下消息:用户 ID 字段是必需的,并且必须包含值。这是由于帖子模型的validate方法导致的。接下来要做的就是移除user_id验证。打开位于BLOGPATH/classes/model/post.php的帖子模型,并在validate方法内部移除以下行:
$val->add_field('user_id', ... );
移除查看链接
由于我们不想保留帖子的详细视图,我们可以移除查看链接。打开BLOGPATH/views/admin/post/edit.php并移除以下代码:
<?php echo Html::anchor(..., 'View'); ?> |
帖子列表
如果你测试了改进后用于创建新帖子的表单,你可能已经注意到列表并不很好地适应。
移除别名、简短描述和内容列
第一个问题是我们显示了别名、简短描述和内容列,尽管它们的值长度可能很重要。由于这可能会对表格布局产生严重影响,我们不得不移除它们。打开位于BLOGPATH/views/admin/post/index.php的列表视图,并移除以下行:
<th>Slug</th>
<th>Small description</th>
<th>Content</th>
以及:
<td><?php echo $item->slug; ?></td>
<td><?php echo $item->small_description; ?></td>
<td><?php echo $item->content; ?></td>
显示分类和作者名称
第二个问题是我们在显示分类和用户的 ID,尽管显示它们关联的名称会更方便。
首先,相应地更改表格标题,通过替换:
<th>Category id</th>
By:
<th>Category</th>
以及以下行:
<th>User id</th>
By:
<th>Author</th>
并将每一行值通过替换:
<td><?php echo $item->category_id; ?></td>
By:
<td><?php echo $item->category->name; ?></td>
以及以下行:
<td><?php echo $item->user_id; ?></td>
By:
<td><?php echo $item->author->username; ?></td>
你可以保留代码不变,因为正确的信息将出现在列表中。但如果你激活了性能分析器,你会注意到如果你有多个帖子,将会执行很多 SQL 请求。正如我们之前看到的,这是因为我们在调用$item->category和$item->author,并且如果没有缓存,每次调用都会执行一个 SQL 请求。为了优化请求的数量,我们将使用related键。打开位于BLOGPATH/classes/controller/post.php的帖子控制器,并在索引操作内部替换以下行:
$data['posts'] = Model_Post::find('all');
By:
$data['posts'] = Model_Post::find(
'all',
array(
'related' => array(
'category',
'author',
),
)
);
移除视图链接
由于我们正在实现管理面板,我们可以将代码缩减到严格必要的部分。由于我们在编辑文章时可以访问文章信息,因此文章编辑和可视化是多余的。因此,我们将删除 视图 链接。只需删除以下行:
<?php echo Html::anchor(..., 'View'); ?> |
最好在 文章 控制器中以及位于 BLOGPATH/admin/post/view.php 的视图中删除 视图 操作,因为它们现在是无用的代码。
精炼分类管理面板
现在,让我们专注于分类管理面板。分类 模型相当简单,所以没有太多的事情要做。实际上,我们几乎只会更改列表页面。
删除视图链接
由于模型只有一个已经显示在列表中的属性,因此视图链接和页面并不太有用。首先,通过删除以下内容来删除位于 BLOGPATH/views/admin/category/index.php 中的 视图 链接:
<?php echo Html::anchor(..., 'View'); ?> |
然后,您可以删除 分类 控制器中的 视图 操作以及位于 BLOGPATH/views/admin/category/view.php 的视图,因为它们现在是无用的代码。
我们还必须删除编辑表单中的 视图 链接。打开 BLOGPATH/views/admin/category/edit.php 并删除以下代码:
<?php echo Html::anchor(..., 'View'); ?> |
添加文章数量列
本节的一个挑战是显示每个分类有多少篇文章。这并不简单,也没有理想的解决方案。
让我们先在我们的表中添加我们的列。在以下位置:
<th>Name</th>
添加:
<th>Number of posts</th>
在以下位置:
<td><?php echo $item->name; ?></td>
添加:
<td><?php /* Depends on solution */ ?></td>
现在,让我们测试不同的选项。
解决方案 1:使用 count
第一个解决方案相当直接;我们使用 count 方法。替换:
<td><?php /* Depends on solution */ ?></td>
通过:
<td>
<?php
echo \Blog\Model_Post::count(
array(
'where' => array(
array('category_id' => $item->id)
)
)
);
?>
</td>
虽然解决方案很简单,但存在一些主要缺点。首先,它不遵循 MVC 模式。其次,它将为每个显示的分类生成一个请求。如果您有很多分类,请不要使用此方法。
解决方案 2:使用 related
另一种解决方案是使用 related 键。首先,打开位于 BLOGPATH/classes/controller/admin/category.php 的 分类 控制器,并在索引操作中替换以下行:
$data['categories'] = Model_Category::find('all');
通过:
$data['categories'] = Model_Category::find(
'all',
array(
'related' => array(
'posts',
),
)
);
然后回到 BLOGPATH/views/admin/category/index.php 视图,替换:
<td><?php /* Depends on solution */ ?></td>
通过:
<td><?php echo count($item->posts); ?></td>
一方面,这个解决方案限制了请求数量,但另一方面,它可能会将大量的无用文章实例加载到内存中,所以这也不是理想的。如果您有很多文章,请不要使用此方法。
解决方案 3:使用 DB::query
另一种解决方案是使用 DB::query 加载分类。首先,打开位于 BLOGPATH/classes/controller/admin/category.php 的 分类 控制器,并在索引操作中替换以下行:
$data['categories'] = Model_Category::find('all');
通过:
$data['categories'] = Model_Category::find_all_with_nb_posts();
然后在 分类 模型中添加以下方法:
public static function find_all_with_nb_posts() {
return \DB::query(
'SELECT
`categories`.*,
count(`posts`.`id`) as nb_posts
FROM `categories`
LEFT JOIN `posts` ON (
`posts`.`category_id` = `categories`.`id`
)
GROUP BY `categories`.id'
)
->as_object('\Blog\Model_Category')
->execute()
->as_array();
}
注意
由于 as_object 方法,我们可以执行自定义查询并将结果转换为模型实例。在这个请求中,我们添加了一个自定义列,nb_posts,它计算每个类别的帖子数量。这个列在我们的类别实例下可通过 nb_posts 属性访问。
然后回到 BLOGPATH/views/admin/category/index.php 视图,替换:
<td><?php /* Depends on solution */ ?></td>
通过:
<td><?php echo $item->nb_posts ?></td>
这个解决方案因其性能而有趣:没有额外的查询或内存使用。它的缺点是它不使用 ORM,并且这个解决方案对于更复杂的问题可能难以实现。
对于这个实例,我们推荐这个解决方案。
精炼评论管理面板
我们还需要在这里做一些调整。建议你现在手动添加一些评论,因为我们改变界面后你将无法再添加(通过管理界面添加评论没有意义,因为任何用户都可以在网站上这样做)。
改进评论列表
首先,我们将改进评论列表。
移除查看并添加新的评论链接
由于我们不需要这些功能,我们将移除它们的链接、动作和视图。
首先,打开 BLOGPATH/views/admin/comment/index.php 视图文件并移除:
<?php echo Html::anchor(..., 'View'); ?> |
并且:
<?php echo Html::anchor(..., 'Add new Comment', ...); ?>
你还被建议移除 Comment 控制器的创建和查看动作,以及 BLOGPATH/views/admin/comment/create.php 和 BLOGPATH/views/admin/comment/view.php 文件。
移除电子邮件和内容列
我们将移除这两个列,因为它们可能占用太多空间。为此,打开 BLOGPATH/views/admin/comment/index.php 并移除以下行:
<th>Email</th>
<th>Content</th>
并且:
<td><?php echo $item->email; ?></td>
<td><?php echo $item->content; ?></td>
将帖子 id 列替换为帖子
了解评论所关联的帖子的标题,而不是帖子的 id,会更方便。
首先,替换:
<th>Post id</th>
通过:
<th>Post</th>
然后替换:
<td><?php echo $item->post_id; ?></td>
通过:
<td>
<?php
echo $item->post ? $item->post->title : '<i>Post deleted</i>';
?>
</td>
但是,如果我们想让它工作,我们必须定义帖子与评论之间的关系。打开位于 BLOGPATH/classes/model/post.php 的 Post 模型,并添加以下属性:
protected static $_has_many = array('comments');
然后打开位于 BLOGPATH/classes/model/comment.php 的 Comment 模型,并添加以下属性:
protected static $_belongs_to = array('post');
现在,你能够再次显示列表。但是,你可能注意到,如果你有多个评论,会执行很多请求。再次,我们需要使用 related 键来防止这种情况。打开位于 BLOGPATH/classes/controller/admin/comment.php 的 Comment 控制器,并在 index 动作中替换:
$data['comments'] = Model_Comment::find('all');
通过:
$data['comments'] = Model_Comment::find(
'all',
array(
'related' => array('post'),
// display last comments first
'order_by' => array('id' => 'DESC'),
)
);
改进评论编辑表单
我们将在评论编辑表单中改进两个字段;状态 和 帖子 id。
将状态输入更改为选择框
由于只有三种可能的状态,我们将用选择框替换输入。打开位于 BLOGPATH/views/admin/comment/_form.php 的表单,并替换:
<?php echo Form::input('status', ...); ?>
通过:
<div>
<?php
echo Form::select(
'status',
$comment->status,
array(
'not_published' => 'not_published',
'pending' => 'pending',
'published' => 'published',
)
);
?>
</div>
将帖子 id 替换为帖子
再次,对于管理员来说,显示帖子的 id 并不重要;最好显示帖子的标题。
首先,替换:
<?php echo Form::label('Post id', ...); ?>
由:
<?php
echo Form::label(
'Post',
null, // No associated input
array('class' => 'control-label')
);
?>
然后替换:
<?php echo Form::input('post_id', ...); ?>
由:
<div><?php echo $comment->post ? $comment->post->title : '<i>Post deleted</i>'; ?></div>
我们需要防止在处理表单时post_id属性发生任何变化。打开评论控制器,并在action_edit方法内部删除以下行:
$comment->post_id = Input::post('post_id');
然后:
$comment->post_id = $val->validated('post_id');
最后,我们需要删除post_id验证。打开评论模型并删除以下行:
$val->add_field('post_id', ...);
删除“查看”链接
由于没有视图操作了,我们必须删除查看链接。打开BLOGPATH/views/admin/comment/edit.php并删除以下代码:
<?php echo Html::anchor(..., 'View'); ?> |
保护您的网站免受 CSRF 攻击
您当然希望防止黑客更改您网站的内容,因为后果可能是灾难性的。尽管只要您是唯一可以访问您自己实施的行政面板的人,风险是有限的,但您可能仍想保护您的网站免受跨站请求伪造(CSRF)攻击。
CSRF 攻击基于网站对用户浏览器的信任。让我们用一个例子来说明这些攻击。假设您登录了您的管理界面。如果您稍后访问另一个网站上的网页,该网页包含以下代码:
<html>
<head>
<title>My attack</title>
</head>
<body>
<img src="img/1" />
</body>
</html>
在您的网站上,帖子控制器的删除操作将被调用,并且将删除id = 1的帖子(如果存在),而无需您的批准或任何通知。创建网页的黑客通过利用您已登录到管理面板的事实,已经成功地通过 CSRF 攻击取得了成功。这是因为您的操作没有验证请求是否合法。更高级的攻击甚至可以提交表单,然后您可能会发现自己网站上出现了不希望的内容。
幸运的是,FuelPHP 允许您通过在链接或表单中包含安全令牌来轻松保护您的网站。该安全令牌在调用操作时进行检查。这个过程确保客户端是从网站请求操作,而不是从其他地方。
保护链接
首先,让我们保护帖子列表中的删除链接。
打开BLOGPATH/views/admin/post/index.php视图文件并替换:
'blog/admin/post/delete/'.$item->id
由:
'blog/admin/post/delete/'.$item->id.
'?'.\Config::get('security.csrf_token_key').
'='.\Security::fetch_token()
如果您刷新网页,删除链接现在应该指向一个类似以下 URL 的地址:
http://myblog.app/blog/admin/post/delete/ID?fuel_csrf_token=215be7bad7eb4999148a22341466f66395ce483d12b17cae463b7bf4b6d6d86233ce38ce6b145c08bf994e56610c1502158b32eca6f6d599a5bb3527d019c324
现在我们通过 CSRF 令牌作为get参数调用帖子控制器的删除操作,我们只需在删除帖子之前检查其值是否正确。为了做到这一点,打开帖子控制器,并在删除操作内部替换:
if ($post = Model_Post::find($id))
由:
if (($post = Model_Post::find($id)) and \Security::check_token())
您的删除操作现在已受保护。您应该对类别和评论管理界面的删除链接做同样的处理。一般来说,甚至建议将此保护添加到任何执行重要或关键操作的链接。
保护表单
我们现在将使用一个非常类似的技术来保护我们的帖子创建和编辑表单。首先,打开BLOGPATH/views/admin/post/_form.php视图文件,并添加:
<?php echo Form::csrf(); ?>
在以下代码之后:
<?php echo Form::open(array("class"=>"form-horizontal")); ?>
Form::csrf方法将自动向您的表单添加一个包含令牌的隐藏输入。如果您显示帖子创建或编辑网页的 HTML 代码,您应该看到该方法返回了一个类似于以下字符串:
<input name="fuel_csrf_token" value="2411b0a6b942105fb80aa0cb1aaf89ca91e0ea715f5641bbfbb5ded23221fcecbbfe7016c8dbd922a19b12274989e67f71d266300ad14ebd9730c3ec604ec4f5" type="hidden" id="form_fuel_csrf_token" />
现在,让我们在修改数据库之前检查此令牌是否正确。
打开帖子控制器,并在创建操作中替换:
if ($post and $post->save())
通过:
if (\Security::check_token() and $post and $post->save())
在编辑操作中,替换:
if ($post->save())
通过:
if (\Security::check_token() && $post->save())
为了本节的简洁性,当令牌没有预期值时,我们不显示特殊错误消息,但建议您添加此功能。
不管怎样,您的帖子创建和编辑表单现在也得到了保护。您应该对分类和评论管理界面的创建和编辑表单做同样的处理。一般来说,甚至建议将此保护添加到所有表单中。
精炼前端
现在,我们必须精炼我们网站的前端,也就是说访客将看到的内容。
精炼帖子列表
如果您请求以下 URL:
http://myblog.app/blog/post
您将看到我们之前使用scaffold/orm生成的脚手架。
删除无用功能
首要重要的事情是防止对我们的帖子进行任何编辑。正如我们多次为管理面板所做的那样,移除帖子控制器的创建、编辑和删除操作及其相关视图。请注意,这里我们谈论的是位于BLOGPATH/classes/controller/post.php的帖子控制器,因为我们正在处理网站的前端。您也可以删除BLOGPATH/views/admin/post/_form.php视图文件,因为它只从创建和编辑视图中被调用。
改变帖子列表的显示方式
目前,帖子列表以表格形式显示,而对我们博客来说,我们希望以更线性的方式显示列表,就像大多数博客那样显示。
最简单的方法是将位于BLOGPATH/post/index.php的视图替换为:
<?php if ($posts): ?>
<?php foreach ($posts as $item): ?>
<div class="post" id="post_<?php echo $item->id; ?>">
<h2>
<?php
echo Html::anchor('blog/post/view/'.$item->id, $item->title);
?>
</h2>
<?php
/*
As we will display the same information when visualizing a
post, we will implement different views in order
to easily reuse them later in BLOGPATH/views/post/view.php
*/
echo \View::forge(
'post/small_description',
array('post' => $item)
);
echo \View::forge(
'post/additional_informations',
array('post' => $item)
);
?>
</div>
<?php endforeach; ?>
<?php else: ?>
<p>No Posts.</p>
<?php endif; ?>
由于我们在分离的视图中显示附加内容(见注释),我们需要创建这些视图。创建BLOGPATH/views/post/small_description.php视图文件,并按以下内容设置其内容:
<div class="post_small_description">
<?php
echo \Markdown::parse($post->small_description)
?>
</div>
并创建BLOGPATH/views/post/additional_informations.php视图文件,并按以下内容设置其内容:
<div class="post_date">
<?php
echo \Date::forge($post->created_at)->format('us_full');
?>
</div>
<div class="post_category">
Category:
<?php echo $post->category->name ?>
</div>
<div class="post_author">
By
<?php echo $post->author->username ?>
</div>
最后,为了优化请求数量,打开帖子控制器(前端控制器),并替换:
$data['posts'] = Model_Post::find('all');
通过:
$data['posts'] = Model_Post::find(
'all',
array(
'related' => array(
'author',
'category',
),
)
);
添加分页
如果您添加了很多帖子,您会注意到列表变得非常长。为了防止这种行为,我们现在将添加分页功能。
在帖子控制器的索引操作开始时,添加以下代码以创建一个分页实例:
// Pagination configuration
$config = array(
'total_items' => Model_Post::count(),
'per_page' => 10,
'uri_segment' => 'page',
);
// Create a pagination instance named 'posts'
$pagination = \Pagination::forge('posts', $config);
备注
在这里,我们设置了Pagination配置的主要选项,但建议您查看官方文档,因为还有更多选项:
fuelphp.com/docs/classes/pagination.html
(可以通过通过 FuelPHP 网站导航到DOCS | Core | Pagination访问)
如果您的帖子不多,您可以降低per_page值以测试分页。
现在我们检索帖子时,必须考虑分页。替换以下内容:
$data['posts'] = Model_Post::find(...);
通过:
$data['posts'] = Model_Post::find(
'all',
array(
'related' => array(
'author',
'category',
),
'rows_offset' => $pagination->offset,
'rows_limit' => $pagination->per_page,
)
);
我们需要将创建的分页实例传递给我们的视图以便显示。在操作末尾添加以下代码:
$this->template->content->set('pagination', $pagination);
这将产生与在$data参数内部设置pagination键相同的效果。
打开BLOGPATH/views/post/index.php视图文件,并在以下位置:
<?php endforeach; ?>
添加:
<?php echo $pagination; ?>
现在,如果您刷新您的列表页面并且有足够的帖子,您将看到分页出现了,但它被转义了,也就是说,它显示了 HTML 代码。这是因为视图参数默认被转义,我们没有通知 FuelPHP 不要转义pagination参数。再次打开Post控制器,并在索引操作中替换以下内容:
$this->template->content->set('pagination', $pagination);
通过:
$this->template->content->set('pagination', $pagination, false);
使用帖子的 slug
如果您显示列表,一切看起来都应该是好的。但如果您点击一篇文章的标题,视图页面将显示,但 URL 看起来像这样:
http://myblog.app/blog/post/view/1
这对于 SEO 来说并不好,因为我们没有使用之前创建的 slug。为了解决这个问题,首先打开BLOGPATH/views/post/index.php视图文件,并替换以下内容:
echo Html::anchor('blog/post/view/'.$item->id, $item->title);
通过:
echo Html::anchor(
'blog/post/view/'.$item->slug,
$item->title
);
现在链接已经指向正确的 URL,视图操作必须处理这种新的行为。打开Post控制器,并首先替换以下行:
public function action_view($id = null)
通过:
public function action_view($slug = null)
然后替换视图操作的内容为:
is_null($slug) and Response::redirect('blog/post');
$data['post'] = Model_Post::find(
'first',
array(
'where' => array(
array('slug' => $slug),
),
)
);
if ( ! $data['post'])
{
Session::set_flash(
'error',
'Could not find post with slug: '.$slug
);
Response::redirect('blog/post');
}
$this->template->title = "Post";
$this->template->content = View::forge('post/view', $data);
按类别列出帖子
一个有趣的功能是列出属于每个类别的帖子。例如,如果我们请求以下 URL:
http://myblog.app/blog/post/category/1
我们希望显示属于id = 1类别的帖子。
注意
最好的方法就是使用 slug,就像我们为帖子所做的那样。我们没有在这个章节中实现它,但建议您这样做。
首先,打开BLOGPATH/views/post/additional_informations.php并替换以下内容:
<?php echo $post->category->name ?>
通过:
<?php
echo Html::anchor(
'blog/post/category/'.$post->category->id,
$post->category->name
);
?>
如果您这么想,显示的分类帖子列表与没有过滤分类的列表相似。视图和请求都是相似的。
我们可以在Post控制器内部编写一个分类操作,在这种情况下,索引和分类操作可以调用一个相同的方法;这种解决方案在大多数情况下是可接受的,甚至被推荐。
但在这里我们将采取不同的方法。由于操作有很多共同点,我们将重新路由:
http://myblog.app/blog/post/category/category_id
到:
http://myblog.app/blog/post/index
并在索引操作中添加分类处理。
首先,创建并打开BLOGPATH/config/routes.php文件,并设置其内容为:
<?php
return array(
'blog/post/category/:category_id' => 'blog/post/index',
);
现在,我们必须在Post控制器中的索引动作内添加分类处理。首先,在Post控制器的索引动作中,替换:
$config = array(...);
通过:
$config = array(
'per_page' => 10,
'uri_segment' => 'page',
);
// Get the category_id route parameter
$category_id = $this->param('category_id');
if (is_null($category_id)) {
$config['total_items'] = Model_Post::count();
} else {
$config['total_items'] = Model_Post::count(
array(
'where' => array(
array('category_id' => $category_id),
),
)
);
}
然后,替换:
$data['posts'] = Model_Post::find(...);
通过:
$data['posts'] = Model_Post::query()
->related(array('author', 'category'))
->rows_offset($pagination->offset)
->rows_limit($pagination->per_page);
if (!is_null($category_id)) {
$data['posts']->where('category_id', $category_id);
}
$data['posts'] = $data['posts']->get();
您可以注意到我们在这里使用了query方法,因为它比在这种情况下使用find方法更方便。
添加索引
为了优化我们的网站,我们将在我们的表中添加一些索引。为此,创建一个位于BLOGPATH/migrations/004_create_indexes.php的迁移文件,并设置其内容为:
<?php
namespace Fuel\Migrations;
class Create_indexes
{
public function up()
{
// For optimizing relations
\DBUtil::create_index('comments', 'post_id');
\DBUtil::create_index('posts', 'category_id');
\DBUtil::create_index('posts', 'user_id');
// For optimizing slug retrieval
\DBUtil::create_index('posts', 'slug');
}
public function down()
{
\DBUtil::drop_index('comments', 'post_id');
\DBUtil::drop_index('posts', 'category_id');
\DBUtil::drop_index('posts', 'user_id');
\DBUtil::drop_index('posts', 'slug');
}
}
不要忘记执行迁移文件。
精炼帖子可视化网页
当在列表页点击帖子的标题时,您将看到可视化网页并不完美。我们需要改进其显示方式,以显示帖子的验证评论,并显示和处理评论表单。
更改帖子布局
为了改进帖子的显示方式,打开BLOGPATH/views/post/view.php视图文件,并设置以下内容:
<div class="post_view">
<h2>
<?php echo $post->title; ?>
</h2>
<?php
// Reusing views we created earlier
echo \View::forge(
'post/small_description',
array('post' => $post)
);
?>
<div class="post_content">
<?php echo $post->content; ?>
</div>
<?php
echo \View::forge(
'post/additional_informations',
array('post' => $post)
);
?>
</div>
<?php echo Html::anchor('blog/post', 'Back'); ?>
现在,如果您可视化一个包含 HTML 元素的帖子内容,您将看到它会被转义(您将看到 HTML 代码)。这是因为默认情况下,发送到视图的任何参数都会被过滤。
注意
每个参数默认过滤的方式可以在APPPATH/config/config.php配置文件中更改,使用security.output_filter键。其默认值是array('Security::htmlentities'),解释了为什么 HTML 代码会被转义。您可以将此值更改为array('Security::xss_clean')以解决这个问题,但您应该知道这可能会造成性能下降。
为了解决这个问题,在Post控制器的视图动作中添加:
$this->template->content->set(
'post_content',
$data['post']->content,
false
);
之后:
$this->template->content->set(
'post_content',
$data['post']->content,
false
);
并且,在BLOGPATH/views/post/view.php视图文件中,替换:
<?php echo $post->content; ?>
通过:
<?php echo \Security::xss_clean($post_content); ?>
当您禁用filter参数时,应谨慎行事,因为它可能会引入安全风险。由于帖子仅由管理员编辑,风险较低,但这并不能阻止我们采取额外措施。这就是为什么我们使用了Security::xss_clean方法来限制潜在问题。
注意
您可能想知道为什么我们设置帖子内容在额外的未过滤视图参数中,而不是仅仅将View::forge的filter参数设置为 false。原因是,在这种情况下,我们将发送一个完全未过滤的post对象(因为当filter设置为 true 时,所有对象的属性都会被过滤)。这将迫使我们手动转义我们在视图中显示的大部分其他属性,从而导致许多更多更改。
如果你决定在其他情况下直接在View::forge中禁用filter参数,请注意一个重要的细节;当filter参数启用时,它会转义所有传递对象的属性,因此在这个过程中改变了它们。因此,对象在设置filter参数为true的任何View::forge之后将不可逆地改变。因此,即使你在控制器中调用View::forge时将filter参数设置为false,如果你的对象属性在显示带有filter设置为true的子视图时仍然可能被转义,所以确保在这种情况下也禁用filter。
添加评论表单
我们还希望用户能够发布评论。为此,我们首先实现评论创建表单(从管理面板的表单派生)。创建BLOGPATH/views/comment/_form.php视图文件,并将其内容设置为:
<h3>Add a comment</h3>
<?php echo Form::open(array("class"=>"form-horizontal")); ?>
<fieldset>
<div class="form-group">
<?php
echo Form::label(
'Name',
'name',
array('class' => 'control-label')
);
echo Form::input(
'name',
Input::post(
'name',
isset($comment) ? $comment->name : ''
),
array(
'class' => 'col-md-4 form-control',
'placeholder' => 'Name'
)
);
?>
</div>
<div class="form-group">
<?php
echo Form::label(
'Email',
'email',
array('class' => 'control-label')
);
echo Form::input(
'email',
Input::post(
'email',
isset($comment) ? $comment->email : ''
),
array(
'class' => 'col-md-4 form-control',
'placeholder' => 'Email'
)
);
?>
</div>
<div class="form-group">
<?php
echo Form::label(
'Content',
'content',
array('class' => 'control-label')
);
echo Form::textarea(
'content',
Input::post(
'content',
isset($comment) ? $comment->content : ''
),
array(
'class' => 'col-md-8 form-control',
'rows' => 8,
'placeholder' => 'Content'
)
);
?>
</div>
<div class="form-group">
<label class='control-label'> </label>
<?php
echo Form::submit(
'submit',
'Save',
array('class' => 'btn btn-primary')
);
?>
</div>
</fieldset>
<?php echo Form::close(); ?>
如前所述,这是一个管理面板中评论表单的派生版本,除了我们移除了状态和帖子字段。现在,在BLOGPATH/views/post/view.php的末尾添加以下行以在显示帖子时显示表单:
<?php echo View::forge('comment/_form'); ?>
我们现在必须处理它。打开Post控制器,在视图操作中,在以下行之前:
$this->template->title = "Post";
添加:
// Is the user sending a comment? If yes, process it.
if (Input::method() == 'POST')
{
$val = Model_Comment::validate('create');
if ($val->run())
{
$comment = Model_Comment::forge(array(
'name' => Input::post('name'),
'email' => Input::post('email'),
'content' => Input::post('content'),
'status' => 'pending',
'post_id' => $data['post']->id,
));
if ($comment and $comment->save())
{
Session::set_flash(
'success',
e('Your comment has been saved, it will'.
' be reviewed by our administrators')
);
}
else
{
Session::set_flash(
'error',
e('Could not save comment.')
);
}
}
else
{
Session::set_flash('error', $val->error());
}
}
这是从生成的脚手架代码派生出来的,所以你没有看到任何新内容。如果你尝试验证评论表单,你会注意到status验证阻止了评论对象被保存。打开BLOGPATH/model/comment.php模型文件,并替换以下内容:
$val->add_field('status', 'Status', 'required|max_length[255]');
通过:
// We require status only if we are editing the comment (thus
// we are on the administration panel).
if ($factory == 'edit') {
$val->add_field(
'status',
'Status',
'required|max_length[255]'
);
}
显示评论
现在用户能够创建评论,展示它们会很好。一个小修正;展示那些由管理员验证过的评论会更好。我们不希望展示所有评论,而只展示那些status = published的评论。为了使我们的工作更简单,我们首先将一个关系添加到Post模型中,该关系只检索已发布的评论。打开Post模型,并在$_has_many属性末尾添加以下内容:
'published_comments' => array(
'model_to' => '\Blog\Model_Comment',
'conditions' => array(
'where' => array(
array('status' => 'published'),
),
),
),
如你所见,也可以向关系添加默认条件(和排序)。从现在起,$post->published_comments将检索status = published的帖子评论。
让我们使用这个关系来显示我们的已发布评论。打开BLOGPATH/views/post/view.php,在以下之前:
<?php echo View::forge('comment/_form'); ?>
添加:
<div class="comments">
<?php
foreach ($post->published_comments as $comment):
echo \View::forge(
'comment/item',
array('comment' => $comment)
);
endforeach;
?>
</div>
最后,创建BLOGPATH/views/comment/item.php视图文件,并将其内容设置为:
<div class="comment">
<div class="comment_content">
<?php echo $comment->content; ?>
</div>
<div class="comment_date">
<?php
echo \Date::forge($comment->created_at)->format('us_full');
?>
</div>
<div class="comment_name">
By
<?php echo $comment->name; ?>
</div>
</div>
当新评论发布时通知作者
由于评论需要管理员验证,当新评论发布时,我们将向帖子的作者发送电子邮件。
我们将使用Email包来完成这项工作。此包位于PKGPATH/email目录。您可以通过将PKGPATH/email/config/email.php复制到APPPATH/config/email.php并更改返回的数组来调整包配置文件,具体取决于您的本地配置。您至少需要设置defaults.from.email和defaults.from.name值。
您可以选择几个电子邮件驱动程序。默认驱动程序是mail,正如我们所期望的,只需简单地使用mailPHP 方法。sendmail驱动程序也常被选择,并使用开源的sendmail实用程序。smtp驱动程序通过套接字连接到电子邮件服务器。其他驱动程序,如mailgun或mandrill,允许您使用外部服务发送您的电子邮件。
您应该在以下位置阅读官方文档:
fuelphp.com/docs/packages/email/introduction.html
(可以通过导航到 FuelPHP 网站上的文档 | 目录 | 邮件 | 简介来访问)
注意
如果您想从本地系统发送电子邮件,您可能需要更改额外的配置文件,例如php.ini。您可以自由地在网上搜索更多信息,因为关于这个主题有无数的资源。
为了发送这些电子邮件,打开帖子控制器,并在视图操作中,在以下代码之前:
Session::set_flash('success', ...);
添加:
// Manually loading the Email package
\Package::load('email');
$email = \Email::forge();
// Setting the to address
$email->to(
$data['post']->author->email,
$data['post']->author->username
);
// Setting a subject
$email->subject('New comment');
// Setting the body and using a view since the message is long
$email->body(
\View::forge(
'comment/email',
array(
'comment' => $comment,
)
)->render()
);
// Sending the email
$email->send();
最后,创建BLOGPATH/views/comment/email.php视图文件,并设置其内容为:
Hi,
A new comment has been posted.
Author: <?php echo $comment->name; ?>
Email: <?php echo $comment->email; ?>
Content:
<?php echo $comment->content; ?>
Go to the administration panel to accept / reject it.
<?php echo Uri::base().'admin' ?>
Thanks,
清除被拒绝的评论
如果您的博客被垃圾邮件攻击,您发现自己有很多状态设置为not_published的评论,您可能想删除所有这些评论以清理您的评论数据库。我们可以简单地实现一个链接和一个操作,但为了示例,让我们实现一个执行此操作的任务。
任务是可以通过oil实用程序通过命令行执行的一类。它们通常用于后台进程或 cron 作业。有时,它们也可以用于生成或修改现有代码,就像我们之前用于将脚手架移动到模块的任务一样。
让我们使用oil实用程序生成我们的任务文件:
php oil generate task clearComments
它应该输出:
No tasks actions have been provided, the TASK will only create default task.
Preparing task method [Index]
Creating tasks: APPPATH/tasks/clearcomments.php
如果您现在打开位于APPPATH/tasks/clearcomments.php的任务文件,您应该看到以下类:
<?php
namespace Fuel\Tasks;
class Clearcomments
{
// ...
public function run($args = NULL)
{
// ...
}
// ...
public function index($args = NULL)
{
// ...
}
}
oil实用程序生成了一个名为Clearcomments的类,其中包含两个方法:run和index。每个方法都可以使用oil实用程序调用。
以下命令执行run方法:
php oil refine clearComments:run
以下命令执行index方法:
php oil refine clearComments:index
如果您添加一个名为my_method的公共方法,它也会在执行时被调用:
php oil refine clearComments:my_method
run方法是默认方法,因此可以按这种方式调用:
php oil refine clearComments
可以向任务传递额外的参数。例如:
php oil refine clearComments:run param_1 param_2
在那种情况下,oil实用程序将调用Clearcomments::run('param_1', 'param_2')。
您应该在以下位置阅读官方文档:
fuelphp.com/docs/packages/oil/generate.html#/tasks
(可以通过导航到 FuelPHP 网站上的DOCS | 目录 | Oil | 生成)来访问
fuelphp.com/docs/general/tasks.html
(可以通过导航到 FuelPHP 网站上的DOCS | 目录 | FuelPHP | 通用 | 任务)来访问
将类内容替换为以下内容:
public function run()
{
\DB::query(
'DELETE FROM comments WHERE status="not_published";'
)->execute();
return 'Rejected comments deleted.';
}
现在,如果你运行:
php oil refine clearComments
它应该删除所有被拒绝的评论。
你可以手动执行此任务,或者你可以设置一个 cron 作业来定期执行它。
额外的改进
可能有许多额外的改进。一些边缘情况需要处理:例如,尝试在删除帖子或分类时成功显示管理面板。你可以设置路由配置,以便欢迎页面显示你的帖子列表。在可视化帖子时,你可以通过使用相关参数来优化发送的 SQL 请求。你甚至可以在发布新评论时向所有评论者发送电子邮件,并允许他们取消订阅。你应该添加你认为必要的改进,这只会对你的 FuelPHP 技能产生有益的影响。
我们还有一个关于模块的额外建议。在本章中,为了简单和简洁,我们创建了一个单独的模块,blog,来管理帖子、评论和分类。然而,根据网站的不同,开发者可能希望禁用(例如,禁用评论),更改这些功能,甚至添加新的功能。
我们可以通过创建一个配置文件来处理这个问题,该文件定义了是否应该启用特定功能,或者某些功能应该如何操作。它可以起到作用,但如果你的模块积累了许多功能,你的代码可能会变得难以维护。
解决这个问题的更好方法是创建几个较小的模块,每个模块处理一个功能。毕竟,评论也可以用于产品页面,例如。显示帖子列表的方式也可能有多个,因此将模型和控制器/视图分离到不同的模块中也是一个好主意。你应该始终追求简单且小的模块,它们相互交互,而不是一个做所有事情的庞大模块。
摘要
在本章中,我们已经构建了一个具有许多功能的复杂项目。通过尽可能使代码易于维护(例如使用模块),我们提供了一个项目应该如何实施的快照,以便添加新功能仍然容易。我们还解决了一些常见的 ORM 问题,学习了如何轻松分页列表,并使用了 Auth 和 Email 包。你当然不可能了解 FuelPHP 框架的所有内容,但现在实施大多数项目应该不会对你构成问题。
在下一章中,你将学习如何通过安装外部包以及创建自己的包来添加可重用的功能。
第四章. 创建和使用包
在本章中,您将学习如何安装、使用和创建 FuelPHP 包。为了说明目的,我们假设我们想要防止垃圾邮件发送者和机器人污染我们的网站,我们将探讨两种不同的解决方案来解决这个问题。我们首先将使用现有的包(recaptcha),然后我们将创建自己的包。
到本章结束时,您将了解:
-
CAPTCHA 是什么
-
如何手动或使用 oil 命令行安装外部包
-
reCAPTCHA 是什么以及如何使用相关的 FuelPHP 包
-
如何创建自己的包
-
启动文件是什么以及如何使用它
CAPTCHA 是什么?
CAPTCHA(完全自动化的公开图灵测试,用于区分计算机和人类)通常用于防止机器人或程序访问网站的一些功能。例如,在博客中,您可能希望防止机器人在评论部分添加未经请求和不相关的广告。如果您希望用户支付会员费才能访问您的内容,您可能还希望防止程序访问此受限制的内容。
您可能已经看到了很多 CAPTCHA,通常以图像中的扭曲文本形式显示。一个知名的服务是 reCAPTCHA,其验证表单看起来如下所示:

不幸的是,由于创建垃圾邮件机器人的激励措施很多,没有 CAPTCHA 系统是完美的,但至少它们使机器人的工作更加困难。
初步步骤
您首先需要遵循以下步骤:
-
安装新的 FuelPHP 实例。
-
配置 Apache 和您的宿主文件以处理它。在本章中,我们将通过请求
http://mytest.app来访问我们的应用程序。 -
如有必要,更新 Composer。
-
为您的应用程序创建一个新的数据库。
-
配置 FuelPHP 以允许您的应用程序访问此数据库。
这些步骤已在第一章创建您的第一个 FuelPHP 应用程序中介绍,因此您可能想查看它。
生成示例应用程序
为了测试我们的包,我们将创建一个简单的应用程序来处理虚拟项目。为了完全清楚起见,我们在这里不关心应用程序的最终目标;这只是一个测试应用程序。大部分工作将在包内部完成。因此,用户界面和模型将非常简单,将由oil工具的 scaffold 命令完全生成。稍后,我们将将这些包连接到创建和编辑功能,以确定访问者是否为人类。
首先,使用以下命令生成 scaffold:
php oil generate scaffold/crud item name:string
它将打印以下输出:
Creating migration: APPPATH/migrations/001_create_items.php
Creating model: APPPATH/classes/model/item.php
Creating controller: APPPATH/classes/controller/item.php
Creating view: APPPATH/views/item/index.php
Creating view: APPPATH/views/item/view.php
Creating view: APPPATH/views/item/create.php
Creating view: APPPATH/views/item/edit.php
Creating view: APPPATH/views/item/_form.php
Creating view: APPPATH/views/template.php
然后,通过执行以下命令来执行生成的迁移文件:
php oil refine migrate
如果您现在请求以下 URL,我们的测试应用程序应该可以完美运行:
http://mytest.app/item
reCAPTCHA 解决方案
将 CAPTCHA 系统集成到您网站的第一种方法是通过使用 FuelPHP 的 recaptcha 包。这是一个方便的解决方案,因为它不需要实现太多,并且允许您集成一个您的访客已经习惯使用的知名 CAPTCHA 系统。
安装 recaptcha 包
首先,我们将安装 recaptcha 包,它可以将 reCAPTCHA 服务轻松集成到您的 FuelPHP 应用程序中。
reCAPTCHA 服务是由 Google 提供的一个流行且免费的服务,它通过要求访客在屏幕上输入扭曲文本图像中看到的单词来检查您的访客是否为机器人。一个有趣的事实是,它有助于将实际图像和书籍的文本数字化。
安装包非常简单。访问以下 URL:
github.com/fuel-packages/fuel-recaptcha
现在,点击 下载 ZIP 按钮,然后解压缩文件到 PKGPATH 目录(fuel/packages)中。
注意
有其他下载包的方法。您可以使用以下命令使用 oil 工具:
php oil package install recaptcha
建议您阅读有关此 oil 功能的官方文档,该文档可在以下 URL 获取:
fuelphp.com/docs/packages/oil/package.html
(可以通过 FuelPHP 网站通过导航到 DOCS | 目录 | Oil | 包 来访问)
一些包也可以通过 Composer 工具安装。
配置 recaptcha 包
在继续之前,您需要在 reCAPTCHA 网站上创建一个账户:
完成此操作后,您必须将 PKGPATH/fuel-recaptcha/config/recaptcha.php 配置文件复制到 APPPATH/config/recaptcha.php,并在新文件中设置 reCAPTCHA 网站提供的 private_key 和 public_key 密钥。
集成 recaptcha 包
现在我们已经安装并配置了 recaptcha 包到我们的 FuelPHP 实例中,我们只需将其集成到我们的创建和编辑表单中。打开 APPPATH/views/item/_form.php 文件,并在具有 form-group 类的两个 div 元素之间,添加以下代码:
<div class="form-group">
<?php
echo Form::label('Please verify that you are human');
// It is how we display the recaptcha form as you can read
// in the package readme file.
echo ReCaptcha::instance()->get_html();
?>
</div>
在 Item 控制器的 create 和 edit 动作的开始处,添加以下代码行:
Package::load('fuel-recaptcha');
如果您显示创建或编辑表单,reCAPTCHA 验证系统将如以下截图所示:

我们现在需要做的就是检查用户输入的值是否正确。打开 Item 控制器,并在 create 和 edit 动作中,围绕以下代码:
$val = Model_Item::validate(/* 'create' or 'edit' */);
if ($val->run())
// And the following if else statement content
通过:
if (static::is_captcha_correct())
{
// Code to be surrounded
} else {
Session::set_flash(
'error',
'You have entered an invalid value for the CAPTCHA'
);
}
在 Item 控制器中,添加 CAPTCHA 验证方法:
public static function is_captcha_correct() {
// This is how a CAPTCHA is checked according to the
// package readme file.
return ReCaptcha::instance()
->check_answer(
Input::real_ip(),
Input::post('recaptcha_challenge_field'),
Input::post('recaptcha_response_field')
);
}
如果您为 CAPTCHA 输入无效值,任何项目添加/编辑都将失败。
创建您自己的包
我们之前看到的解决方案可以快速实现,但存在一个主要缺陷;reCAPTCHA 非常知名,有各种在线服务提供以几美元的价格解码数千个(它们可以使用光学字符识别甚至实际的人类解密者)。实际上,任何知名系统都存在同样的问题,因此有时最好的解决方案在于系统的原创性,而不是其绝对的鲁棒性。确实,即使新系统更加简单,它也会迫使垃圾邮件发送者专门创建新的机器人,如果他们想要污染你的网站,从而产生一种阻力(只要你的网站不是特别受欢迎)。
因此,我们将构建一个新的 CAPTCHA 包,以便创建我们自己的原创解决方案。我们不会显示包含扭曲文本的图像,而是简单地要求访客计算一个简单的加法。
注意
请注意,这个解决方案仅实现来演示如何构建一个包。因此,我们将选择一个非常简单的解决方案,它可能很容易被解码。欢迎你根据这个朴素的包创建你自己的鲁棒验证系统。
概念
由于我们需要检查用户是否在服务器上输入了正确的数字,我们将把预期的答案保存在数据库中。为此,我们将生成一个只包含 id、expected_value 和 created_at 属性的 Captcha_Answer 模型。
生成包
我们将再次使用 oil 命令为我们的包生成脚手架:
php oil generate package captcha
这将打印以下输出:
Creating file: PKGPATH/captcha/classes/captcha.php
Creating file: PKGPATH/captcha/config/captcha.php
Creating file: PKGPATH/captcha/bootstrap.php
你可以看到已经生成了几个文件。如果你打开位于 PKGPATH/captcha/classes/captcha.php 的 Captcha 类,你会看到该类位于 Captcha 命名空间中,并且已经实现了几个方法:
<?php
namespace Captcha;
class CaptchaException extends \FuelException {}
class Captcha
{
// ...
protected static $_defaults = array();
// ...
protected $config = array();
// ...
public static function _init()
{
\Config::load('captcha', true);
}
// ...
public static function forge($config = array())
{
$config = \Arr::merge(
static::$_defaults,
\Config::get('captcha', array()),
$config
);
$class = new static($config);
return $class;
}
// ...
public function __construct(array $config = array())
{
$this->config = $config;
}
// ...
public function get_config($key, $default = null)
{
return \Arr::get($this->config, $key, $default);
}
// ...
public function set_config($key, $value)
{
\Arr::set($this->config, $key, $value);
return $this;
}
}
-
有五个方法,如下:构造函数,其中你传递包配置作为参数。
-
静态的
forge方法,它获取位于PKGPATH/captcha/config/captcha.php的包配置文件并将其传递给构造函数。这意味着如果你使用forge方法创建一个Captcha对象,其配置将自动从配置文件中加载,而如果你使用构造函数创建它,你必须手动定义包配置。 -
get_config和set_config方法是自解释的。 -
_init方法,在初始化Captcha类时被调用。一般来说,在任意类中,如果你定义了一个静态的_init方法,当类被 FuelPHP 加载时,它将被调用。在我们的类中,该方法加载位于PKGPATH/captcha/config/captcha.php的配置文件。
PKGPATH/captcha/config/captcha.php 配置文件目前是一个空数组,但你可以根据需要添加任意多的参数。
我们生成的captcha包也包含一个位于PKGPATH/captcha/bootstrap.php的bootstrap文件。当包被加载时,此bootstrap文件将被执行。同样,当您的应用程序被加载时(几乎每次请求网页时),APPPATH/bootstrap.php文件将被执行。
如果您打开PKGPATH/captcha/bootstrap.php文件,您将看到以下代码:
<?php
Autoloader::add_core_namespace('Captcha');
Autoloader::add_classes(array(
'Captcha\\Captcha' => __DIR__ . '/classes/captcha.php',
'Captcha\\CaptchaException' => __DIR__ . '/classes/captcha.php',
));
Autoloader::add_classes方法指定了 Autoloader 可以在哪里找到类。例如,在执行bootstrap文件后,FuelPHP 将知道Captcha\Captcha类位于PKGPATH/captcha/classes/captcha.php文件中。
Autoloader::add_core_namespace方法指定了需要添加到核心命名空间中的命名空间。在实践中,在执行bootstrap文件后,\Captcha\Captcha和\Captcha都将指向同一个类。
建议您阅读可以在以下位置找到的 Autoloader 官方文档:
fuelphp.com/docs/classes/autoloader.html
(可以通过访问 FuelPHP 网站在DOCS | 目录 | 核心 | 自动加载器处找到)
还建议您阅读有关包的官方文档:
fuelphp.com/docs/general/packages.html
(可以通过访问 FuelPHP 网站在DOCS | 目录 | FuelPHP | 通用 | 包处找到)
生成 Captcha_Answer 模型
为了加快进程,我们再次使用oil命令行:
php oil generate model captcha_answer expected_value:int created_at:int --crud
这将打印以下输出:
Creating model: APPPATH/classes/model/captcha/answer.php
Creating migration: APPPATH/migrations/002_create_captcha_answers.php
在进行任何其他操作之前,您需要将这些文件移动到我们的包中:
-
将
APPPATH/classes/model/captcha/answer.php移动到PKGPATH/captcha/classes/model/captcha/answer.php。 -
此外,将
APPPATH/migrations/002_create_captcha_answers.php移动到PKGPATH/captcha/migrations/001_create_captcha_answers.php(别忘了重命名文件)。
完成后,打开PKGPATH/captcha/classes/model/captcha/answer.php,并在文件开头(在<?php之后)添加以下内容:
namespace Captcha;
您还需要在模型内部添加以下属性,以便自动填充created_at属性:
protected static $_created_at = 'created_at';
打开位于PKGPATH/captcha/bootstrap.php的bootstrap文件,并在传递给Autoloader::add_classes的数组末尾添加以下代码:
'Captcha\\Model_Captcha_Answer' => __DIR__ . '/classes/model/captcha/answer.php',
迁移包
现在我们需要执行Captcha包中的迁移文件。为了做到这一点,只需输入以下命令:
php oil refine migrate --packages=captcha
将包集成到我们的应用程序中
在本节中,为了清晰起见,我们假设您尚未实现 reCAPTCHA 解决方案。尽管如此,值得注意的是,这个新的实现将明显受到它的启发。因此,如果您已经实现了 reCAPTCHA 解决方案,只需在执行过程中用新代码替换旧代码即可。
首先,在 PKGPATH/captcha/classes/captcha.php 中 Captcha 类的位置添加以下方法:
public function check_answer($id, $answer) {
return true;
}
public function get_html() {
return '<div>Will be implemented in the next section</div>';
}
您可以注意到我们并没有在那些方法中实现任何功能;这些只是占位方法。由于它们稍微有些复杂,我们将在下一节中完成它们,但现在我们将它们连接到测试应用程序。打开 APPPATH/views/item/_form.php,并在具有 form-group 类的两个 div 元素之间添加以下代码行:
<div class="form-group">
<?php
echo Form::label('Please verify that you are human');
// Displaying the captcha form
echo Captcha::forge()->get_html();
?>
</div>
在 Item 控制的 create 和 edit 动作的开头添加以下代码:
Package::load('captcha');
我们现在需要检查用户输入的值是否正确。打开 Item 控制器,并在 create 和 edit 动作中,围绕以下代码:
$val = Model_Item::validate(/* 'create' or 'edit' */);
if ($val->run())
// And the following if else statement content
通过:
if (static::is_captcha_correct())
{
// Code to be surrounded
} else {
Session::set_flash(
'error',
'You have entered an invalid value for the captcha'
);
}
最后,仍然在 Item 控制器中,添加 CAPTCHA 验证方法:
public static function is_captcha_correct() {
// Checking the captcha
return Captcha::forge()
->check_answer(
Input::post('captcha_id'),
Input::post('captcha_answer')
);
}
如果您现在测试您的应用程序,您将在 请验证您是人类 下方看到 将在下一节实现 的消息,并且任何项目都将被添加或更新而无需任何检查,如下面的截图所示:

实现 get_html 方法
打开 Captcha 类,并用以下代码替换 get_html 方法:
/**
* Returns the CAPTCHA form
*
* @return string the CAPTCHA form html code
*/
public function get_html() {
// Getting configuration
$min_number = $this->get_config('min_number');
$max_number = $this->get_config('max_number');
// Generating two random numbers
$number_1 = rand($min_number, $max_number);
$number_2 = rand($min_number, $max_number);
// Computing the correct answer
$answer = $number_1 + $number_2;
// Saving the expected answer
$captcha_answer = Model_Captcha_Answer::forge();
$captcha_answer->expected_value = $answer;
$captcha_answer->save();
return \View::forge(
'captcha',
array(
'number_1' => $number_1,
'number_2' => $number_2,
'captcha_answer' => $captcha_answer,
)
)->render();
}
如您所见,我们在 get_html 方法中调用了 captcha 视图。因此,我们需要实现它。创建 PKGPATH/captcha/views/captcha.php 视图文件,并添加以下内容:
<div class="captcha_area">
<div class="captcha_instruction">
<?php echo $number_1; ?> + <?php echo $number_2; ?> ?
</div>
<div class="captcha_fields">
<input type="hidden" name="captcha_id"
value="<?php echo $captcha_answer->id; ?>" />
<input type="text" name="captcha_answer"
value="" class="col-md-4 form-control" />
</div>
</div>
最后,如您可能在 new get_html 方法中注意到的那样,我们从配置文件中获取 min_number 和 max_number,因此我们需要定义这些值(请随意更改它们)。打开 PKGPATH/captcha/config/captcha.php 配置文件,并用以下代码替换其内容:
<?php
return array(
'min_number' => 1,
'max_number' => 9,
);
如果您重新加载创建或编辑表单,您现在将看到 CAPTCHA 验证表单:

实现 CAPTCHA 验证方法
check_answer 方法相当简单;因为我们已经将预期的答案保存到了一个 Model_Captcha_Answer 实例中,所以我们只需要检索它并检查提交的答案是否正确。在 Captcha 类中,用以下代码替换 check_answer 方法:
/**
* Check if the captcha is valid
*
* @param int $id id of the CAPTCHA answer
* @param string $answer answer given by the visitor
* @return boolean is the answer correct ?
*/
public function check_answer($id, $answer) {
// Model::find_by_pk finds an instance by its
// Primary Key (in our case, id).
$captcha_answer = Model_Captcha_Answer::find_by_pk(
intval($id)
);
$correct = $captcha_answer->expected_value == $answer;
// The answer has been checked, so no need to keep the
// expected answer
$captcha_answer->delete();
return $correct;
}
清理旧的 CAPTCHA
如您可能已注意到,每次我们显示 CAPTCHA 时,我们都会在 captcha_answers 表中添加一行新数据,并且当用户提交答案时,这一行数据将被清除,或者说,如果用户没有提交表单,这一行数据将永远不会被删除。一个良好的做法是定期删除这些行。我们可以使用模型的删除方法来做这件事,但由于可能需要删除多行,我们将会简单地执行一个 SQL 请求。
仍然在 Captcha 类中,添加以下方法:
/**
* Clean the old captchas
*/
public function clean_old_captchas() {
\DB::query('
DELETE FROM `captcha_answers`
WHERE `created_at` < '.
intval(\Date::forge()->get_timestamp()
- $this->get_config('captcha_expiration'))
.';')
->execute();
}
您可以在 get_html 和 check_answer 方法的开头添加以下内容:
$this->clean_old_captchas();
由于我们使用$this->get_config('captcha_expiration')来确定CAPTCHA何时过期,我们需要在PKGPATH/captcha/config/captcha.php配置文件中定义captcha_expiration键:
// Captcha are expired 4 hours after generation
'captcha_expiration' => 3600 * 4,
可能的改进
正如我们在本节开头所解释的,这个包当然可以改进。你可以在图片中显示添加的内容,而不是以纯文本形式显示。例如,你可以通过添加噪声和交替颜色使其稍微难以阅读。这超出了本章的范围,因为我们想专注于包,但建议你添加这样的功能来提高你的 PHP 和 FuelPHP 技能。
摘要
本章的主要内容是关于包:如何安装外部包以及如何创建自己的包。因此,你已经学会了如何创建和使用可重用的代码。我们使用了fuel-recaptcha包,但如果你访问网址github.com/fuel-packages?tab=repositories,你会看到那里有很多不同的包可供选择。由于 FuelPHP 也使用 Composer,你可以查看packagist.org/search/?q=fuel并使用 Composer 安装额外的包。
当你考虑在你的应用程序中添加新功能时,查看是否已有满足你需求的项目总是一个好主意。如果你找不到,你可以改进一个足够接近的包或者创建自己的,就像我们用我们的自定义Captcha包所做的那样。一旦完成,考虑分享它,例如,通过在 GitHub 上发布;这样你就可以回馈给你带来这个令人惊叹框架的社区。
在下一章中,你将看到如何创建一个提供并使用其自身 API 的应用程序。我们还将探讨如何自动测试你的应用程序以防止不希望的反向回归。
第五章. 构建自己的 RESTful API
在本章中,我们将创建自己的类似于 Twitter 的微型博客应用程序。社交组件将相当简单:用户将在他们的墙上发布最多 140 个字符的消息。本章的实际输入将在于设置一个可以通过外部应用程序访问的 JSON API,并添加自动测试,这将允许你跟踪回归。为了限制交换的数据量,我们将尽可能让我们的应用程序使用此 API。
到本章结束时,你应该知道:
-
如何创建注册表单
-
如何实现一个不重复任何代码的 JSON API
-
Parser包是什么 -
什么是语言无关的模板系统以及为什么使用这样的系统
-
Mustache 引擎是什么以及如何使用它实现视图
-
什么是魔法迁移
-
如何实现单元测试并运行它们
规格
访问者可以订阅我们的微型博客应用程序。一旦他们这样做,他们就能写 140 个字符的小帖子,这些帖子将显示在他们的个人资料页面上。任何人,即使是非用户,也可以看到用户的个人资料页面。
为了避免认证问题并保持项目简单,我们只提供只读的 JSON API。此外,我们不会跟踪使用我们 API 的应用程序,因此不会实施任何限制(如果您正在考虑发布自己的 API,这可能是一个重要的点)。因此,只有用户的个人资料信息(用户名、创建日期等)和发布的帖子将通过 API 提供。
概念
我们需要以下两个模型:

实体关系图(Min-Max 表示法)
-
用户:由于模型的表将由
Auth包的迁移生成,因此列已经生成。我们需要的是username和password列。 -
帖子:每篇帖子都有一个
content和created_at属性。由于每篇帖子只能由单个用户发布,每个用户可以发布多篇文章,因此帖子与用户之间存在belongs_to关系,用户与帖子之间存在has_many关系。因此,必须添加一个额外的user_id属性来表示这种关系。
FuelPHP 的安装和配置
您首先需要执行以下步骤:
-
安装新的 FuelPHP 实例。
-
配置 Apache 和主机文件以处理它。在本章中,我们将通过请求
http://mymicroblog.appURL 来访问我们的应用程序。 -
如有必要,更新 Composer。
-
为您的应用程序创建一个新的数据库。
-
最后,配置 FuelPHP 以允许您的应用程序访问此数据库。
此项目还需要ORM、Auth和Parser包。我们在前面的章节中使用了ORM和Auth包,但从未使用过Parser包;我们将在《Parser 包和模板引擎》部分解释其作用。由于它们已经安装,我们只需要启用它们。为此,只需打开APPPATH/config/config.php文件,并在返回数组的末尾插入以下代码行:
'always_load' => array(
'packages' => array(
'orm',
'auth',
'parser',
),
),
或者,你可以取消注释适当的行。这将每次加载 FuelPHP 实例时加载ORM、Auth和Parser包。我们还需要为Auth包更改一些配置项。
首先,将位于PKGPATH/auth/config/auth.php的文件复制到APPPATH/config/auth.php,并将PKGPATH/auth/config/simpleauth.php复制到APPPATH/config/simpleauth.php。
然后,打开配置文件APPPATH/config/auth.php,将salt值更改为随机字符串(这是一种安全预防措施)。在这里,我们将使用Simpleauth驱动程序,因为我们不需要在认证系统中使用很多功能。
然后,打开APPPATH/config/simpleauth.php文件,将login_hash_salt的值设置为随机字符串(再次,出于安全考虑)。通过执行迁移文件来安装Auth表:
php oil refine migrate --packages=auth
如果你查看数据库,你应该会看到已经生成了几个表:
-
users -
users_clients -
users_providers -
users_scopes -
users_sessions -
users_sessionscopes
然而,正如预期的那样,生成的表比Ormauth驱动程序少得多。
解析器包和模板引擎
你可能会注意到我们已将parser包添加到always_load.package键中。多亏了这个包,我们不再需要用 PHP 编写视图,而是能够使用模板引擎。对于那些不熟悉模板引擎的人来说,它们允许我们使用不同的语法编写视图文件。
例如,可以通过在 PHP 中编写以下代码来显示项目列表:
<h1>Items</h1>
<?php foreach ($items as $item) { ?>
<li><?php echo $item->title ?></li>
<?php } ?>
<a href="item/create">Create an item</a>
但是,使用 HAML 模板引擎,可以写成这样:
%h1 Items
- foreach ($items as $item)
%li
= $item->title
%a(href="item/create") Create an item
或者,使用 Mustache 模板引擎,可以写成这样:
<h1>Items</h1>
{{#items}}
<li>{{title}}</li>
{{/items}}
<a href="item/create">Create an item</a>
你可能有各种原因想要使用模板引擎:
-
它允许你编写更简洁、更优雅的代码,例如在 HAML 语言中。
-
它允许你保持一致的代码格式。
-
它迫使你将逻辑与表示分离。因此,你可以轻松地将代码交给设计师,设计师可以更改它而无需理解任何 PHP。
对于我们的项目,我们将使用 Mustache 模板引擎,但并非出于上述任何原因。
语言无关模板引擎的主要好处
如果您打开 Mustache 模板引擎的主页(mustache.github.io/),您会发现该引擎在许多不同的语言中都有可用(Ruby、JavaScript、Python、Node.js、PHP、Java、C++、ASP、C#等等)。然而,您打算使用哪种语言使用该引擎并不重要:模板的语法将保持不变,语言不会对您所编写的代码产生影响。这是因为 Mustache 是一个语言无关的模板引擎。如果您与使用多种不同语言(如 PHP、JavaScript、Ruby 或 Python)的团队合作,这是一个巨大的优势;您的视图可以使用相同的通用标记语言编写。我们将利用这一特性来发挥我们的优势。
下面的图表显示了网站目前最常见的工作方式:

但您通常需要在浏览器中显示网页后动态加载新内容:

为了进一步说明这一点,让我们假设我们正在显示一个用户的个人资料页面,因此显示其帖子列表。如果用户已经发布了 1000 篇帖子,我们不会一次性显示它们。我们首先使用 PHP 视图显示最后 30 篇帖子,例如,这样网页在某个时刻应该看起来像这样:
...
<div class="posts_list">
<div class="post" id="post_232">
<div class="post_author">first_user</div>
<div class="post_content">My last post.</div>
<div class="post_date">5 minutes ago</div>
</div>
<div class="post" id="post_214">
<div class="post_author">first_user</div>
<div class="post_content">Hello everyone.</div>
<div class="post_date">21 minutes ago</div>
</div>
...
</div>
...
当访客滚动到网页底部时,它将向服务器 API 发送 AJAX 请求,该请求将用 JSON 格式替换之前的 30 篇帖子。返回的代码应该看起来像这样:
{
...
"posts": [
{
"id": 142,
"content": "previous post.",
"created_at": 1409741475,
"author": {
"id": 24,
"username": "first_user"
}
},
{
"id": 125,
"content": "very old post.",
"created_at": 1209751372,
"author": {
"id": 24,
"username": "first_user"
}
},
...
]
...
}
我们已经拥有了所有必要的数据,但我们需要将其转换为 HTML 代码,以便用户能够看到。无论您使用 jQuery 还是直接 DOM 操作,您都需要使用 JavaScript 代码来完成这项工作(该代码将充当 JavaScript 视图)。这会导致代码重复,也就是说,如果您更改 PHP 视图中帖子显示的方式,您也需要更改 JavaScript 代码。对于大型项目来说,这会迅速变得难以管理。然而,如果我们使用 mustache 模板引擎,所有这一切都可以改变。

这里没有什么特别之处。然而,在加载动态内容时,这个过程得到了改进:

由于 Mustache 模板引擎是语言无关的,因此可以在 PHP 和 JavaScript 中解释同一个模板。如果我们想改变,比如说,帖子显示的方式,我们只需要更改这个模板。没有重复总意味着更健壮和易于维护的应用程序。
当然,我们始终可以编写一个完整的 JavaScript 应用程序,从 API 加载数据而不使用任何 PHP 视图。这样,就不需要模板引擎,因为我们只会编写 JavaScript 视图。然而,能够直接从服务器返回 HTML 内容有两个好处。首先,如果客户端不支持 JavaScript——如大多数搜索引擎的情况——它仍然能够访问网站(因此,您应用程序的索引将更好)。其次,当客户端第一次访问您的网站时,您可以通过返回请求网页的缓存 HTML 代码来加快这个过程。
为了使用 Mustache 模板引擎,我们需要安装它。在composer.json文件中,在require列表中添加以下行(别忘了在上一行添加逗号):
"mustache/mustache": "2.7.0"
然后更新 Composer。
备注
我们选择 Mustache 引擎主要是因为它的简洁性,但您有很多其他选择。如果您想使用我们将在更复杂的项目中实施的 API 策略,我建议您查看更完整的解决方案。例如,尽管它们本身不是语言无关的模板引擎,您可以考虑 Smarty 及其 JavaScript 端口 jSmart。
订阅和认证功能
我们不会生成整个脚手架,我们将手动创建控制器和视图,因为我们不需要大多数 CRUD 功能。
实现订阅和认证表单
首先,让我们创建用户控制器。在APPPATH/classes/controller/user.php创建一个文件,并设置其内容为:
<?php
class Controller_User extends Controller_Template
{
}
主页,将由User控制器的index操作处理,如果用户已登录,将显示用户的帖子,否则将显示订阅和认证表单。
由于我们系统中没有用户,没有人可以登录。因此,我们将开始订阅和认证表单。
首先,在User控制器中添加以下方法:
public function action_index()
{
if (false /* is the user logged ? */) {
// @todo: handle response if user is logged.
} else {
$this->template->content =
View::forge(
'user/connect.mustache',
array(),
// By default, mustache escapes displayed
// variables, so no need to escape them here
false
);
}
}
我们稍后会回到这个操作,但到目前为止,这对您来说应该相当简单。
由于我们使用Controller_Template,我们需要定义一个模板。在APPPATH/views/template.php创建模板视图文件,并设置其内容为:
<!DOCTYPE html>
<html>
<head>
<?php
echo '<base '.array_to_attr(array('href' => Uri::base())).' />';
?>
<meta charset="utf-8">
<title>My microblog</title>
<?php echo Asset::css('bootstrap.css'); ?>
<?php echo Asset::css('website.css'); ?>
<style>
body { margin: 50px; }
</style>
<?php echo Asset::js(array(
'http://code.jquery.com/jquery-1.11.2.min.js',
'bootstrap.js'
)); ?>
<script>
$(function(){ $('.topbar').dropdown(); });
</script>
</head>
<body>
<div class="navbar navbar-inverse navbar-fixed-top">
<div class="container">
<div class="navbar-header">
<a class="navbar-brand"
<?php echo array_to_attr(array('href' => Uri::base())) ?>
>
My microblog
</a>
</div>
</div>
</div>
<div class="container">
<div class="row">
<div class="col-md-12">
<?php if (Session::get_flash('success')): ?>
<div class="alert
alert-success
alert-dismissable">
<button
type="button"
class="close"
data-dismiss="alert"
aria-hidden="true">
×
</button>
<p>
<?php
echo implode(
'</p><p>',
(array) Session::get_flash('success')
); ?>
</p>
</div>
<?php endif; ?>
<?php if (Session::get_flash('error')): ?>
<div class="alert
alert-danger
alert-dismissable">
<button
type="button"
class="close"
data-dismiss="alert"
aria-hidden="true">
×
</button>
<p>
<?php
echo implode(
'</p><p>',
(array) Session::get_flash('error')
); ?>
</p>
</div>
<?php endif; ?>
</div>
<?php echo $content; ?>
</div>
</div>
</body>
</html>
它灵感来源于第三章中生成的管理模板,构建博客应用程序,您可以将其与位于PKGPATH/oil/views/admin/template.php的用于脚手架生成的文件进行比较。我们使用 Bootstrap 框架以响应式的方式轻松构建我们的网页;我们不时会使用其 CSS 类。您可以在getbootstrap.com/查看 Bootstrap 的官方文档。
我们还将在website.css文件中定义一些自定义 CSS 类。由于我们已经在模板中包含了它,所以在public/assets/css/website.css创建样式表文件,并设置其内容为:
body {
background-color: #f8f8f8;
}
h1.home {
font-size: 45px;
text-align: center;
}
.alert {
margin-top: 10px;
}
现在,我们需要创建APPPATH/views/user/connect.mustache视图。我们需要在那里添加注册和登录表单,所以这不是什么高难度的任务:
<h1 class="home">
My microblog
</h1>
<div class="signup_or_signin">
<div class="signin col-md-1"></div>
<div class="signup col-md-4">
<h2>Signup</h2>
<form action="user/signup" method="post">
<div class="form-group">
<input
type="text"
name="username"
placeholder="Username"
class="form-control" />
</div>
<div class="form-group">
<input
type="email"
name="email"
placeholder="Email"
class="form-control" />
</div>
<div class="form-group">
<input
type="password"
name="password"
placeholder="Password"
class="form-control" />
</div>
<div class="form-group">
<input
type="submit"
value="Signup"
class="btn btn-lg
btn-primary
btn-block" />
</div>
</form>
</div>
<div class="signin col-md-2"></div>
<div class="signin col-md-4">
<h2>Signin</h1>
<form action="user/signin" method="post">
<div class="form-group">
<input
type="text"
name="username"
placeholder="Email or Username"
class="form-control" />
</div>
<div class="form-group">
<input
type="password"
name="password"
placeholder="Password"
class="form-control" />
</div>
<div class="form-group">
<input
type="submit"
value="Signin"
class="btn btn-lg
btn-primary
btn-block" />
</div>
</form>
</div>
<div class="signin col-md-1"></div>
</div>
由于我们的视图中还没有动态部分,你可以看到它与经典的 PHP 或 HTML 视图非常相似。
最后,由于我们希望User控制器的index操作成为我们的主页,我们需要在routes配置文件中的_root_键中定义其 URI。打开APPPATH/config/routes.php配置文件,将其内容设置为:
<?php
return array(
'_root_' => 'user/index',
);
如果你请求以下 URL:
http://mymicroblog.app/
你应该看到一个简单但响应式的网页,包含注册和登录表单。
在以下屏幕截图中,你可以看到在大屏幕上网页的样子:

这是小屏幕和设备上网页的样子:

处理注册表单
由于目前没有用户存在,现在是时候在User控制器中创建signup操作(由注册表单指向)了,这样我们就可以创建我们的第一个用户。创建以下方法并阅读注释(你应该已经熟悉所有这些方法):
public function action_signup()
{
/*
Validating our form (checks if the username, the
password and the email have a correct value). We
are using the same Validation class as we saw on
numerous generated models
*/
$val = Validation::forge('signup_validation');
$val->add_field(
'username',
'Username',
'required|valid_string[alpha,lowercase,numeric]'
);
$val->add_field(
'password',
'Password',
'required|min_length[6]'
);
$val->add('email', 'Email')
->add_rule('required')
->add_rule('valid_email');
// Running validation
if ($val->run())
{
try {
// Since validation passed, we try to create
// a user
$user_id = Auth::create_user(
Input::post('username'),
Input::post('password'),
Input::post('email')
);
/*
Note: at this point, we could send a
confirmation email, but for the sake of this
chapter conciseness, we will leave the
implementation of this feature to you as a
training exercise.
*/
// If no exceptions were triggered, the user
// was succesfully created.
Session::set_flash(
'success',
e('Welcome '.Input::post('username').'!')
);
} catch (\SimpleUserUpdateException $e) {
// Either the username or email already exists
Session::set_flash('error', e($e->getMessage());
}
}
else
{
// At least one field is not correct
Session::set_flash('error', e($val->error()));
}
/*
Sending the signup form fields information so that they
are already filled when the user is redirected to the
the index action (useful if the user could not be created)
*/
Session::set_flash('signup_form', Input::post());
// No matter what, we return to the home page.
Response::redirect('/');
}
如果你现在请求主页并正确填写注册表单,users表中应该会创建一个新的用户。如果出现问题(例如,你输入了不完整的电子邮件地址或用户名已存在),将显示错误消息,但由于我们返回主页,表单将被清空。这没什么大不了的,但它可能会降低你的转化率。我们已经将表单数据保存在signup_form闪存变量中;因此,现在它可以在index操作中访问。我们将在index操作中通过替换以下内容将其传递给视图:
View::forge(...);
使用:
View::forge(
'user/connect.mustache',
array(
'signup_form' => Session::get_flash('signup_form'),
),
// By default, mustache escape displayed
// variables, so no need to escape them here
false
);
要自动填充username字段,打开APPPATH/views/user/connect.mustache视图文件,并将注册表单中的username输入替换为以下代码片段:
<input
type="text"
name="username"
placeholder="Username"
class="form-control"
value="{{signup_form.username}}"/>
如你所见,我们通过写入{{signup_form.username}}显示了$signup_form['username']变量。在 Mustache 文件中,通过写入{{var}}显示$var变量,通过写入{{var.val_1}}显示$var['val_1']。如果$var是一个对象,通过写入{{var.val_1}}也可以显示$var->val_1。
你也可以通过在注册表单的email输入中添加value="{{signup_form.email}}"来自动填充email字段。
处理登录表单
现在我们能够创建新用户了,我们需要处理登录表单。因此,我们将在User控制器中创建signin操作:
public function action_signin()
{
// If already logged in, redirecting to home page.
if (Auth::check()) {
Session::set_flash(
'error',
e('You are already logged in, '.
Auth::get_screen_name().'.')
);
Response::redirect('/');
}
$val = Validation::forge();
$val->add('username', 'Email or Username')
->add_rule('required');
$val->add('password', 'Password')
->add_rule('required');
// Running validation
if ($val->run())
{
$auth = Auth::instance();
// Checking the credentials.
if (
Auth::check() or
$auth->login(
Input::post('username'),
Input::post('password')
)
)
{
Session::set_flash(
'success',
e('Welcome, '.Auth::get_screen_name().'!')
);
}
else
{
Session::set_flash(
'error',
'Incorrect username and / or password.'
);
}
} else {
Session::set_flash(
'error',
'Empty username or password.'
);
}
// No matter what, we return to the home page.
Response::redirect('/');
}
这又非常受oil中由管理面板生成器生成的admin控制器的影响;查看位于PKGPATH/oil/views/admin/orm/controllers/admin.php的admin控制器的login操作(用于管理面板生成)。
允许用户注销
你可能在测试表单时注意到,一旦你成功登录,你就无法注销。除非你刚刚成功登录,否则你没有任何线索表明你是否已登录。
为了解决这个问题,我们将在导航栏中显示用户名,并允许用户在下拉菜单中注销,就像我们在管理面板中所做的那样。
打开位于APPPATH/views/template.php的模板文件,将<div class="navbar-header">...</div>替换为以下代码行:
<div class="navbar-header">
<!-- Allows the navbar to collapse when
the screen width is too small -->
<button
type="button"
class="navbar-toggle"
data-toggle="collapse"
data-target=".navbar-collapse">
<span class="icon-bar"></span>
<span class="icon-bar"></span>
<span class="icon-bar"></span>
</button>
<a class="navbar-brand"
<?php echo array_to_attr(array('href' => Uri::base())); ?>
>
My microblog
</a>
</div>
<div class="navbar-collapse collapse">
<?php if (Auth::check()): ?>
<ul class="nav navbar-nav pull-right">
<li class="dropdown">
<a
data-toggle="dropdown"
class="dropdown-toggle"
href="#">
<?php echo e(Auth::get_screen_name()) ?>
<b class="caret"></b>
</a>
<ul class="dropdown-menu">
<li>
<?php
echo Html::anchor('user/signout', 'Sign out');
?>
</li>
</ul>
</li>
</ul>
<?php endif; ?>
</div>
如果你已登录,你的用户名现在应该出现在屏幕的右上角。如果你点击你的用户名,应该出现注销链接。
现在,我们必须在User控制器中实现这个signout动作。这一步相当简单:
public function action_signout()
{
Auth::logout();
Response::redirect('/');
}
允许用户创建和查看帖子
现在,我们将允许用户创建他们自己的帖子并在他们的个人资料页中显示它们。帖子也将是我们 API 显示的主要信息。
生成帖子模型
我们首先需要生成帖子模型。像往常一样,我们将使用oil。输入以下命令行:
php oil generate model post content:varchar[140] user_id:int created_at:int --no-timestamp
输出如下:
Creating model: APPPATH/classes/model/post.php
Creating migration: APPPATH/migrations/001_create_posts.php
你可以看到我们在这里使用了--no-timestamp参数。它只是阻止自动生成created_at和updated_at列。由于我们可能有大量的帖子,而updated_at列可能变得无意义,我们将手动生成created_at列。因此,我们需要自己指定CreatedAt观察者。打开位于APPPATH/classes/model/post.php的帖子模型,并添加以下属性:
protected static $_observers = array(
'Orm\Observer_CreatedAt' => array(
'events' => array('before_insert'),
'mysql_timestamp' => false,
),
);
然后,简单地使用oil执行你的应用程序迁移:
php oil refine migrate
允许用户创建新帖子
我们将首先实现用户界面,然后我们将实现帖子创建动作。
实现用户界面
首先,让我们在导航栏的右侧添加一个新建帖子按钮。打开位于APPPATH/views/template.php的模板文件,在<ul class="nav navbar-nav pull-right">之后添加以下代码行:
<li>
<a href="#"
data-toggle="modal"
data-target="#create_post_modal">
<!-- Displays the pencil icon.
http://glyphicons.com/ -->
<span class="glyphicon glyphicon-pencil"></span>
New post
</a>
</li>
如果你刷新主页并且已经登录,你应该在导航栏的右侧看到带有铅笔图标的按钮。我们广泛使用了 Bootstrap 框架,因此建议你阅读官方文档getbootstrap.com/。
需要注意的是,链接内部声明的两个属性:
data-toggle="modal" data-target="#create_post_modal"
这意味着当我们点击链接时,我们希望 Bootstrap 使用具有id = create_post_modal的div元素的内容显示一个模态窗口。因此,我们需要定义这个div元素。在</body>之前添加以下代码行:
<!-- Post modal window -->
<div
class="modal fade"
id="create_post_modal"
tabindex="-1"
role="dialog"
aria-labelledby="myModalLabel"
aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<button
type="button"
class="close"
data-dismiss="modal">
<span aria-hidden="true">×</span>
<span class="sr-only">Close</span>
</button>
<h4 class="modal-title" id="myModalLabel">
Compose new Post
</h4>
</div>
<div class="modal-body">
<!-- Will be displayed conditionally -->
<div id="post_success" class="alert
alert-success">
Your post has been successfully
published!
</div>
<!-- Will be displayed conditionally -->
<div id="post_fail" class="alert
alert-danger"></div>
<textarea
id="post_content"
rows="4"
class="form-control"></textarea>
</div>
<div class="modal-footer">
<span id="post_remaining_characters"></span>
<button
type="button"
class="btn btn-primary"
id="post_submit_button">
Submit
</button>
</div>
</div>
</div>
</div>
这段代码是从官方文档getbootstrap.com/javascript/#modals中的实时演示中获得的灵感。
在website.css文件中添加以下样式:
textarea {
resize: none;
}
#post_success, #post_fail {
display: none;
}
#post_remaining_characters.too_much {
color: red;
}
点击新建帖子按钮后,你现在应该看到以下模态窗口:

然而,如果你尝试点击提交按钮,什么也不会发生。我们需要添加一些 JavaScript 代码来实现这一点。
由于这不会很短,首先在public/assets/js/post_form.js中创建一个新的 JavaScript 文件,并通过在模板中添加'post_form.js',在'bootstrap.js'之后,在模板的Asset::js调用中包含它。
接下来,打开新创建的 JavaScript 文件,并设置其内容为:
// When the DOM is ready
$(function(){
// jQuery elements initialization
var $postContent = $('#post_content');
var $postRemainingCharacters =
$('#post_remaining_characters');
var $postSuccess = $('#post_success');
var $postFail = $('#post_fail');
var $postSubmitButton = $('#post_submit_button');
// Defining the max number of characters of a post
var postMaxNbCharacters = 140; // will be improved
/*
Refreshes the remaining number of characters indicator,
and whether or not the submission button is enabled.
*/
function refreshPostWindow() {
var postLength = $postContent.val().length;
var remainingCharacters =
postMaxNbCharacters - postLength;
$postRemainingCharacters
.text(remainingCharacters)
.attr(
'class',
remainingCharacters >= 0 ? '' : 'too_much'
);
$postSubmitButton.prop(
'disabled',
postLength == 0 || remainingCharacters < 0
);
}
// Initialization
refreshPostWindow();
/*
When showing the post creation modal window, clearing
all previous messages. Useful if a user publishes many
posts in a row.
*/
$('#create_post_modal')
.on('show.bs.modal', function() {
$postFail.hide();
$postSuccess.hide();
});
// When the user type in the post textarea
$postContent.keyup(function() {
// In case he writes two posts in a row
$postSuccess.hide();
// See comments above
refreshPostWindow();
});
// When clicking on the submit button
$postSubmitButton.click(function() {
// Sending an AJAX POST request to post/create.json
// with the post content.
$.post(
'post/create.json',
{post_content: $postContent.val()}
)
.done(function(data) {
// In case the connection succeeded
/*
The action will define whether or not the
post passed validation using the data.success
variable.
*/
if (data.success) {
// If succeeded
$postFail.hide();
$postContent.val('');
refreshPostWindow();
$postSuccess.show();
} else {
// If failed, the error message will be
// defined in data.error.
$postFail
.text(data.error)
.show();
}
})
.fail(function() {
// In case the connection failed
$postFail
.text('Sorry, it seems there was an issue ' +
'somewhere. Please try again later.')
.show();
});
});
});
读取前面代码中的注释。如果你刷新主页并尝试提交新帖子,将出现消息抱歉,似乎某个地方出了问题。请稍后再试,因为我们还没有实现post/create操作。
你可能已经注意到以下行:
var postMaxNbCharacters = 140; // will be improved
这行代码有问题,因为我们在这里定义了帖子可以拥有的最大字符数,我们将在实现我们的操作(用于验证)时需要这个信息。最好的选择是只定义一次这个信息,这样,如果我们将来需要更改它,我们只需要更改一行。因此,我们将这个变量写入配置文件。
在APPPATH/config/mymicroblog.php中创建配置文件,并设置其内容为:
<?php
return array(
'post_max_nb_characters' => 140,
);
在我们的操作中,稍后访问它将很容易,但配置文件的内容目前无法通过我们的 JavaScript 代码访问。为了解决这个问题,打开位于APPPATH/views/template.php的模板视图文件,并在script标签内的$(function(){ $('.topbar').dropdown(); });之后添加以下代码行:
<?php
// Converts the mymicroblog configuration to json.
$json_configuration = Format::forge(
\Config::load('mymicroblog', true)
)->to_json();
echo ' ';
echo 'var MMBConfiguration = '.$json_configuration.";\n";
?>
然后,回到post_form.js JavaScript 文件,将var postMaxNbCharacters = 140; // will be improved替换为以下代码行:
var postMaxNbCharacters =
MMBConfiguration['post_max_nb_characters'];
当你在 JavaScript 和 PHP 代码之间有一些常见的变量和常量时,始终采用类似的解决方案是一个好主意。
实现帖子创建操作
现在我们将处理 AJAX 请求,检查发送的数据,并在一切正常的情况下创建帖子。
首先,我们需要创建Post控制器。创建APPPATH/classes/controller/post.php文件,目前将其内容设置为:
<?php
class Controller_Post extends Controller_Rest
{
}
你可以看到我们在这里扩展了一个不同的控制器类;我们不是扩展Controller_Template,而是扩展Controller_Rest。它是一个内置了 RESTful 支持的基本控制器。它将允许我们轻松实现我们将要发送的 JSON 响应,并且它还将帮助我们稍后实现 API。
为了说明这一点,添加以下测试操作:
public function action_test() {
return $this->response(array(
'test_1' => 42,
'test_2' => 'Answer to the Ultimate Question',
'test_3' => array(
'test_4' => array(
'test_5', 'test_6', 'test_7'
),
'test_8' => true,
'test_9' => null,
),
));
}
如果你现在请求以下 URL:
http://mymicroblog.app/post/test
应该出现以下输出:
请求的 REST 方法返回了一个数组或对象:
{ "test_1": 42, "test_2": "终极问题的答案", "test_3": { "test_4": [ "test_5", "test_6", "test_7" ], "test_8": true, "test_9": null } }
如果你请求以下 URL:
http://mymicroblog.app/post/test.json
它将返回:
{
"test_1":42,
"test_2":"Answer to the Ultimate Question",
"test_3":{
"test_4":["test_5","test_6","test_7"],
"test_8":true,
"test_9":null
}
}
如果你请求以下 URL:
http://mymicroblog.app/post/test.xml
它将返回:
<?xml version="1.0" encoding="utf-8"?>
<xml>
<test_1>42</test_1>
<test_2>Answer to the Ultimate Question</test_2>
<test_3>
<test_4>
<item>test_5</item>
<item>test_6</item>
<item>test_7</item>
</test_4>
<test_8>1</test_8>
<test_9/>
</test_3>
</xml>
如果你请求以下 URL:
http://mymicroblog.app/post/test.php
它将返回:
array (
'test_1' => 42,
'test_2' => 'Answer to the Ultimate Question',
'test_3' =>
array (
'test_4' =>
array (
0 => 'test_5',
1 => 'test_6',
2 => 'test_7',
),
'test_8' => true,
'test_9' => NULL,
),
)
到现在为止,你应该已经理解了,根据请求 URL 中定义的扩展名,动作将以相关格式返回结果。我建议你阅读官方文档fuelphp.com/docs/general/controllers/rest.html#/formats,以查看支持哪些格式。
可以通过打开 FuelPHP 网站,导航到 DOCS | FuelPHP | General | Controllers | Rest 来访问文档。
注意
REST 控制器的一个特定属性是它们允许你实现只对特定 HTTP 方法做出响应的动作。例如,如果我们把 action_test 方法命名为 get_test,则 test 动作将只响应 GET 请求。对于 POST、PUT、DELETE 和 PATCH 请求也是如此;再次建议你阅读关于 REST 控制的官方 FuelPHP 文档。
删除 test 动作并添加以下 create 动作:
public function action_create()
{
$post_content = Input::post('post_content');
$response = array();
if (!Auth::check()) {
// In case the user has been signed out before
// he submits his post.
$response = array(
'success' => false,
'error' => 'You are not authenticated.',
);
} else {
// Checking if the post is correct. The JavaScript
// should have tested that already, but never trust
// the client.
$val = Validation::forge('post');
$val->add_field(
'post_content',
'post',
'required|max_length[140]'
);
if ($val->run())
{
// Creating the post.
list(, $user_id) = Auth::get_user_id();
$post = Model_Post::forge();
$post->content = $post_content;
$post->user_id = $user_id;
if ($post and $post->save()) {
$response = array(
'success' => true,
);
} else {
$response = array(
'success' => false,
'error' => 'Internal error: Could'.
' not save the post.',
);
}
} else {
// The error can only occur on the only field...
$error = $val->error()['post_content'];
$response = array(
'success' => false,
'error' => $error->get_message(),
);
}
}
return $this->response($response);
}
现在如果你尝试添加一个新的有效帖子,posts 表中应该添加一行,并且应该出现以下消息:
你的帖子已成功发布!
注意
虽然我们发送的是 JSON 响应,但我们不认为 create 动作是应用程序 API 的一部分。如前所述,我们的 API 只允许只读访问,并且不需要身份验证;该动作不遵守任何这些要求。然而,如果要将它集成到 API 中,它返回 JSON 内容(以及其他格式)是一个很好的开始。
实现个人资料页面
由于我们现在可以创建帖子,展示它们将非常棒。正如我们在规范中写的,用户个人资料页面显示已发布的帖子列表,因此我们将实现它。
配置路由
我们希望在请求以下 URL 时显示此个人资料页面:
http://mymicroblog.app/USERNAME
我们可以在 User 控制器的 index 动作中添加一个参数,但这将无谓地复杂化动作。而不是这样做,我们将使用路由来透明地重定向这些 URL 到 User 控制器的 show 动作:
http://mymicroblog.app/user/show/USERNAME
要做到这一点,打开 APPPATH/config/routes.php 配置文件,并在返回数组的末尾添加以下行:
'(:segment)' => 'user/show/$1',
创建用户模型
在 show 动作内部,我们将需要从数据库中请求一个用户。我们将创建用户模型以更容易地完成此操作。创建 APPPATH/classes/model/user.php 文件,并将其内容设置为:
<?php
class Model_User extends \Orm\Model
{
protected static $_properties = array(
'id',
'username',
'password',
'group',
'email',
'last_login',
'login_hash',
'profile_fields',
'created_at',
'updated_at',
);
protected static $_table_name = 'users';
protected static $_observers = array(
'Orm\Observer_CreatedAt' => array(
'events' => array('before_insert'),
'mysql_timestamp' => false,
),
'Orm\Observer_UpdatedAt' => array(
'events' => array('before_save'),
'mysql_timestamp' => false,
),
);
}
实现显示动作
我们现在将在 User 控制器内部实现 show 动作,就像我们之前做的那样:
public function action_show($username) {
// Finding a user with a similar username
$user = Model_User::find('first', array(
'where' => array(
array('username' => $username),
),
));
if (!$user) {
Session::set_flash(
'error',
'The user '.e($username).' does not exists.'
);
Response::redirect('/');
}
// Finding 20 latest posts (will be improved)
$posts = Model_Post::find('all', array(
'related' => 'user',
'where' => array(
array('user_id' => $user->id),
),
'order_by' => array('id' => 'DESC'),
'limit' =>
\Config::get('mymicroblog.pagination_nb_posts'),
));
// Displaying the profile page
$this->template->content =
View::forge(
'user/show.mustache',
array(
'user' => $user,
/*
As Model_Post::find returns an associative
array, with ids as keys and posts as
values, we need to transform it to a
classic array, otherwise mustache will
process as an object and not the list,
hence the use of array_values.
*/
'posts' => array_values($posts),
),
// By default, mustache escape displayed
// variables...
false
);
}
我们还需要做几件事情。首先,你可以看到我们在查找帖子时指定了'related' => 'user',但我们没有在Post模型中声明这个关系。通过打开Post模型并添加以下属性来修复它:
protected static $_belongs_to = array('user');
然后,在用户模型中,你可能已经看到我们是从配置\Config::get('mymicroblog.pagination_nb_posts')中获取要加载的帖子数量。我们需要在APPPATH/config/mymicroblog.php文件中指定这个配置项。在返回的数组中,添加以下行:
'pagination_nb_posts' => 20,
但仍然存在问题;我们还没有加载配置文件,所以\Config::get('mymicroblog.pagination_nb_posts')将返回null。我们可以在同一个操作中加载配置文件,但由于我们还需要在其他地方使用它,我们将在before方法中加载它。这个方法在执行任何操作之前被调用。在User控制器的开头添加以下行:
public function before() {
parent::before();
\Config::load('mymicroblog', true);
}
如果我们想让这个操作工作,我们仍然需要实现user/show.mustache视图。
实现视图
首先,创建APPPATH/views/user/show.mustache视图文件,并设置其内容为:
<div class="col-md-3"></div>
<div class="col-md-6 profile">
<div class="row profile_informations">
<h1>
{{user.username}}
</h1>
</div>
{{> post/list}}
</div>
<div class="col-md-3"></div>
唯一的新语法是{{> post/list}};,这意味着我们想要显示post/list部分,其 PHP 等价形式如下:
echo \View::forge(
'post/list.mustache',
array(/* all current variables */),
false
);
我们将post列表分离出来,因为我们还需要在其他操作中显示它。因此,下一步的逻辑步骤是实现这个部分。创建APPPATH/views/post/list.mustache视图文件,并设置其内容为:
<div class="row post_list">
{{> post/inside_list}}
</div>
我们只实现了一个简单的div元素,并在其中调用了另一个部分。这个新的部分将只显示列表的内容。创建APPPATH/views/post/inside_list.mustache视图文件,并设置其内容为:
{{#posts}}
<div class="post">
<div class="post_content">{{content}}</div>
<div class="post_additional_infos">
By
<a
class="post_author"
href="/{{user.username}}">
{{user.username}}
</a>
·
<span
class="post_date"
data-timestamp="{{created_at}}">
</span>
</div>
</div>
{{/posts}}
要理解这一点,你需要了解一个新的 Mustache 标签。{{#posts}}和{{/posts}}标签在这里实现,用于遍历posts数组。这两个标签之间的内容将为每个帖子重复。在这个循环中显示的变量将是之前声明的变量,或者是循环中当前帖子的属性;例如,{{created_at}}是循环中当前帖子的created_at属性,但我们可以显示{{independent_variable}},这不会是当前帖子的属性,而是之前声明的变量。查看官方文档以了解变量是如何解析的(第二个链接托管在 Mustache 的 PHP 端口仓库中,但内容相当完整且清晰):
然而,如果你现在尝试访问个人资料页面,即使这个用户创建了帖子,也只会显示用户名。这是因为 Mustache 不知道在哪里找到部分。为了解决这个问题,请再次打开 User 控制器,并在 before 方法的末尾添加以下代码行:
\Parser\View_Mustache::parser()
->setPartialsLoader(
new Mustache_Loader_FilesystemLoader(
APPPATH.'views'
)
);
如果你计划在模块中使用 Mustache,调用 setPartialsLoader 方法时你需要设置其他路径。
现在我们将添加一些样式。打开 public/assets/css/website.css 文件,并附加以下代码:
.profile {
border-left: 1px solid #e8e8e8;
border-right: 1px solid #e8e8e8;
background-color: white;
}
.profile_informations {
text-align: center;
padding-top: 10px;
padding-bottom: 40px;
border-bottom: 1px solid #e8e8e8;
}
.post {
padding: 5px 10px 5px 10px;
border-bottom: 1px solid #e8e8e8;
}
.post_content {
margin-bottom: 10px;
word-break: break-all;
white-space: pre-wrap;
}
.post_additional_infos {
color: #888;
}
如果你刷新个人资料页面,现在应该会显示帖子列表。
尽管如此,仍然存在一个问题:没有显示日期。然而,你可能已经阅读了如何在 APPPATH/views/post/inside_list.mustache 视图文件中显示 created_at,如下所示:
<span>
class="post_date"
data-timestamp="{{created_at}}">
</span>
什么也看不见,但时间戳可以访问一个带有 post_date 类的 span 元素中。我们希望以相对格式(例如,5 分钟前)显示这些日期,并定期更新它们。我们将使用 JavaScript 和 jQuery 来完成这项操作。由于这是一个复杂的操作,我们将创建一个新的 JavaScript 文件。创建 public/assets/js/posts_dates.js 文件,并将其内容设置为:
/*
Converts a timestamp to relative format.
You could use plugins as jquery.timeago for doing that, and
it would probably be better that way, but we implemented
ourselves the method for being sure we won't have any
compatibility issues in the future. It is far from a perfect
solution: for instance, it supposes the client and the server
share the same time zone.
*/
function relativeFormat(timestamp) {
var timeLabels = [
{
divider: 31536000,
label: '(:nb) year ago',
label_plural: '(:nb) years ago'
},
{
divider: 2592000,
label: '(:nb) month ago',
label_plural: '(:nb) months ago'
},
{
divider: 86400,
label: '(:nb) day ago',
label_plural: '(:nb) days ago'
},
{
divider: 3600,
label: '(:nb) hour ago',
label_plural: '(:nb) hours ago'
},
{
divider: 60,
label: '(:nb) minute ago',
label_plural: '(:nb) minutes ago'
}
];
var seconds = Math.floor(
(new Date() - timestamp) / 1000);
for (var i = 0; i < timeLabels.length; i++) {
var nb = Math.floor(seconds / timeLabels[i].divider);
if (nb > 0) {
var label = timeLabels[i][
(nb == 1 ? 'label' : 'label_plural')];
return label.replace('(:nb)', nb);
}
}
return 'Few seconds ago';
}
// Refresh all posts dates
function refreshPostsDates() {
$('.post_date').each(function() {
var $this = $(this);
$this.text(
relativeFormat(
parseInt($this.data('timestamp')) * 1000
)
);
});
}
// When the DOM is ready
$(function(){
refreshPostsDates();
// Regularly refresh posts dates (every 30000ms = 30s)
setInterval(refreshPostsDates, 30000);
});
最后,我们需要将此脚本包含在位于 APPPATH/views/template.php 的模板中。在 'post_form.js' 之后添加 'posts_dates.js'。
实现 API
现在我们已经开发了个人资料页面的第一个版本,我们将开始实现访问我们网站数据的 API。
实现基控制器
由于我们需要在 User 和 Post 控制器上使用方法,我们将首先实现一个基控制器,这两个控制器都将扩展它。创建 APPPATH/classes/controller/base.php 文件,并将其内容设置为:
<?php
class Controller_Base extends Controller_Hybrid
{
}
你可以看到 Controller_Base 正在扩展一个名为 Controller_Hybrid 的新原生控制器。正如其名所示,它是一个混合版本,实现了 Controller_Template 和 Controller_Rest 的功能。如果我们想要一个动作根据上下文返回 JSON 或 HTML,这正是我们需要的。
首先,将我们在 User 控制器中实现的 before 方法移动到这个新的 Base 控制器中。
接下来,实现以下方法:
/*
Overriding the is_restful method to make the controller go into
rest mode when an extension is specified in the URL. Ex:
http://mymicroblog.com/first_user.json
*/
public function is_restful()
{
return !is_null(\Input::extension());
}
/*
Handles an hybrid response: when no extension is specified
the action returns HTML code by setting the template's content
attribute with the specified view and data, and when an
extension is specified, the action returns data in the expected
format(if available).
*/
public function hybrid_response($view, $data) {
if (is_null(\Input::extension())) {
$this->template->content =
View::forge(
$view.'.mustache',
$data,
// By default, mustache escape displayed
// variables...
false
);
} else {
$this->response($data);
}
}
每次我们想要一个混合响应(HTML、JSON 或 XML,取决于请求的扩展名),我们都需要调用 hybrid_response 方法。
最后,让 Post 和 User 控制器扩展这个新的 Base 控制器。
实现你的第一个混合动作
在 User 控制器的 show 动作中,将 $this->template->content = ...; 替换为以下代码行:
return $this->hybrid_response(
'user/show',
array(
'user' => $user,
'posts' => array_values($posts),
)
);
现在如果你请求以下 URL:
http://mymicroblog.app/USERNAME.json
(或者 http://mymicroblog.app/USERNAME.xml,因为浏览器通常显示这个格式更好)
你会看到数据现在可以访问了。问题是你可以读取太多的信息:
-
最紧迫的问题是,我们显示了每个对象的全部属性。对于用户对象来说,这是一个非常严重的问题,因为我们显示了他们的哈希密码、登录哈希、电子邮件,以及可能的其他机密信息。这是一个非常严重的安全问题。
-
我们不需要每次都显示对象的相同属性。例如,当显示用户个人资料页面时,我们可能想发布更多关于用户的信息,但当显示帖子的
user属性时,只显示用户名。这是一个不那么紧急的问题,但仍然是一个重要的问题。
实现映射器以控制信息共享方式
为了控制通过 API 发送哪些信息,我们将实现映射器,将我们的对象转换为适当的关联数组,只包含我们想要显示的属性。映射器将根据上下文以不同的方式映射对象。
创建 APPPATH/classes/mapper.php 文件,并设置其内容为:
<?php
// This class will be extended by all our mappers and
// contains general purpose methods.
class Mapper
{
/**
* Transforms an object or objects to their mapped
* associative arrays. No matter what mapper we
* will use, the idea is to always call
* Mapper_CLASS::get('CONTEXT', $objects)
*
* @param string $context The context
* @param mixed $objects Array of objects or single object
*
* @return array Array of associative array or associative
* array
*/
static function get($context, $objects) {
if (is_array($objects)) {
$result = array();
foreach ($objects as $object) {
$result[] = static::get($context, $object);
}
return $result;
} else {
return static::$context($objects);
}
}
/**
* Extracts specified properties of an object and
* returns them as an associative array.
*
* @param object $object The object to convert
* @param array $attributes The list of attributes to extract
*
* @return array The associative array
*/
static function extract_properties($object, $properties) {
$result = array();
foreach ($properties as $property) {
$result[$property] = $object->{$property};
}
return $result;
}
}
我们现在将为我们的 Post 和 User 模型创建映射器。首先,创建 APPPATH/classes/mapper/post.php 文件,并设置其内容为:
<?php
// Mapper for posts
class Mapper_Post extends Mapper
{
static function item($post) {
$result = static::extract_properties(
$post,
array('id', 'content', 'created_at')
);
$result['user'] = Mapper_User::get(
'minimal',
$post->user
);
return $result;
}
}
然后,创建 APPPATH/classes/mapper/user.php 文件,并设置其内容为:
<?php
// Mapper for users
class Mapper_User extends Mapper
{
static function minimal($user) {
return array('username' => $user->username);
}
static function profile($user) {
$result = static::extract_properties(
$user,
array('id', 'username', 'created_at')
);
/*
profile_fields is always empty, but this is just here
to illustrate that you can also send other information
than object attributes.
*/
$result['profile_fields'] = unserialize(
$user->profile_fields
);
return $result;
}
}
现在,我们只需在我们的 User 控制器的 show 动作中使用这些映射器。在动作内部,替换以下代码:
'user' => $user,
'posts' => array_values($posts),
为以下代码行:
'user' => Mapper_User::get('profile', $user),
'posts' => Mapper_Post::get('item', $posts),
现在,如果你请求以下 URL:
-
http://mymicroblog.app/USERNAME.json -
http://mymicroblog.app/USERNAME.xml
你应该看到只有有用的信息出现。你始终可以访问 http://mymicroblog.app/USERNAME,因为 Mustache 模板引擎以相同的方式处理对象和关联数组。
注意
一些开源库提供了工具,允许你以标准化和更复杂的方式执行我们用 mapper 类所做的类似工作。如果你正在寻找这样的库,我建议你查看 fractal 库,网址为 fractal.thephpleague.com/。
改进列表
个人资料网页仍然不完整,因为我们只显示了用户的最后 20 篇帖子。如果能通过添加一个“查看更多”按钮来改进这个列表,以便我们可以阅读更早的帖子,那就太好了。
我建议你生成许多帖子(你可以通过编程方式完成)在一个个人资料中,以便测试我们的界面。
给 JavaScript 访问我们的 Mustache 视图
在本节中,我们将使用 JavaScript 将 JSON 数据转换为 HTML 内容。确实,当你点击例如“查看更多”按钮时,将向我们的 API 发送 AJAX 请求,该请求将返回 JSON 数据。我们需要将此 JSON 代码转换为 HTML 内容,以便观众可以阅读它,但我们不希望有任何代码重复,因此我们将给 JavaScript 代码访问我们的 Mustache 视图。这将通过将所有 Mustache 文件的内容复制到 public/assets/js/templates.js JavaScript 文件中的对象来完成。
生成 templates.js 文件
我们将讨论两种生成template.js文件的方法。
简单而直接的方法
简单而直接的方法是在开发模式下每次有人访问您的应用程序时都重新生成此文件。为此,打开APPPATH/bootstrap.php文件,并在末尾添加以下代码行:
// Executed each time the application is requested in
// development mode
if (Fuel::$env == Fuel::DEVELOPMENT && !\Fuel::$is_cli) {
$view_directory = APPPATH.'views/';
$extension = '.mustache';
/*
The following searches for mustache files in APPPATH/views/
and saves its content into the $template associative array.
Each key will be the template relative path; for instance,
if a template is located at
APPPATH/views/dir_1/file.mustache the value of the key
will be dir_1/file.
Each value will be the template content.
*/
$templates = array();
$it = new RecursiveDirectoryIterator($view_directory);
foreach(new RecursiveIteratorIterator($it) as $file)
{
if (substr($file, -strlen($extension)) == $extension) {
// Deducing the key from the filename
// APPPATH/views/dir_1/file.mustache -> dir_1/file
$file_key = substr(
$file,
strlen($view_directory)
);
$file_key = substr(
$file_key,
0,
-strlen($extension)
);
$templates[$file_key] = file_get_contents($file);
}
}
$template_file_content = 'MyMicroblog.templates = '.
json_encode($templates).';';
// Saves the templates in the templates.js file
file_put_contents(
DOCROOT.'assets/js/templates.js',
$template_file_content
);
}
尽管我们只在开发模式下生成文件,但如果您的应用程序包含大量模板,这可能会变得不可持续;您将遇到延迟和内存问题。您可能还需要更改一些权限,以便创建文件。但在大多数情况下,您应该没问题,并且这个解决方案的一个优点是不需要任何依赖。
使用 guard-templates
您可以在请求应用程序时每次生成 JavaScript 文件,而不是使用类似于 guard-templates 的实用程序。这个想法是在您编码应用程序时启动这个实用程序,并且该实用程序将跟踪任何文件更改,并在必要时重新生成 JavaScript 文件。
备注
请注意,在撰写本文时,此实用程序似乎在 Ubuntu 上不起作用:如果您在阅读本书时仍然遇到这种情况,建议您使用我们在上一节中提供的解决方案。
您首先需要在您的计算机上安装 Ruby 和 gem。然后,您必须通过执行以下命令来安装guard-templategem:
sudo gem install guard-templates
然后,在您的网站目录根目录下执行以下命令(就像您为oil所做的那样):
guard init templates
打开位于您网站目录根目录下的生成的Guardfile文件;它包含 guard-templates 实用程序的示例配置。用以下内容替换其内容:
guard 'templates',
:output => 'public/assets/js/templates.js',
:namespace => 'MyMicroblog' do
watch(/fuel\/app\/views\/(.*)\.mustache$/)
end
理解此配置应该相当简单。如果您有任何疑问,您始终可以检查github.com/thegreatape/guard-templates的官方文档。
然后,您可以通过执行以下命令来启动guard:
guard
它将生成 JavaScript 文件,并在任何 Mustache 模板更改时重新生成它。
集成 template.js 和 Mustache.js
现在我们已经将 Mustache 模板存储到 JavaScript 文件中,我们必须将其集成到我们的网站中。
首先,我们必须安装mustache.js,这是 Mustache 的 JavaScript 端口。为了做到这一点,请访问github.com/janl/mustache.js的mustache.js存储库。
在public/assets/js/mustache文件夹中克隆存储库或下载并解压存档。
我们还将实现render函数,该函数灵感来源于 FuelPHP 的render函数(View::forge(...)->render()的等价物)。为此,创建public/assets/js/view.js文件,并将其内容设置为:
// We need to initialize the MyMicroblog for our templates
// to work
MyMicroblog = {};
// Inspired from FuelPHP's render method
function render(view, data) {
return Mustache.render(
MyMicroblog.templates[view],
data,
MyMicroblog.templates
);
}
我们现在需要在模板中包含我们的 JavaScript 文件。打开APPPATH/views/template.php文件,并在'bootstrap.js'行之后添加以下代码行:
'mustache/mustache.js',
'view.js',
'templates.js',
刷新您的网页。如果您现在打开 JavaScript 控制台(在浏览器开发者工具中),并执行:
render('user/connect', {})
您应该看到它返回正确的 HTML 代码。
实现 post/list 动作
我们还需要从服务器检索帖子数据。为此,我们将在 Post 控制器中实现两个动作:list 和 count 动作。在 Post 控制器末尾添加以下代码:
// Get the posts list depending on $_GET parameters
// limited to 20 posts maximum
public function action_list() {
$query = static::get_posts_query(Input::get(), true);
$posts = $query->limit(
\Config::get('mymicroblog.pagination_nb_posts')
)->get();
return $this->response(
Mapper_Post::get('item', $posts)
);
}
// Get the number of posts depending on $_GET parameters
public function action_count() {
$query = static::get_posts_query(Input::get(), false);
return $this->response($query->count());
}
get 和 count 动作调用 static::get_posts_query 方法。我们需要实现这个方法,我们将在 Base 控制器中完成:
// Getting the posts query
public static function get_posts_query($params) {
$user_id = Arr::get($params, 'user_id', null);
// id > since_id
$after_id = intval(
Arr::get($params, 'after_id', null)
);
// id < from_id
$before_id = intval(
Arr::get($params, 'before_id', null)
);
$query = Model_Post::query();
$query->related('user');
$query->where('user_id', '=', $user_id);
if ($after_id != 0) {
$query->where('id', '>', $after_id);
}
if ($before_id != 0) {
$query->where('id', '<', $before_id);
}
$query->order_by(array('id' => 'DESC'));
return $query;
}
现在,如果您请求以下 URL:
http://mymicroblog.app/post/list.json?user_id=ID
它将返回 id = ID 的用户的最新的 20 篇帖子。
如果您请求以下 URL:
http://mymicroblog.app/post/list.json?user_id=ID&before_id=30
它将返回 id 值小于 30 的用户 id = ID 发布的最近 20 篇帖子。
如果您请求以下 URL:
http://mymicroblog.app/post/list.json?user_id=ID&after_id=30
它将返回 id 值大于 30 的用户 id = ID 发布的最近 20 篇帖子。
现在,如果您请求以下 URL:
http://mymicroblog.app/post/count.json?user_id=ID
它将返回用户 id = ID 发布的帖子数量。
如果您请求以下 URL:
http://mymicroblog.app/post/count.json?user_id=ID&before_id=30
它将返回 id 值小于 30 的用户 id = ID 发布的帖子数量。
如果您请求以下 URL:
http://mymicroblog.app/post/count.json?user_id=ID&after_id=30
它将返回 id 值大于 30 的用户 id = ID 发布的帖子数量。
我们将在稍后使用 count 动作,当我们需要知道自我们显示用户资料以来是否有任何帖子被发布时。
为了限制代码重复,在 User 控制器的 show 动作中,将 $posts = Model_Post::find('all', ...); 替换为以下代码行:
$query = static::get_posts_query(
array('user_id' => $user->id),
true
);
$posts = $query->limit(
\Config::get('mymicroblog.pagination_nb_posts')
)->get();
注意
在控制器内部防止任何重复是可以的,但如果您需要在控制器内部实现很多与 get_posts_query 类似的代码和方法,那么您的实现可能存在问题。您应该考虑将一些代码片段移动到模型、辅助工具或库中。我必须说我对此方法应该在哪里实现犹豫了一下,但最终我决定在 Base 控制器中实现它,因为它会更方便。一般来说,要警惕控制器中的长代码块,因为它们不应该包含太多逻辑。
实现查看更多按钮
我们需要在视图中做一些更改。首先,打开 APPPATH/views/post/inside_list.mustache 并将 <div class="post"> 替换为以下行:
<div class="post" data-post_id="{{id}}">
它将使我们能够识别哪些帖子已经被显示。然后,打开 APPPATH/views/post/list.mustache 视图,将 <div class="row post_list"> 替换为以下行:
<div class="row post_list" data-user_id="{{user.id}}">
这将允许我们在请求更多帖子时知道用户标识符。然后,在{{> post/inside_list}}之后添加以下代码行:
<div class="load_more see_more">
<button type="button" class="btn btn-default btn-lg">
<span
class="glyphicon glyphicon-arrow-down"></span>
See more...
</button>
<div class="loading_message">
Loading...
</div>
</div>
现在按钮已经添加,我们需要指定点击按钮时会发生什么,因此我们将在新 JavaScript 文件中编写它。创建public/assets/js/posts_list.js文件,并将其内容设置为:
// When the DOM is ready
$(function() {
// Triggered when the user clicks on the see more button
$('body').on(
'click',
'.post_list .see_more button',
function() {
// JQuery elements initialization
var $this = $(this);
var $post_list = $this.closest('.post_list');
var $see_more = $this.closest('.see_more');
// Getting user_id and before_id (last displayed
// post id)
var user_id = $post_list.data('user_id');
var before_id =
$post_list.find('.post:last').data('post_id');
/*
Adding the loading class to the see more in order
to tell the user we are loading older posts.
*/
$see_more.addClass('loading');
// Getting the older posts
$.get(
'post/list.json',
{
user_id: user_id,
before_id: before_id
}
)
.done(function(data) {
if (data != null) {
// Displaying loaded posts
$see_more.before(
render('post/inside_list', {posts: data})
);
} else {
// Everything has been loaded, no need
// to show the See more button anymore
$see_more.addClass('all_loaded');
}
$see_more.removeClass('loading');
// Refreshing posts dates
refreshPostsDates();
})
.fail(function() {
$see_more.removeClass('loading');
alert('Sorry, it seems there was an issue ' +
'somewhere. Please try again later.');
});
}
);
// @note: we will add more code here later
});
不要忘记在模板中包含此 JavaScript 文件。打开模板,并在'posts_dates.js'之后添加'posts_list.js'。
然后,在public/assets/css/website.css文件的末尾添加以下 CSS 代码:
.load_more {
padding: 10px 0px 10px 0px;
text-align: center;
border-bottom: 1px solid #e8e8e8;
}
.loading_message,
.load_more.loading button,
.load_more.all_loaded {
display: none;
}
.load_more.loading .loading_message {
display: block;
}
当你在个人资料页面中有超过 20 篇文章时,See more按钮现在应该可以正常工作。它可以从许多方面进行完善。例如,当个人资料中的文章少于 20 篇时,按钮首先可见,但如果你点击它,它将简单地消失,因为没有更多的文章可以显示。有许多简单的方法可以解决这个问题,所以我们将把它留给你。
对于那些不太熟悉 JavaScript 的读者来说,一个可能很有用的改进是无限滚动。打开public/assets/js/posts_list.js文件,并替换:
// @note: we will add more code here later
使用以下代码:
// When the See more button appears in the screen, the following
// code triggers a click on it to load older posts, resulting in
// an infinite scroll
$(document).scroll(function() {
var $this = $(this);
var $see_more_button = $('.see_more button');
if ($see_more_button.length > 0 &&
$see_more_button.is(':visible')) {
if (
$this.scrollTop() + $(window).height() >
$see_more_button.offset().top) {
$see_more_button.click();
}
}
});
将主页重定向到登录用户的网页
当用户连接时,我们希望将主页重定向到他的网页,这样他们就可以查看他们的帖子。为了做到这一点,请转到User控制器的index动作,并替换以下行:
if (false /* is the user logged ? */) {
输入以下行:
if (Auth::check()) {
Response::redirect('/'.Auth::get_screen_name());
这不是必须的,但你可能还想在User控制器的signin动作中更改和添加一些Response::redirect调用,以使事情更加整洁(不会改变用户在登录时将被重定向两次的情况)。
单元测试
单元测试特别适合这个项目,因为定期检查 API 是否返回正确数据非常重要。在本节中,我们将快速向您介绍如何在 FuelPHP 中实现单元测试。这些测试将非常基础,因为这只是一个介绍。如果您不熟悉单元测试,可以从阅读 FuelPHP 关于单元测试的文档开始,文档地址为fuelphp.com/docs/general/unit_testing.html。
文档可以通过在 FuelPHP 网站上导航到DOCS | FuelPHP | General | Unit Testing来访问。
对于更一般的信息,你可以查看维基百科网页以获取更多参考资料(en.wikipedia.org/wiki/Unit_testing)。
简而言之,单元测试允许您测试代码中的单个单元(如方法或类),以检查它们是否按预期工作。在大多数情况下,它们会定期执行以检查项目中是否存在回归。在测试驱动开发过程中,测试甚至会在代码编写之前编写,并用作某种类型的单元规范。在该开发过程中,开发者首先在单元测试中定义方法应该如何工作,然后实现该方法并检查它是否通过了之前编写的所有测试和断言(断言是必须满足的条件)。
单元测试应与集成测试、测试一组单元及其如何协同工作的功能测试以及检查项目是否遵循其功能要求的验收测试以及检查最终用户访问的功能是否按预期工作的验收测试分开。
在编写单元测试时,您至少应该尝试遵循以下指南:
-
每个单元测试应一次只测试单个代码单元(通常是方法,但有时是类)。
-
尽量少写断言来测试功能,因为不必要的断言会导致可维护性降低。
-
测试应相互独立。例如,您不应编写一个假设另一个单元测试已经运行的单元测试。
-
每个单元测试的目的都应该是清晰的:其名称应该是明确的,代码应该是易于理解的(不要犹豫使用注释)。
现在让我们实际看看如何在 FuelPHP 中运行单元测试。
首先,您需要安装 PHPUnit。为此,请输入以下命令行:
php composer.phar require phpunit/phpunit:4.4.*
当 PHPUnit 下载并安装时,创建APPPATH/config/oil.php配置文件,并将其内容设置为:
<?php
return array(
'phpunit' => array(
'autoload_path' =>
VENDORPATH.'phpunit/phpunit/PHPUnit/Autoload.php',
'binary_path' => VENDORPATH.'bin/phpunit',
),
);
一旦安装了 PHPUnit,您就可以启动测试。首先,只需执行以下命令行:
php oil test
输出将类似于以下内容:
Tests Running...This may take a few moments.
...
Time: 512 ms, Memory: 20.25Mb
OK (375 tests, 447 assertions)
如您所见,已有375个测试存在,并且php oil test命令行执行了所有这些测试。这些测试都在 FuelPHP 核心中,可以在fuel/core/tests目录中找到。
我们将创建自己的测试。创建APPPATH/tests/examples.php文件,并将其内容设置为:
<?php
namespace Fuel\App;
/**
* Examples tests
*
* @group App
*/
class Test_Examples extends \TestCase
{
// This method is executed before all tests are executed.
// If your unit test require some initialization, you can
// do it here.
public static function setUpBeforeClass() {
\Config::load('mymicroblog', true);
// Executing migrations (we are on a test database)
\Migrate::latest('auth', 'package');
\Migrate::latest();
// Truncating the tables since we might already have data
\DBUtil::truncate_table('users');
\DBUtil::truncate_table('posts');
// Generating test data
\Auth::create_user(
'first_user',
'test',
'email@email.com'
);
for ($i = 1; $i < 100; $i++) {
$post = \Model_Post::forge(array(
'content' => 'post 1',
'user_id' => 1
));
$post->save();
}
// ...
}
/**
* Tests the User mapper.
*
* @test
*/
public function test_extract_properties() {
$object = new \stdClass();
$object->a = '1';
$object->b = 2;
$object->c = true;
$res = \Mapper::extract_properties(
$object,
array('a', 'c')
);
$expected_res = array('a' => '1', 'c' => true);
$this->assertEquals($res, $expected_res);
// A lot more should be tested...
}
/**
* Tests the User mapper.
*
* @test
*/
public function test_user_mapper() {
// Getting any user.
// Note: In order not to depend on the database and on
// the ORM, you might want to create mock users objects
// (simulated users objects) and test features on these
// objects instead...
$user = \Model_User::find('first');
// Testing that the profile context returns 4
// attributes
$profile = \Mapper_User::get('profile', $user);
$this->assertCount(4, $profile);
// Testing that the minimal context returns 1 attribute
$minimal = \Mapper_User::get('minimal', $user);
$this->assertCount(1, $minimal);
// A lot more should be tested...
}
// This method is executed after all tests have been
// executed
static function tearDownAfterClass() {}
}
所有以test开头的所有方法将在运行此测试文件时执行。阅读前述代码中的注释,并阅读 PHPUnit 的官方文档以获取更多信息(phpunit.de)。
当您运行测试文件时,FuelPHP 处于测试环境。因此,您必须在APPPATH/config/test/db.php文件中配置数据库访问。建议您为单元测试创建一个单独的数据库。
现在,通过执行以下命令行仅运行您的应用程序测试:
php oil test --group=App
输出将类似于以下内容:
Tests Running...This may take a few moments.
..
Time: 22 ms, Memory: 18.50Mb
OK (2 tests, 3 assertions)
测试已正确执行。然而,正如本节开头所解释的,我们编写了非常表面的测试。如果您想对您的应用有良好的测试覆盖率,您将需要编写更多的测试。
可能的改进
首先,您应该像我们在第三章中做的那样,保护所有表单免受跨站请求伪造(CSRF)攻击。由于您正在使用 Mustache 模板,您在这里需要做一些不同的操作(例如,您需要以纯 HTML 的形式编写 CSRF 输入)。我建议您阅读官方文档fuelphp.com/docs/general/security.html#csrf。
您可以通过导航到DOCS | FuelPHP | 通用 | 安全在 FuelPHP 网站上访问文档。
其次,如果您想在外部网站上使用 JavaScript 轻松地提供 API,您必须将 Access-Control-Allow-Origin 头设置为*。这可以在Base控制器中的before方法中完成。
接下来,我们只在应用的 JavaScript 部分使用了post/inside_list部分,但我们本可以做更多。例如,由于所有数据都是可用的,当我们点击用户名时,我们本可以加载 JSON 数据并使用我们的部分来显示个人资料页面,而不是加载个人资料页面的 HTML 版本。
我们的小型微博应用仍然非常基础。然而,我们可以管理订阅、通知、提及和直接消息;允许用户搜索帖子和其他用户;自动转换帖子中的 URL;改进用户界面...
摘要
在本章中,我们构建了一个基本的微博应用,它支持用户订阅、认证、帖子创建和个人资料页面等几个功能。我们已经看到,如果处理得当,API 可以实现而无需任何代码重复和大量努力。我们还使用了 Mustache,这是一个与语言无关的模板引擎,它允许我们在服务器(PHP)和客户端(JavaScript)两侧使用相同的视图。最后,我们使用了单元测试来检查我们的应用功能是否按预期运行。
在下一章中,我们将向您介绍基于 FuelPHP 框架的内容管理系统 Novius OS。
第六章:使用 Novius OS 构建网站
在本章中,我们将向您介绍Novius OS,这是一个基于 FuelPHP 的开源内容管理系统(或 CMS)。使用 Novius OS 可以极大地简化网站的实施和管理。它的后台办公包括重要且用户友好的功能,如网页、菜单、模板、应用程序、用户和权限管理;它目前提供六种语言(英语、法语、日语、俄语、西班牙语和世界语)。如果您想轻松构建一个非程序员用户可以轻松管理的复杂网站,它是一个出色的工具。
到本章结束时,您将了解:
-
如何安装和配置 Novius OS
-
Novius OS 的基本功能和如何使用它们来创建您的网站
-
Novius OS 文件系统层次结构
-
什么是 Novius OS 应用程序,如何生成一个,以及其主要组件
关于 Novius OS
Novius OS 是一个基于 FuelPHP 的开源 CMS,由 jQuery UI 和 Wijmo 库提供支持。它于 2011 年 12 月由法国里昂的一家小型网络公司 Novius 正式推出。设计和实施此项目的核心团队由一位用户体验设计师 - Antoine Lefeuvre - 和三位工程师 - Gilles Félix、Julian Espérat 和我 - 组成。然而,该软件也收到了开源社区的大量贡献。
在本章中,我们将假设您使用的是 Novius OS 的版本 5.0.1(Elche)(写作时的当前稳定版本)。这个 CMS 的官方网站可以在www.novius-os.org/找到。
它的官方文档可在以下网址找到:
注意
我在这本书中包含了对 Novius OS 的介绍,因为它基于 FuelPHP,我认为你们中的一些人可能会觉得这个系统很有用。尽管如此,由于我参与了该项目,我意识到我的观点可能会有所偏颇,这就是为什么这一章只是关于它的简短介绍。
获取 Novius OS
Novius OS 的要求与 FuelPHP 的要求相似:
-
PHP 5.3 或更高版本
-
MySQL
-
Apache 已启用 mod_rewrite
-
Windows (> Vista)、Linux 或 Mac OS
安装说明可在以下网址找到:
docs.novius-os.org/en/elche/install/install.html
建议您遵循通过 Zip 文件安装部分中的说明(最简单和通用的解决方案)。如果您在 Linux 或 Mac OS 上进行开发,您可能希望遵循安装部分中的说明,因为它将下载此版本的最新修复。
你也可以配置一个虚拟主机,就像我们在前几章中为 FuelPHP 所做的那样(参考高级安装部分)。然而,我们将假设你只下载并解压了 Novius OS 到你的 web 服务器根目录下的 novius-os 文件夹中(DOCUMENT_ROOT/novius-os)。
配置 Novius OS
如果你输入 URL http://localhost/novius-os,Novius OS 安装向导就会出现。按照指示操作;它将首先检查你的服务器配置是否与 Novius OS 兼容,其次要求你提供数据库配置(你首先需要创建一个数据库),然后要求你提供一些信息以创建第一个用户账户(这是连接到 Novius OS 后台办公室所必需的),最后,询问你希望你的网站提供哪些语言。
你现在可以点击 进入后台并登录。
探索 Novius OS
在本节中,你将通过探索界面来了解 Novius OS 的主要功能。如果你之前从未使用过 Novius OS,这一步非常重要,因为如果你不知道如何使用 CMS,就无法理解实现细节。
以下登录网页将显示:

输入你在安装步骤中定义的凭据。
应用程序管理器
一旦连接,应用程序管理器就会出现:

应用程序允许你为 Novius OS 添加新功能。例如,博客应用程序扩展了 Novius OS 的核心功能,为你的网站添加了一个完整的博客解决方案。如果你已经使用过其他 CMS,Novius OS 的应用程序与它们的模块或扩展同义。建议你阅读以下网址提供的关于应用程序和应用管理器的官方文档:
当你安装 Novius OS 时,已经有一些应用程序可用。然而,我们稍后会看到,你可以添加其他应用程序或创建自己的应用程序。Novius OS 的核心功能大多数是通过原生应用程序实现的,这些应用程序直接包含在核心中。你现在可以理解到,应用程序是 Novius OS 的一个关键特性,并且它们做了大部分的工作。
目前,大多数应用程序都只是可用但未安装。如果你想激活可用应用程序的功能,你必须安装它。点击 博客 旁边的 安装 按钮,以激活博客的功能。以下消息将出现在屏幕右上角:

在 已安装的应用程序 部分中,现在将显示以下三个应用程序:

您可以看到,博客 应用程序已安装,但 BlogNews 和 Comments 也随它一起安装。这是因为这些应用程序是依赖项;博客 应用程序需要它们两个。Novius OS 允许应用程序相互依赖,并且系统会尝试管理任何潜在冲突。例如,您不能在不先卸载 博客 应用程序的情况下卸载 BlogNews 应用程序。
Novius OS 桌面
要离开应用程序管理器,请点击屏幕左上角的 Novius OS 图标:

现在,Novius OS 桌面将显示:

桌面显示称为 启动器 的图标,可以点击;它们通常允许您访问应用程序。例如,如果您点击 应用程序管理器,您将返回我们之前看到的屏幕。
Novius OS 的前端和默认主页
如果您访问您的网站主页(http://localhost/novius-os),您将只能看到默认的 Novius OS 主页,因为我们没有定义任何内容。通过请求返回管理面板:
Webpages 应用程序
Novius OS 的另一个重要特性是管理您的网页的能力。为了做到这一点,点击 Webpages 启动器,它将显示网页管理面板。一个空表格将出现,底部有几个按钮。点击 添加页面 按钮以创建您的第一个页面。然后会出现网页创建表单:

在这种情况下,您应该更改的三个字段是标题、内容和发布设置。
标题字段将定义您的网页的元数据标题。通常,此标题也将显示在网页内容上方。
内容字段将定义您的网页的核心内容。它是一个 WYSIWYG 字段,因此您还可以格式化文本或添加图片等功能。
发布设置允许您定义内容是否对访客可见。您可以使用 标题 字段下方的三个小图标进行更改:

如果左侧按钮处于活动状态,如前一张截图所示,则网页不会被发布;也就是说,网页对访客不可见。右侧按钮允许您立即发布内容(只要您点击 保存/添加 按钮)。中间按钮允许您安排内容发布的时间。现在点击右侧按钮:

点击 添加 按钮以保存更改并创建新的网页。
您可能已经注意到,自从我们点击了 Webpages 启动器后,屏幕上方的部分出现了新的标签页:

Novius OS 界面是围绕标签设计的。就像浏览器的标签一样,Novius OS 的标签允许您打开多个管理页面。尽管这对新用户来说可能会造成不稳定,但当您需要同时管理多个元素时,这种标签导航系统可能会很有用。
第一个标签包含 应用程序管理器 应用程序。由于我们不再需要它,请点击它,然后点击交叉按钮关闭它。
点击新的第一个标签(没有任何标题的标签),您现在将回到网页管理面板。然而,您新创建的页面现在将在表格中可见:

在网页标题旁边的房子图标表示这是主页。我们没有指定它,但 Novius OS 会自动选择第一个创建的网页作为主页。
在表格的每一行中,您都可以看到右下角有小的按钮。这些按钮允许您应用单个操作。在我们的例子中,第一个按钮允许您编辑网页,第二个按钮可以可视化它,第三个按钮显示一个下拉菜单,将显示更多操作。
如果您现在请求主页(http://localhost/novius-os),您将看到以下屏幕:

我们的内容在底部可见,但也有很多示例内容,我们当然希望删除。
Novius OS 模板
您在网页中定义的内容将在模板内部显示,这与在 FuelPHP 中使用 模板 控制器时在模板内显示的视图类似。如果您回到网页编辑表单,您会注意到有一个名为 模板变体 的字段,其值设置为 Bootstrap 可定制模板。因此,要删除示例内容,我们必须编辑 Bootstrap 可定制模板。
为了完成这个操作,请回到 Novius OS 桌面(通过点击屏幕左上角的 NOS 图标),然后点击 模板变体 启动器。

我们将在下一节讨论这个管理界面。首先,点击 Bootstrap 可定制模板 项中的编辑按钮(由铅笔图标表示)。将打开一个新标签,如下截图所示:

每个齿轮图标都允许您编辑模板的特定部分。请随意根据您的需求调整模板。
您还可以通过安装 Novius OS 默认模板 应用程序(这个配置较少)、外部应用程序或创建自己的应用程序来添加其他模板模型。
您会注意到一些模板部分显示以下菜单字段:

菜单字段允许你在网站上生成菜单。默认情况下,也就是说如果没有选择菜单,将构建一个默认菜单,从具有显示在菜单中配置的网页中构建(你可以在网页编辑表单中更改此配置)。你还可以通过点击管理桌面上的网站菜单启动器来创建自定义菜单。
注意
当你完成模板更改后,你可能需要刷新页面缓存。这可以通过在网页管理界面中点击刷新页面缓存链接来完成。一般来说,如果你发现尽管你在后台更新了内容,但你的网页没有变化,刷新这个缓存是个好主意。
App Desk
如果你回到模板变体管理界面(你可以通过标签或点击NOS图标然后点击模板变体启动器来完成此操作),你会看到管理面板被分为三个部分:
-
上左部分列出了所有模板的原模型。
-
上右部分列出了所有变体,即我们为我们的网站适配的模板。
-
底部包含操作按钮。
你甚至可以通过点击顶部按钮来更改右上部分显示的方式:

点击列表按钮时,这样显示管理面板:

你可以注意到用户界面与网页管理界面非常相似。实际上,Novius OS 的大多数应用程序都将有一个通用的用户界面,因为核心提供了可以轻松重用的通用组件。你现在看到的主要管理界面,即你正在查看的界面,扩展了App Desk组件。通过最小配置,你可以以有组织和标准化的方式显示和管理你的应用程序数据。为了方便起见,这样的管理界面也被称为 App Desk。
这是当前 App Desk 的标准布局:

你可以注意到有 3 个组件,如下所示:
-
主要内容显示在主网格中。正如我们之前看到的,通常可以通过不同的方式显示主网格(列表、缩略图、层次结构等)。
-
在左侧,检查器显示相关内容(例如博客文章的分类),并过滤主网格的内容(例如,它可以过滤属于特定类别的博客文章)。
-
在底部,按钮允许用户执行一般操作,如创建新项目或刷新缓存。如果内容依赖于语言(例如,博客文章将包含英文、法语、西班牙语等内容),语言选择框也可能显示。
建议您查看有关 UI 指南的官方文档,可在 docs.novius-os.org/en/elche/understand/ergonomy.html 找到。
在您的网页中插入增强器
您可能还记得我们之前安装了 博客 应用程序。此应用程序允许您在网站中插入完整的博客解决方案。让我们看看在 Novius OS 中是如何操作的。
首先,返回 网页 管理标签页,创建一个标题为 博客 的新页面。保存并发布。如果您在右侧菜单(或手风琴)中点击 URL(页面地址) 标签,您将看到以下内容:

此字段指定了网页的相对 URL。在这种情况下,您可以通过输入以下 URL 访问您创建的网页:
http://localhost/novius-os/blog.html
博客 应用程序,就像大多数在网站前端显示的 Novius OS 应用程序一样,可以插入到网页内容中。为此,点击 内容 WYSIWYG 输入。您将在顶部看到一个工具栏:

点击 应用程序 按钮,然后点击 博客:

然后将出现一个配置框;您不需要更改任何内容:

当您点击 插入 按钮,以下框将出现在 WYSIWYG 字段内:

此框表示 博客 应用程序已插入到您的网页中。为了使用正确的术语,您插入了 博客增强器。增强器是一个应用程序组件,可以插入到大多数 WYSIWYG 字段中,以便在前端显示内容。一个应用程序可以有多个增强器,就像您在点击 应用程序 按钮时看到的;您可以选择 链接到博客文章(例如,用于侧边栏) 或 博客。这两个增强器都属于 博客 应用程序,但它们以不同的方式显示博客。
如果您再次请求 URL http://localhost/novius-os/blog.html,您将不会看到任何变化,这是正常的;您必须首先创建博客文章。在 Novius OS 管理面板中,返回桌面,点击 博客 启动器,并添加几篇博客文章(别忘了发布它们)。
如果您再次请求博客网页,您将看到以下文章:

您可以通过点击文章的标题来查看特定的博客文章。如果您这样做,您将看到一个更完整的文章视图,并且 URL 将如下所示:
http://localhost/novius-os/blog/POST_TITLE.html
如您所见,博客增强器不仅显示内容,还创建额外的 URL。这是因为它是一个 URL 增强器,一种特殊的增强器,可以响应额外的 URL。具体来说,如果一个 URL 增强器托管在位于 http://localhost/novius-os/PAGE.html 的网页上,它也可以响应任何类似 http://localhost/novius-os/PAGE/ANY_STRING.html 的 URL。
当然,URL 增强器可以根据其实施和配置对每个 URL 做出不同的响应。
Novius OS 文件系统层次结构
现在我们已经了解了 Novius OS 及其界面的基础知识,让我们深入了解我们安装 Novius OS 的目录。在撰写本文时,Novius OS(Elche)的当前版本具有以下目录层次结构:
-
/local: 这个文件夹包含所有特定于网站的代码、配置和应用程序。它包含以下文件夹:-
/local/applications: 这个文件夹包含所有可用的非核心应用程序。 -
/local/cache: 这里所有的文件都允许 Novius OS 及其应用程序缓存数据,以提高网站的性能。 -
/local/classes: 这包括网站使用的类,这些类不属于核心或任何应用程序。 -
/local/config: 这包括配置文件,包括 FuelPHP 主配置文件和数据库配置文件。 -
/local/data: 这些是由 Novius OS 及其应用程序创建的数据文件。 -
/local/metadata: 这些是由 Novius OS 创建的文件。与/local/data不同,此文件夹内的文件仅在应用程序安装、升级或卸载时更改。 -
/local/views: 这些是网站使用的视图。您可以在该文件夹内创建文件以覆盖应用程序视图。
-
-
/logs: 这包含日志文件。它与 FuelPHP 的日志文件夹类似。 -
/novius-os: 这是 Novius OS 核心,您不应该更改其中的任何内容。其中还包括 FuelPHP 核心和包。 -
/public: 这个目录对外部访客是可访问的。你可以在这里添加公开的文件(CSS、JS...)。
应用程序文件夹结构
在 /local/applications 目录内,每个文件夹都是一个应用程序。为了您的信息,您应该知道 Novius OS 所称的应用程序实际上是改进后的 FuelPHP 模块。如果您查看这些文件夹,您将看到以下结构:
-
/classes: 这些是应用程序使用的类。-
/classes/controller: 应用程序的控制器。 -
/classes/menu: 应用程序关于菜单的信息。 -
/classes/model: 应用程序的模型。
-
-
/config: 这些是应用程序的配置文件。以下是最重要的几个:-
/config/metadata.config.php: 这是元数据配置文件。它包含关于应用程序的所有关键信息:名称、图标、描述、依赖项、启动器、增强器等。 -
/config/permissions.config.php:这允许应用程序处理自定义权限。
-
-
/lang:应用程序的翻译文件。 -
/migrations:应用程序的迁移文件。 -
/static:这是公共文件夹的等价物,但特定于应用程序。例如,如果 Blog 应用程序(位于local/applications/noviusos_blog)已安装,则local/applications/noviusos_blog/static/img/blog-16.png文件可以通过http://localhost/novius-os/static/apps/noviusos_blog/img/blog-16.png访问。 -
/views:应用程序的视图文件。
文件扩展名
你可能已经注意到一些文件具有以下后缀:
-
模型的文件名以
.model.php结尾 -
控制器的文件名以
.ctrl.php结尾 -
配置的文件名以
.config.php结尾 -
视图的文件名以
.view.php结尾
这是 Novius OS 的一个约定,旨在增强开发者的体验。它之所以被实施,是因为开发者通常使用相同的文件名来命名文件(例如,一个 post.php 控制器,一个 post.php 视图,和一个 post.php 配置文件),如果他们在 IDE 的多个标签页上打开它们,大多数时候他们不知道他们正在寻找的文件在哪个标签页上。这是一个可选的约定,它不会改变文件执行的方式。
配置和类
另一个重要的约定涉及配置和类文件的位置。由于开发者经常需要为控制器和模型编写配置,因此配置文件路径与类文件路径相关。例如,在一个应用程序中,classes/controller/front.ctrl.php 控制器可以使用 config/controller/front.config.php 配置文件进行配置。如果控制器扩展了 Novius OS 的默认控制器之一,配置文件将自动加载。
一般而言,config/FILE_PATH 配置文件将与 classes/FILE_PATH 类文件相关联。这样,当你想了解其他人实现的应用程序时,你可以轻松地知道每个配置文件与哪个类相关联。
创建应用程序
要进一步理解 Novius OS 的工作原理,唯一的方法是创建一个应用程序。首先,我们将使用 Novius OS 的 'Build you app' 向导生成一个应用程序,这个向导试图实现与 oil generate 工具相同的目标,但它生成的是 Novius OS 应用程序而不是 FuelPHP 框架的脚手架。然后,我们将查看生成的大多数文件,并看看当我们调整它们时会发生什么。
安装 'Build your app' 向导
应用程序可用但需要安装。为此,请转到 Novius OS 桌面,点击 应用程序管理器 启动器,然后在 可用 应用程序下,点击 'Build your app' 向导 旁边的 安装 按钮。
生成应用程序
返回 Novius OS 桌面并点击'构建您的应用程序'向导启动器。将出现一个表单。正如第一章中所述,构建您的第一个 FuelPHP 应用程序,我们将生成一个将管理我们动物园猴子的应用程序。
首先,在关于应用程序下,将应用程序名称字段的值设置为我的第一个应用程序。应用程序文件夹和应用程序命名空间字段应自动完成,但您可以根据需要更改它们。
接下来,在模型下,将名称字段设置为Monkey,因为我们想生成一个Monkey模型。表名和列前缀字段应自动完成。
由于我们想要发布应用程序的内容,请勾选URL 增强器复选框。我们还想精确选择我们想在网站上显示的猴子,因为有些猴子可能暂时在动物园里,所以勾选可发布行为复选框。最后,我们想知道哪个用户将猴子输入到应用程序中,所以勾选作者行为复选框。
现在表单将看起来如下:

您现在可以点击下一步。
在这里,我们将定义管理表单的布局(我们在创建和编辑猴子时将使用的表单)。布局由字段组定义,这些字段组可以分为两种类型:
-
主要列字段组:这些包含您模型的最重要信息(或需要最多空间的信息),因此它们始终可见,并占据表单的大部分区域。通常,这些字段组将包含 WYSIWYG 编辑器或非常重要的字段。
-
侧边栏字段组:这些包含不需要太多空间的相关信息。它们将出现在屏幕右侧的菜单(或手风琴)中。
默认创建的第一个主要列字段组名为Properties。通过点击下一步按钮旁边的添加另一个字段组来创建一个新的字段组。将其标题字段设置为Additional informations,并将其类型设置为Side column。现在表单应该看起来如下:

您现在可以点击下一步。
我们现在将定义我们的模型字段和属性。对于第一个字段,将其标签属性定义为Name,列名属性应自动完成,然后勾选是否为表单标题复选框(因为这个字段将被用作标题)。
点击添加另一个字段。对于新字段,将标签属性定义为Still here,在类型选择框中选择复选框,勾选在 App Desk 中显示复选框,然后在字段组选择框中选择Additional informations (Side column)。
点击添加另一个字段。对于新字段,将标签属性定义为Height,勾选在应用程序桌面中显示复选框,然后在字段组选择框中选择附加信息(侧栏)。
点击添加另一个字段。对于新字段,将标签属性定义为Description,然后在类型选择框中选择WYSIWYG 文本编辑器。
表单的结尾应如下所示:

您现在可以点击下一步。以下对话框将出现:

由于我们想要安装此应用程序,所以不要更改任何内容。
点击生成按钮。您将看到一个确认消息出现,其中包含各种链接到文档,以帮助您改进生成的应用程序。建议您查看此文档。
测试您生成的应用程序
返回 Novius OS 桌面并点击Monkey启动器。您将看到一个空的应用程序桌面出现。点击添加 Monkey按钮,创建表单将如您在“构建您的应用程序”向导表单中配置的那样出现。创建尽可能多的猴子,您将看到应用程序桌面逐渐填满。
由于我们勾选了URL 增强器复选框,我们的内容可以在网页上显示。返回 Novius OS 桌面,点击网页启动器,然后点击添加页面按钮。将网页的标题设置为Monkey,将其发布状态设置为已发布。接下来,为了添加我们的应用程序 URL 增强器,点击内容WYSIWYG。然后点击应用程序,然后点击我的第一个应用程序 Monkey。最后,通过点击添加按钮保存网页。
现在,如果您检查 URL http://localhost/novius-os/monkey.html,您将看到您的猴子列表:

如果您点击列表中的某个项目,将出现更详细视图:

在这种情况下详细视图的 URL 将如下所示:
http://localhost/novius-os/monkey/my-first-monkey.html
如您所见,通过使用“构建您的应用程序”向导选项,并且只填写很少的输入,我们创建了一个完整的应用程序框架。与oil generate实用程序一样,每次您想要实现一个应用程序时都应该使用向导,因为它将加快您的开发过程,并且您将有一个良好的基础开始。
应用程序基础知识
我们将使用此生成的应用程序来描述在 Novius OS 中应用程序的工作方式。我们不会过多地深入细节,但这应该足以让您开始并知道在哪里查找更多信息。
我们生成的应用程序可以在local/applications/my_first_application目录中找到。我们将查看的所有文件都位于文件夹内。
元数据配置文件
应用程序名称、依赖项、图标、启动器、增强器在哪里定义?
所有这些基本信息都包含在config/metadata.config.php中。这是创建应用程序所需的唯一文件。如果您打开此配置文件,应用程序的名称由name键定义,其命名空间由namespace键定义,其启动器由launchers键定义。这很简单,您可以在官方文档中了解更多信息,该文档可在docs-api.novius-os.org/en/elche/php/configuration/application/metadata.html找到。
迁移文件
迁移文件位于migrations文件夹中,并在应用程序安装时执行。它们可以像正常的 FuelPHP 迁移文件一样实现,但如果您打开migrations/001_install.php,您将看到它是空的:
<?php
namespace MyFirstApplication\Migrations;
class Install extends \Nos\Migration
{
}
这是因为migration文件扩展了\Nos\Migration。默认情况下,up方法将尝试执行与migration文件具有相似名称的 SQL 文件,在我们的例子中是migrations/001_install.sql。如果您打开此 SQL 文件,您将看到它只是创建了monkeys表。
App desk
您的应用程序的 App Desk 是从以下 URL 加载的(如果您想检查,可以使用浏览器开发者工具):
http://localhost/novius-os/admin/my_first_application/monkey/appdesk
一般而言,当您输入http://WEBSITE/admin/APPLICATION_FOLDER/CONTROLLER_PATH(/ACTION)时,位于local/applications/APPLICATION_FOLDER/classes/controller/admin/CONTROLLER_PATH的控制器将执行ACTION动作。
因此,在我们的情况下,URL 执行了my_first_application应用程序中controller/admin/monkey/appdesk.ctrl.php控制器的index动作(因为如您所记得的,当 URL 中没有定义动作时,FuelPHP 将执行index动作)。让我们打开这个控制器:
<?php
namespace MyFirstApplication;
class Controller_Admin_Monkey_Appdesk extends \Nos\Controller_Admin_Appdesk
{
}
再次强调,您可以看到一个空类。所有动作都定义在由我们的控制器扩展的\Nos\Controller_Admin_Appdesk类中。尽管返回的列表不是通过某种魔法过程自动生成的,但它是由配置文件生成的。
如您可能记得,我们之前提到配置文件路径与类文件路径相关。因此,我们可以在config/controller/admin/monkey/appdesk.config.php找到我们的控制器配置文件。如果您打开此文件,您将看到以下代码片段(注释已被删除):
<?php
return array(
'model' => 'MyFirstApplication\Model_Monkey',
'search_text' => 'monk_name',
);
它定义了必须由App Desk显示的模型以及当在搜索栏中写入内容时要扫描的列。您可以定义更多键,如检查器或查询。建议您阅读官方文档以了解更多关于此配置文件的信息:
docs-api.novius-os.org/en/elche/php/configuration/application/appdesk.html
这里定义的配置只是一个起点,但肯定不足以显示整个应用桌面。大部分必要的信息定义在config/common/monkey.config.php配置文件中(注释已被删除):
<?php
return array(
'controller' => 'monkey/crud',
'data_mapping' => array(
'monk_name' => array(
'title' => __('Name'),
),
'monk_still_here' => array(
'title' => __('Still here'),
'value' => function($item) {
return $item->monk_still_here ? __('Yes') :
__('No');
},
),
'monk_height' => array(
'title' => __('Height'),
),
'publication_status' => true,
),
);
如您所见,显示的列定义在data_mapping键中。每一列的标题由title键定义,除了publication_status,它是一个特殊情况。行值要么由键确定,要么由value回调确定。具体来说,应用桌面的每一行将显示以下属性:
-
在名称列下,
monk_name属性 -
在仍在列下,根据
monk_still_here属性,显示是或否 -
在高度列下,
monk_height属性
为了训练自己,尝试更改一个列标题或添加一个value回调。
我们只是触及了表面,建议您阅读官方文档docs-api.novius-os.org/en/elche/php/configuration/application/common.html。
编辑和创建表单
如果您创建或编辑一个猴子,您将看到 Novius OS 将请求以下 URL:
http://localhost/novius-os/admin/my_first_application/monkey/crud/insert_update(/ID)
因此,我们可以推断出classes/controller/admin/monkey/crud.ctrl.php控制器的insert_update动作被调用。如果您打开控制器,您将看到,正如您所猜想的,一个空类。同样,一切都在扩展的\Nos\Controller_Admin_Crud控制器中定义。
如果您阅读相关的config/controller/admin/monkey/crud.config.php配置文件,您将看到它定义了编辑和创建表单的布局和字段。所有字段都定义在fields键中。
为了训练自己,您可以通过编辑它们的label键来更改一些字段标签。
同样,我们只是触及了表面。建议您阅读官方文档docs-api.novius-os.org/en/elche/php/configuration/application/crud.html。
前端控制器
现在我们已经看到了后台办公室的工作方式,我们必须看看我们的 URL 增强器是如何工作的。
如您所回忆的那样,增强器是在config/metadata.config.php配置文件中声明的:
'enhancers' => array(
'my_first_application_monkey' => array(
'title' => 'My first application Monkey',
'desc' => '',
'urlEnhancer' => 'my_first_application/front/monkey/main',
),
),
再次提醒,建议您阅读关于元数据的官方文档。这里有趣的关键词是 urlEnhancer;如果您将增强器插入到网页中,每次网页显示时,Novius OS 都会触发一个对 urlEnhancer 的内部 HMVC 请求并显示返回的内容。在我们的例子中,当您显示 http://localhost/novius-os/monkey.html 网页(以及其中的猴子增强器),Novius OS 将内部请求 my_first_application/front/monkey/main 并显示返回的内容。
如您所猜,它调用 Front_Monkey 控制器的 main 操作。打开 classes/controller/front/monkey.ctrl.php,查看其 action_main 方法。您会看到该方法根据 $enhancer_url 变量返回单个猴子视图或列表。此变量在操作的开始处定义:
$enhancer_url = $this->main_controller->getEnhancerUrl();
让我们以我们的例子说明 $this->main_controller->getEnhancerUrl() 方法返回的内容:
-
如果您请求
http://localhost/novius-os/monkey.html,它将返回一个空字符串 -
如果您请求
http://localhost/novius-os/monkey/first.html,它将返回first -
如果您请求
http://localhost/novius-os/monkey/one/two.html,它将返回one/two
您明白了;它允许控制器知道在显示增强器时请求的是相对于网页 URL 的哪个 URL。现在这就有意义了;如果您请求 monkey.html 根网页,操作将返回列表,否则它将尝试找到具有类似 URL 的猴子。
如果您查看 display_list_monkey 和 display_monkey 方法,您可能会希望不会感到困惑,因为它们只包含 FuelPHP 代码(ORM、View::forge 等)。您可以看到它们分别显示位于 views/front/monkey_list.view.php 和 views/front/monkey_item.view.php 的 front/monkey_list 和 front/monkey_item 视图。为了训练自己,您可以尝试稍微调整它们。
注意
如果您编辑了视图,刷新网页但没有发生任何变化,您可能需要刷新 Novius OS 网页缓存。为此,请返回到 Novius OS 的后台,打开 Webpages App Desk,然后点击 Add a page 按钮旁边的 Renew pages' cache。
Front_Monkey 控制器中的 getUrlEnhanced 方法允许 Novius OS 将猴子实例映射到 URL。
再次强调,我们只是触及了表面。建议您阅读官方文档 docs.novius-os.org/en/latest/app_create/enhancer.html 以了解更多信息。
更多关于 Novius OS 的信息
我们没有涉及很多非常重要的功能,如应用程序扩展、行为、可配对行为、数据共享者和权限,但这将需要写另一本关于 Novius OS 的完整书籍。再次建议您阅读官方文档以了解更多关于这些功能的信息:
如果您有任何问题或遇到问题,您也可以在以下社区论坛寻求帮助:
摘要
现在,您应该已经对使用 Novius OS 能做什么有了概念。请注意,这只是一个简单的介绍:您应该阅读文档,以便更好地理解这个有潜力的 CMS。
在这次旅程中,通过实施各种项目,我们展示了如何使用 FuelPHP 的主要功能来构建强大、复杂且高效的程序。
最重要的是,我希望您喜欢阅读这本书,并从中学习到一些宝贵的技能。
如果您在书中或 FuelPHP 框架中遇到任何需要帮助的问题,请不要犹豫,给我发电子邮件或发推文给我。
非常感谢您的关注。


浙公网安备 33010602011771号