Phalcon-学习指南-全-
Phalcon 学习指南(全)
原文:
zh.annas-archive.org/md5/eb9f8b38126aaa80123cb86ddf80fab5译者:飞龙
前言
Phalcon 是市面上最快的 PHP 框架,它以 C 扩展的形式提供。不仅如此,你会发现它非常容易学习。本书将详细介绍 Phalcon PHP 中最常见和有用的部分,并指导你在开发 Phalcon 驱动的应用程序时做出正确的决策。
学习 Phalcon PHP 是一段有趣的旅程,它从安装所需软件和准备工作环境及项目结构开始,然后通过逐步的方法开发每个模块。
到本书结束时,你将开发出一个简单但功能齐全的新闻网站,并获取关于 Phalcon 如何工作的高级知识。
本书涵盖的内容
第一章, 使用 Phalcon 入门,介绍了 Phalcon 框架。在本章中,你将学习如何安装和配置 Phalcon。
第二章, 为我们的项目设置 MVC 结构和环境,帮助你掌握 MVC(模型-视图-控制器)的基础以及设置工作环境。
第三章, 学习 Phalcon 的 ORM 和 ODM,是关于 Phalcon 的 ORM(对象关系映射)和 ODM(对象文档映射)。你将学习如何连接到数据库并创建模型以及它们之间的关系。
第四章, 数据库架构、模型和 CLI 应用程序,教你如何创建我们项目所需的数据架构和模型。你还将了解 Phalcon CLI 并开发一个简单的 CLI 应用程序。
第五章, API 模块,帮助你开始开发 RESTful API 模块。
第六章, 资产、认证和 ACL,解释了资产管理(JavaScript 文件、样式表和图像),并基于 ACL(访问控制列表)创建了一个简单的认证系统。
第七章"), 后端模块(第一部分),展示了如何开发 CRUD 操作。这一部分是关于类别和标签的 CRUD。
第八章"), 后端模块(第二部分),是前一章的延续。在这里,你将开发用户和文章的 CRUD 操作。
第九章, 前端模块,帮助你开发前端模板。你将学习如何实现 Elasticsearch 和 Mongo 来提高应用程序的速度。
第十章, 进一步学习,教你常见的操作,如文件上传和注解。
你需要这本书的内容
您最需要的是对 PHP 5.3 或更高版本以及 Linux 环境的一些了解(本书基于 Ubuntu/Debian 编写)。如果您不是使用 Linux 发行版,或者您使用的是除 Ubuntu/Debian 之外的分发版,您将需要查阅它们的官方文档来安装所需的软件。
本书面向的对象
如果您是一位具有安装和配置环境基本知识的中级 PHP 开发者,那么这本书适合您。熟悉 PHP 框架将使您的生活更加轻松。
约定
在本书中,您将找到多种文本样式,用于区分不同类型的信息。以下是一些这些样式的示例及其含义的解释。
文本中的代码词如下所示:“删除数据更容易,因为我们不需要做更多的事情,只需调用内置的 delete() 方法。”
代码块设置如下:
<?php
$di['session'] = function () {
$session = new Phalcon\Session\Adapter\Files();
$session->start();
return $session;
};
当我们希望您注意代码块中的特定部分时,相关的行或项目将以粗体显示:
public function registerServices(\Phalcon\DiInterface $di) {
$config = include __DIR__ . "/Config/config.php";
$di['config'] = $config;
include __DIR__ . "/Config/services.php";
}
任何命令行输入或输出都应如下编写:
$ cd modules/Frontend/Views/Default
$ mkdir index
$ cd index
$ touch index.volt
新术语和重要词汇将以粗体显示。您在屏幕上看到的单词,例如在菜单或对话框中,在文本中如下所示:“返回到文章列表,您将看到新的标题,并且更新列将有一个新的值。”
注意
警告或重要注意事项将以如下框中的形式出现。
小贴士
小技巧和窍门将以如下形式出现。
读者反馈
我们始终欢迎读者的反馈。请告诉我们您对这本书的看法——您喜欢什么或可能不喜欢什么。读者反馈对我们开发您真正能从中受益的标题非常重要。
要发送给我们一般性的反馈,只需发送一封电子邮件到 <feedback@packtpub.com>,并在邮件的主题中提及书名。
如果您在某个领域有专业知识,并且有兴趣撰写或为书籍做出贡献,请参阅我们的作者指南,网址为 www.packtpub.com/authors。
客户支持
现在,您已经成为 Packt 书籍的骄傲拥有者,我们有一些事情可以帮助您从您的购买中获得最大收益。
下载示例代码
您可以从您在 www.packtpub.com 的账户下载所有已购买 Packt 书籍的示例代码文件。如果您在其他地方购买了这本书,您可以访问 www.packtpub.com/support 并注册,以便将文件直接通过电子邮件发送给您。
错误清单
尽管我们已经尽最大努力确保内容的准确性,但错误仍然可能发生。如果您在我们的某本书中发现错误——可能是文本或代码中的错误——如果您能向我们报告这一点,我们将不胜感激。通过这样做,您可以避免其他读者感到沮丧,并帮助我们改进本书的后续版本。如果您发现任何勘误,请通过访问www.packtpub.com/submit-errata,选择您的书籍,点击勘误提交表单链接,并输入您的勘误详情来报告它们。一旦您的勘误得到验证,您的提交将被接受,勘误将被上传到我们的网站,或添加到该标题的勘误部分下的现有勘误列表中。您可以通过从www.packtpub.com/support选择您的标题来查看任何现有的勘误。
盗版
在互联网上对版权材料的盗版是所有媒体中持续存在的问题。在 Packt,我们非常重视保护我们的版权和许可证。如果您在互联网上发现任何我们作品的非法副本,无论形式如何,请立即向我们提供位置地址或网站名称,以便我们可以寻求补救措施。
请通过发送链接到疑似盗版材料的方式,联系我们的邮箱 <copyright@packtpub.com>。
我们感谢您在保护我们的作者以及为我们提供有价值内容的能力方面的帮助。
问题
如果您在本书的任何方面遇到问题,可以通过 <questions@packtpub.com> 联系我们,我们将尽力解决。
第一章. 使用 Phalcon 入门
什么是 Phalcon?让我们先从官方网站的文档中引用一些内容(phalconphp.com/):
"Phalcon 是一个开源的全栈 PHP 框架,以 C 扩展的形式编写,针对高性能进行了优化。"
Phalcon 2.0 版本于四月发布,它是用一种名为 Zephir 的新语言开发的(zephir-lang.com/)。Zephir 特别设计用于开发 PHP 扩展,并且对于(PHP 和 C)开发者来说都非常友好。
现在有很多框架。我们选择 Phalcon 的主要原因是因为它的学习曲线陡峭、速度快,并且它是解耦的。(我们可以独立使用其任何组件。)如果你对 模型-视图-控制器(MVC)有所了解,并且对任何 对象关系映射(ORM)有一些经验,你会发现与之工作非常直接。
我们将从本章开始我们的旅程,我们将:
-
配置我们的 Web 服务器
-
安装 Phalcon
-
稍微讨论一下 Phalcon 的工作原理
在开始之前,我们假设你正在使用一个 *nix 环境。我个人对 Debian 发行版感到很舒适,特别是 Ubuntu,这是我每天都在使用的;所以,我们将讨论的安装步骤是针对 Ubuntu 的。操作系统是一个个人选择的问题,但我强烈推荐任何 *nix 发行版用于开发。(即使是微软在今年早些时候决定开源他们的 ASP.NET for Linux))
对于其他类型的操作系统,你将不得不搜索它们的官方文档,关于“如何”的问题。这本书的目的是关于 Phalcon 以及在不同类型的操作系统上安装不同软件的教程,这些内容超出了本书的范围。
注意
这里是包含不同操作系统安装说明的 URL 列表:
-
docs.phalconphp.com/en/latest/reference/install.html#windows -
docs.phalconphp.com/en/latest/reference/install.html#mac-os-x -
docs.phalconphp.com/en/latest/reference/install.html#freebsd
高级开发者可能不会在某些主题或某些技术/建议上同意我的观点。一般来说,作为一名开发者,我认为你应该分析适合你的内容,并根据你的(或客户的)需求开发平台。此外,最重要的是,没有所谓的“完美解决方案”。总有改进的空间。
安装所需的软件
我们需要安装以下我们将在这本书中使用的软件:
-
PHP
-
Nginx 和 Apache
-
MongoDB
-
MySQL
-
GIT
-
Redis
-
Phalcon
安装 PHP
您可能已经在本系统中安装了 PHP,因为您正在阅读这本书。但是,以防万一您还没有,以下是一些快速安装最新 PHP 版本的简单步骤(Phalcon 运行在 PHP 版本 >= 5.3)。我建议您使用 Ondřej Surý (launchpad.net/~ondrej/+archive/ubuntu/php5) 的 个人软件包存档(PPA),因为它上有可用的最新 PHP 版本:
$ sudo add-apt-repository ppa:ondrej/php5
$ sudo apt-get update
如果您不想使用此步骤,您可以直接从官方仓库安装 PHP:
$ sudo apt-get install php
Apache 将默认与 PHP 一起安装。但是,如果您想使用 Nginx 而不是 Apache,您必须按照一定的顺序安装 PHP。
以下命令将 自动安装 PHP 和 Apache。如果您不需要/想要使用 Apache,请跳过使用此命令**:
$ sudo apt-get install php5 php5-fpm
要避免安装 Apache,请按以下顺序执行以下命令:
$ sudo apt-get install php5-common
$ sudo apt-get install php5-cgi
$ sudo apt-get install php5 php5-fpm
php5-cgi 软件包满足了本应由 Apache 满足的依赖项。
安装 Nginx
要安装 Nginx 网络服务器,我们需要执行以下命令:
$ sudo add-apt-repository ppa:nginx/stable
$ sudo apt-get update
$ sudo apt-get install nginx
安装 MySQL
MySQL 可能是分布最广泛的 RDBMS 系统,市场份额超过 50%。由于我们将使用它来开发我们的项目,我们需要通过执行以下命令来安装它:
$ sudo apt-get install mysql-server
注意
下载示例代码
您可以从您在 www.packtpub.com 的账户下载您购买的所有 Packt 书籍的示例代码文件。如果您在其他地方购买了这本书,您可以访问 www.packtpub.com/support 并注册以直接将文件通过电子邮件发送给您。
安装 Redis
Redis 是一个高级键值存储/缓存系统。我们将主要使用它来处理会话并缓存对象以提高应用程序的速度。让我们通过执行以下命令来安装它:
$ sudo add-apt-repository ppa:chris-lea/redis-server
$ sudo apt-get update
$ sudo apt-get install redis-server
$ sudo apt-get install php5-redis
安装 MongoDB
MongoDB 是一个文档数据库(NoSQL 数据库)系统。我们将使用它来存储频繁访问的数据。让我们安装它:
$ sudo apt-key adv --keyserver hkp://keyserver.ubuntu.com:80 --recv 7F0CEB10
$ echo 'deb http://downloads-distro.mongodb.org/repo/ubuntu-upstart dist 10gen' | sudo tee /etc/apt/sources.list.d/mongodb.list
$ sudo apt-get update
$ sudo apt-get install -y mongodb-org
$ sudo service mongodb start
$ sudo apt-get install php5-mongo
安装 Git
Git 是一个分布式版本控制系统,我们将使用它来跟踪应用程序的更改以及更多内容。我们将通过执行以下命令来安装 Git:
$ sudo apt-get install git
小贴士
我强烈建议您尽可能使用所有软件的最新版本。
安装 Phalcon
现在我们已经安装了所有必需的软件,我们将继续安装 Phalcon。在我们继续之前,我们必须安装一些依赖项:
$ sudo apt-get install php5-dev libpcre3-dev gcc make php5-mysql
对于 Windows 系统,以及有关如何在不同的系统上编译扩展的更多详细信息,请查看 phalconphp.com/en/download 上的最新文档。
现在,我们可以克隆仓库并编译我们的扩展:
$ git clone --depth=1 git://github.com/phalcon/cphalcon.git
$ cd cphalcon/build
$ sudo ./install
$ echo 'extension=phalcon.so' | sudo tee /etc/php5/mods-available/phalcon.ini
$ sudo php5enmod phalcon
$ sudo service php5-fpm restart
如果一切顺利,您应该能够在 PHP 安装模块列表中看到 Phalcon:
$ php -m | grep phalcon
Apache 和 Nginx 的配置文件
我们将使用 /var/www/learning-phalcon.localhost 作为我们项目的默认目录,并将其称为 根文件夹。请创建此文件夹:
$ sudo mkdir -p /var/www/learning-phalcon.localhost/public
当然,如果您愿意,您可以使用另一个文件夹。让我们在根目录下的 public 文件夹中创建一个测试文件,并包含一些 PHP 内容:
$ cd /var/www/learning-phalcon.localhost/public
$ echo "<?php date();" > index.php
Apache
让我们切换到 Apache 存储可用网站配置文件的默认目录,使用命令行:$ cd /etc/apache2/sites-available/。之后,执行以下步骤:
-
使用您喜欢的编辑器,为 Apache 版本 < 2.4 创建一个名为
learning-phalcon.localhost的文件,或为 Apache 版本 >= 2.4 创建一个名为learning-phalcon.localhost.conf的文件:$ vim learning-phalcon.localhost.conf -
现在,将以下内容粘贴到该文件中:
<VirtualHost *:80> DocumentRoot "/var/www/learning-phalcon.localhost" DirectoryIndex index.php ServerName learning-phalcon.localhost ServerAlias www.learning-phalcon.localhost <Directory "/var/www/learning-phalcon.localhost/public"> Options All AllowOverride All Allow from all </Directory> </VirtualHost> -
然后,切换到公共文件夹并添加一个名为
.htaccess的文件到其中:$ cd /var/www/learning-phalcon.localhost/public $ vim .htaccess -
然后,将以下内容添加到
.htaccess文件中:<IfModule mod_rewrite.c> RewriteEngine On RewriteCond %{REQUEST_FILENAME} !-d RewriteCond %{REQUEST_FILENAME} !-f RewriteRule ^(.*)$ index.php?_url=/$1 [QSA,L] </IfModule> -
如果您没有启用
mod_rewrite,这将不起作用。为此,执行此命令:$ sudo a2enmod rewrite -
现在我们已经配置了虚拟主机,让我们启用它:
$ sudo a2ensite learning-phalcon.localhost $ sudo service apache2 reload
hosts 文件
如果您打开浏览器并输入 http://www.learning-phalcon.localhost/,您将收到一个“找不到主机”或连接错误。这是因为没有为这个 TLD(顶级域的缩写)提供名称解析器。为了解决这个问题,我们编辑我们的 hosts 文件并添加此名称:
$ echo "127.0.0.1 learning-phalcon.localhost www.learning-phalcon.localhost" | sudo tee /etc/hosts
重新启动您的浏览器并再次输入地址 http://www.learning-phalcon.localhost/。如果一切顺利,您应该会看到当前的日期/时间。
Nginx
如果您选择使用 Nginx(我推荐这样做,尤其是因为它可以以更高的吞吐量服务更多的并发客户端,并且更有效地服务静态内容)而不是 Apache,以下是您需要执行的操作:
找到 Nginx 的 config 文件夹(在 Ubuntu 上,它安装在 /etc/nginx/ 下)。在您的 sites-available 文件夹中创建一个名为 learning-phalcon.localhost 的文件(通过导航到 /etc/nginx/sites-available):
$ cd /etc/nginx/sites-available
$ vim learning-phalcon.localhost
现在,向其中添加以下内容:
server {
listen 80;
server_name learning-phalcon.localhost;
index index.php;
set $root_path "/var/www/learning-phalcon.localhost/public";
root $root_path;
client_max_body_size 10M;
try_files $uri $uri/ @rewrite;
location @rewrite {
rewrite ^/(.*)$ /index.php?_url=/$1;
}
location ~ \.php {
fastcgi_index /index.php;
fastcgi_pass unix:/var/run/php5-fpm.sock;
fastcgi_intercept_errors on;
include fastcgi_params;
fastcgi_split_path_info ^(.+\.php)(/.*)$;
fastcgi_param PATH_INFO $fastcgi_path_info;
fastcgi_param PATH_TRANSLATED $document_root$fastcgi_path_info;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
fastcgi_param DOCUMENT_ROOT $realpath_root;
fastcgi_param SCRIPT_FILENAME $realpath_root/index.php;
}
location ~* ^/(css|img|js|flv|swf|download)/(.+)$ {
root $root_path;
}
location ~ /\.ht {
deny all;
}
}
注意
在某些环境中,您可能需要编辑您的 php.ini 文件并将 cgi.fix_pathinfo = 0 设置。
然后,保存文件并重新启动 Nginx:
$ sudo service nginx restart
请编辑并保存您的 hosts 文件(检查 hosts 文件 部分),然后打开您的浏览器并输入 http://www.learning-phalcon.localhost/。此时,您应该会看到一个显示当前日期/时间的页面。
安装和配置 PHP 和 Apache/Nginx 有许多可能的方法。如果您觉得我的方法不适合您的需求,请随意进行简单的 Google 搜索并选择一个更适合您的方法。
假设到目前为止一切顺利,我们将进一步学习一些关于 Phalcon 内部结构的知识。
理解框架的内部结构
在本节中,我将尝试简要介绍框架的常见部分。这里展示的大部分文本都是官方文档的一部分,你应该始终阅读。本节的想法是让你熟悉最常见的方法和组件,这将帮助你快速了解框架的工作方式。
小贴士
请注意,本书中的图片可能包含文本learning-phalcon.dev。你需要忽略这些内容,并按照章节中建议的,使用http://learning-phalcon.localhost。
依赖注入
可能是 Phalcon 最强大的特性之一的是依赖注入(DI)。如果你对依赖注入一无所知,你应该至少阅读一下这个设计模式的维基百科页面en.wikipedia.org/wiki/Dependency_injection:
"依赖注入是一种软件设计模式,它实现了控制反转以解决依赖关系。注入是将依赖(一个服务或软件模块)传递给依赖对象(一个客户端)。服务成为客户端状态的一部分。将服务传递给客户端,而不是允许客户端构建或找到服务,是这种模式的基本要求。
依赖注入允许程序设计遵循依赖倒置原则。
“依赖注入”这个术语是由马丁·福勒(Martin Fowler)提出的。
依赖注入的一个现实生活例子可能是以下情况:假设你去购物。在商场,你需要一个袋子来装你的杂货,但你离开家时忘了带。在这种情况下,你需要买一个袋子。在开发中,购买这个袋子可能相当昂贵。那么,如果你的门有一个扫描器,可以扫描你的身体以寻找袋子,并且只有在你有袋子的情况下才会打开,这可以称为依赖注入。
Phalcon 使用\Phalcon\DI组件,这是一个实现控制反转模式的组件。这减少了整体代码的复杂性。
框架本身或开发者可以注册服务。Phalcon 有许多内置组件,这些组件在 DI 容器中可用,如下所示:
-
请求和响应
-
记录器
-
密码
-
闪存
-
路由和配置
-
查看
-
缓存
-
会话
在 DI 中设置新的组件就像以下代码一样简单:
<?php
$di = new Phalcon\DI();
// Lazy load
$di['mail'] = function() {
return new \MyApp\Mail();
};
当你需要访问“mail”组件时,例如在一个控制器中,你可以简单地调用它:
<?php
$mail = $this->getID()->get('mail');
// or
$mail = $this->getDI()->getMail();
如果需要创建自己的 DI,Phalcon 或必须实现DiInterface接口以替换 Phalcon 提供的接口,或者必须扩展当前的接口。
这些只是一些示例,以便你可以在我们开始项目时对 Phalcon 的 DI 有一个大致的了解。同时,请花些时间阅读官方文档,可以在 docs.phalconphp.com/en/latest/reference/di.html 找到。
请求组件
请求组件可能是任何框架中最常用的组件之一。它处理任何 HTTP 请求(如 GET、POST 或 DELETE 等),同时也为 $_SERVER 变量提供了一些快捷方式。大多数时候,我们将在控制器中使用请求组件。Phalcon 文档(docs.phalconphp.com/en/latest/reference/mvc.html)中说明了以下内容:
"控制器在模型和视图之间提供“流程”。控制器负责处理来自网页浏览器的传入请求,查询模型以获取数据,并将这些数据传递给视图进行展示。"
在 Phalcon 中,所有控制器都应该扩展 \Phalcon\Mvc\Controller 组件,而我们想要通过 HTTP GET 访问的公共方法名称应该以 Action 后缀结尾。例如:
<?php
class ArticleController extends \Phalcon\Mvc\Controller
{
// Method for rendering the form to create an article
public function createAction()
{
}
// Method for searching articles
public function searchAction()
{
}
// This method will not be accessible via http GET
public function search()
{
}
}
好的。那么,我们如何使用请求组件呢?很简单!你还记得我们在 DI 部分讨论的内置组件吗?请求组件就是其中之一。我们只需要获取 DI。以下是如何获取和使用请求组件的示例:
<?php
class ArticleController extends \Phalcon\Mvc\Controller
{
public function searchAction()
{
$request = $this->getDI()->get('request');
// You can also use $request = $this->request; but I don't
// recommend it because $this->request can be easily overwritten
// by mistake and you will spend time to debug ... nothing.
$request->getMethod(); // Check the request method
$request->isAjax(); // Checks if the request is an ajax request
$request->get(); // Gets everything, from the request (GET, POST, DELETE, PUT)
$request->getPost(); // Gets all the data submitted via POST method
$request->getClientAddress(); // Return the client IP
}
}
这些只是请求组件中内置的一些常见方法。让我们继续下一个重要组件——响应。
响应组件
那么,这个组件能做什么呢?嗯,几乎与响应或输出相关的一切都能做。使用它,我们可以设置头,执行重定向,发送 cookie,设置内容等等。以下是该组件的一些常见方法列表:
<?php
public function testRedirectAction()
{
$response = $this->getDI()->get('response');
// or you can use $this->response directly
// Redirect the user to another url
$this->view->disable();
return $response->redirect('http://www.google.com/', true);
}
redirect 方法接受三个参数:一个位置(字符串),如果是一个外部重定向(这是一个布尔类型,默认为 false),以及一个状态码(HTTP 状态码范围)。以下代码行是重定向方法:
<?php
/**
* Redirect by HTTP to another action or URL
*
* @param string $location
* @param boolean $externalRedirect
* @param int $statusCode
* @return \Phalcon\Http\ResponseInterface
*/
public function redirect($location, $externalRedirect, $statusCode);
另一个有用的方法是 setHeader 方法:
<?php
public function testSetHeaderAction()
{
$this->response->setHeader('APIKEY', 'AWQ23XX258561');
}
上述示例设置了一个名为 APIKEY 的头,其值为 AWQ23XX258561。在开发 API 时发送头是一个常见的做法。你可以使用此方法发送任何类型的头并覆盖当前头。
内容相关方法:setContent() 和 setJsonContent()。让我们以以下代码为例:
<?php
public function testContentAction()
{
// First, we disable the view if there is any
$this->view->disable();
// Set a plain/text or html content
$this->response->setContent('I love PhalconPHP');
// OR
// Set a json content (this will return a json object)
$this->response->setJsonContent(array(
'framework' => 'PhalconPHP'
'versions' => array(
'1.3.2',
'1.3.3',
'2.0.0'
)
));
// We send the output to the client
return $this->response->send();
}
当你需要发送任何 JSON 内容时,你应该使用响应对象中的内置方法将头设置为 application/json:
<?php
$this->response->setContentType('application/json', 'UTF-8');
现在我们已经了解了响应/请求组件的基础知识,我们可能会发现自己处于需要记录不同情况的位置,例如错误。为此,我们需要检查记录器组件。
记录器组件
在生产环境中,我们不能承担向客户端抛出错误或空白页面的风险。我们将避免这种情况,并将错误记录在日志文件中。您将在下一章中了解更多关于此内容。总结一下,我们将实现一个自定义的记录器到 DI 中,捕获异常,然后记录它们。例如,执行以下步骤:
-
使用以下代码在 DI 中设置自定义记录器:
<?php $di['logger'] = function() { $error_file = __DIR__.'/../logs/'.date("Ymd_error").'log'; return new \Phalcon\Logger\Adapter\File($error_file, array('mode' => 'a+')); }; -
创建一个会抛出异常的方法,捕获它,并记录它,如下所示:
<?php public function testLoggerAction() { try { $nonExistingComponent = $this->getDI()->get('nonExistingComponent'); $nonExistingComponent->executeNonExistingMethod(); } catch (\Exception $e) { $this->logger->error($e->getMessage()); return $this->response->redirect('error/500.html'); } }
在前面的例子中,我们尝试执行一个不存在的方法,我们的代码将抛出一个异常,我们将其捕获并记录,然后重定向用户到一个友好的错误页面,error/500.html。您会注意到我们的记录器组件调用了一个名为error的方法。还有其他实现的方法,如debug、info、notice、warning等等。
logger组件可以是事务性的。(Phalcon 将日志临时存储在内存中,稍后将其写入相关适配器。)例如,考虑以下代码片段:
<?php
$this->logger->begin();
$this->logger->error('Ooops ! Error !');
$this->logger->warning('A warning message');
$this->logger->commit();
加密组件
如果有人需要在您的端加密数据并解密它,加密是一个非常有用的组件。你可能想要使用加密组件的情况之一是将数据通过 HTTP get方法发送或保存敏感信息到您的数据库中。
此组件具有许多内置方法,例如encrypt、decrypt、getAvailableChiphers、setKey、getKey等等。以下是在 HTTP get方法中使用加密组件的示例。
首先,我们覆盖 DI(依赖注入),然后向它传递一个键以避免每次都设置它:
<?php
$di['crypt'] = function () {
$crypt = new \Phalcon\Crypt();
$crypt->setKey('0urSup3rS3cr3tK3y!?');
return $crypt;
};
public function sendActivationAction()
{
$activation_code = $this->crypt->encryptBase64('1234');
$this->view->setVar('activation_code', $activation_code);
}
public function getActivationAction($code)
{
if ('1234' == $this->crypt->decryptBase64($code)) {
$this->flash->success('The code is valid ');
} else {
$this->flash->error('The code is invalid');
}
}
当然,您可能永远不会以这种方式使用它。前面的例子只是展示了此组件的强大功能。您可能已经注意到有一个新的 DI 方法名为 flash。我们将在下一节讨论它。
flash 组件
此组件用于向客户端发送通知,并告知他们组件动作的状态。例如,当用户在我们的网站上完成注册或提交联系表单后,我们可以发送一条成功消息。
有两种类型的 flash 消息——直接和会话,两者都在 DI 中可用。直接方法直接输出消息,不能在未来请求中加载。相反,会话方法将消息存储在会话中,打印后自动清除。
假设您有一个名为 register 的页面,并且您在同一页面上提交数据,以下是 flash 直接和 flash 会话的常见用法:
public function registerAction()
{
// … code
if ($errors) {
$this->flash->warning('Please fix the following errors: ');
foreach($errors as $error) {
$this->flash->error($error);
}
} else {
$this->flash->success('You have successfully registered on our website');
}
}
在我们的视图中,我们将使用getContent()方法或模板引擎Volt中的content()来渲染消息(我们将在本章后面讨论这一点)。
如果我们需要将用户重定向到另一个页面(让我们称它为registerSuccess),那么我们需要使用 flash 会话方法;否则,消息将不会显示。
<?php
public function registerAction()
{
// render our template
}
register模板将包含一个方法为post且action指向create方法的表单。create方法看起来可能像这样:
<?php
public function createAction()
{
if ($errors) {
$this->flashSession->warning('Please fix the following errors: ');
foreach($errors as $error) {
$this->flashSession->error($error);
}
} else {
$this->flashSession->success('You have successfully registered on our website');
}
return $this->response->redirect('/register');
}
在前面的示例中,我们使用flashSession方法在会话中设置消息,并将用户重定向回注册页面。为了在我们的视图中渲染这些消息,我们需要调用flashSession()->output();方法。
小贴士
推荐的方式是使用分发器来转发请求,而不是使用重定向。如果你使用重定向,用户将丢失他们在表单中填写的所有数据。
路由组件
路由组件帮助我们将友好的 URL 映射到我们的控制器和操作。
默认情况下,如果你的 Web 服务器启用了重写模块,你将能够通过以下方式访问名为Post的控制器和read操作:http://www.learning-phalcon.localhost/post/read。我们的代码可能看起来像这样:
<?php
class PostController extends \Phalcon\Mvc\Controller
{
public function readAction()
{
// get the post
}
}
然而,有时如果你需要将 URL 翻译成多种语言,或者需要以不同于代码中定义的方式命名 URL,这段代码可能就不适用了。以下是路由组件的使用示例:
<?php
$router = new \Phalcon\Mvc\Router();
// Clear the default routes
$router->clear();
$st_categories = array(
'entertainment',
'travel',
'video'
);
$s_categories = implode('|', $st_categories);
$router->add('#^/('.$s_categories.')[/]{0,1}$#', array(
'module' => 'frontend',
'controller' => 'post',
'action' => 'findByCategorySlug',
'slug' => 0
));
在前面的示例中,我们将所有类别映射到post控制器和findByCategorySlug操作。路由组件允许我们使用正则表达式来定义 URL。使用preg_match,这可以表示如下
$url = 'http://www.learning-phalcon.localhost/video';
preg_match('#^/(entertainment|travel|video)[/]{0,1}$#', $url);
通过访问http://www.learning-phalcon.localhost/video,请求将被转发到 post 控制器的findByCategorySlug操作:
<?php
class PostController extends \Phalcon\Mvc\Controller
{
public function findByCategorySlug()
{
$slug = $this->dispatcher->getParam('slug', array('string', 'striptags'), null);
// We access our model (entity) to get all the posts from this category
$posts = Posts::findByCategorySlug($slug);
if ($posts->count() > 0) {
$this->view->setVar('posts', $posts);
} else {
throw new \Exception('There are no posts', 404);
}
}
}
getParam()方法有三个参数。第一个是我们正在搜索的名称,第二个参数是可以自动应用的一组过滤器,第三个参数是在请求的名称不存在或未设置时的默认值。
我们将在下一章讨论模型。这只是一个简单的示例,说明你可以如何使用路由。
路由还支持对request方法的预检查。你可能习惯于检查方法是否为 POST、DELETE、PUT 或 GET,如下所示:
<?php
if ($_SERVER['REQUEST_METHOD'] == 'post') {
// process the information
}
虽然这是完全正确的,但它对我们的代码来说并不友好。Phalcon 的路由具有这种能力,你可以添加你期望的正确类型的请求,而无需在代码中进行检查:
<?php
// Add a get route for register method within the user controller
$router->addGet('register', 'User::register');
// Add a post route for create method, from the user controller
$router->addPost('create', 'User::create');
这只是路由的基本用法。一如既往,请阅读文档以了解有关此组件的所有信息。
小贴士
你可以在官方文档中了解更多关于路由的信息,请访问docs.phalconphp.com/en/latest/reference/routing.html。
配置组件
此组件可以通过适配器处理各种格式的配置文件。Phalcon 为此提供了两个内置适配器,分别是 INI 和 Array。使用 INI 文件可能从来都不是一个好主意。因此,我建议你使用原生数组。
这些文件可以或需要存储什么类型的数据?嗯,几乎是我们应用中需要的所有全局数据,例如数据库连接参数。在以前的日子里,我们使用$_GLOBALS(一个大的安全问题),或者我们使用define()方法,然后逐渐开始全局使用它。
这里是一个config文件的示例,以及我们如何使用它:
<?php
$st_settings = array(
'database' => array(
'adapter' => 'Mysql',
'host' => 'localhost',
'username' => 'john',
'password' => 'johndoe',
'dbname' => 'test_database',
),
'app' => array(
'name' => 'Learning Phalcon'
)
);
$config = new \Phalcon\Config($st_settings);
// Get our application name:
echo $config->app->name; // Will output Learning Phalcon
可以使用toArray()方法将config对象转换回数组:
<?php
$st_config = $config->toArray();
echo $config['app']['name']; // Will output Learning Phalcon
对于此对象,另一个有用的方法是merge方法。如果我们有多个配置文件,我们可以轻松地将它们合并成一个对象:
<?php
$config = array(
'database' => array(
'adapter' => 'Mysql',
'host' => 'localhost',
'dbname' => 'test_database',
),
'app' => array(
'name' => 'Learning Phalcon'
)
);
$config2 = array(
'database' => array(
'username' => 'john',
'password' => 'johndoe',
)
现在,$config对象将具有与之前相同的内容。
提示
还有两个尚未实现的适配器(YAML 和 JSON),但如果你克隆 Phalcon 的孵化器存储库(github.com/phalcon/incubator),你可以使用它们。此存储库包含一系列适配器/辅助工具,这些工具可能很快将被集成到 Phalcon 中。
视图组件
此组件用于渲染我们的模板。默认情况下,模板具有.phtml扩展名,并包含 HTML 和 PHP 代码。以下是一些使用视图的示例:
-
首先,我们使用以下代码片段在 DI 中设置视图:
<?php $di['view'] = function () use ($config) { $view = \Phacon\Mvc\View(); // Assuming that we hold our views directory in the configuration file $view->setViewsDir($config->view->dir); return $view; }; -
现在,我们可以如下使用此服务:
<?php class PostControler extends \Phalcon\Mvc\Controller { public function listAction() { // Retrieve posts from DB $posts = Post:find(); $this->view->setVar('pageTitle', 'Posts'); $this->view->setVar('posts', $posts); } } -
接下来,我们需要创建一个视图模板,它必须看起来像这样:
<!DOCTYPE html> <html> <head> <meta charset="UTF-8"> <title><?php echo $pageTitle; ?></title> </head> <body> <?php foreach($posts as $post) { ?> <p><?php echo $post->getPostTitle(); ?></p> <p><?php echo $post->getPostContent(); ?></p> <?php } ?> </body> </html>
简单,不是吗?此组件还支持分层渲染。你可以有一个基本布局,一个用于帖子的通用模板,以及一个用于单个帖子的模板。让我们以以下目录结构为例:
app/views/
- index.phtml
- post/detail.phtml
Phalcon 首先渲染app/views/index.phtml。然后,当我们从帖子控制器请求detailAction()时,它将渲染app/views/post/details.phtml。主布局可以包含类似以下代码的内容:
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Learning Phalcon</title>
</head>
<body>
<?php echo $this->getContent(); ?>
</body>
</html>
此外,details.phtml模板将包含以下内容:
<?php foreach($posts as $post) { ?>
<p><?php echo $post->getPostTitle(); ?></p>
<p><?php echo $post->getPostContent(); ?></p>
<?php } ?>
此组件还允许你选择不同的模板来设置渲染级别,禁用或启用视图,以及更多。
Phalcon 有一个名为 Volt 的内置模板引擎。如果你熟悉 PHP 模板引擎,如Smarty或Twig,你肯定会想使用它们。Volt 几乎与 Twig 相同,你会发现它非常有用——它受到了Jinja的启发(jinja.pocoo.org/)。你甚至可以使用自己的模板引擎,或者任何你能在那里找到的其他模板引擎。
为了启用 Volt 模板引擎,我们需要对我们的视图服务进行一些小的修改,并创建一个 Volt 服务;以下是这样做的方法:
<?php
$di['voltService'] = function($view, $di) use ($config) {
$volt = new \Phalcon\Mvc\View\Engine\Volt($view, $di);
if (!is_dir($config->view->cache->dir)) {
mkdir($config->view->cache->dir);
}
$volt->setOptions(array(
"compiledPath" => $config->view->cache->dir,
"compiledExtension" => ".compiled",
"compileAlways" => false
));
$compiler = $volt->getCompiler();
return $volt;
};
// First, we setup the view in the DI
$di['view'] = function () use ($config) {
$view = \Phacon\Mvc\View();
$view->setViewsDir($config->view->dir);
$view->registerEngines(array(
'.volt' => 'voltService'
));
return $view;
};
通过添加此修改和voltService,我们现在可以使用此模板引擎。从继承的角度来看,Volt 表现得有点不同。我们首先需要定义一个主布局,并带有命名块。然后,其余的模板应该扩展主布局,并且我们需要将内容放在与主布局相同的块中。在我们查看一些示例之前,我将简要介绍一下 Volt 的语法,具体如下。
-
输出数据或输出内容的语法:
{{ my_content }} -
定义块的语法:
{% block body %} Content here {% endblock %} -
扩展模板的语法(这应该是您模板中的第一行):
{% extends 'layouts/main.volt' %} -
包含文件的语法:
{% include 'common/sidebar.volt' %} -
包含文件并传递变量的语法:
{% include 'common/sidebar' with{'section':'homepage'} %}小贴士
请注意缺少的扩展名。如果您传递变量,必须省略扩展名。
-
控制结构(
for,if,else)的语法:{% for post in posts %} {% if post.getCategorySlug() == 'entertainment' %} <h3 class="pink">{{ post.getPostTitle() }}</h3> {% else %} <h3 class="normal">{{ post.getPostTitle() }}</h3> {% endif %} {% endfor %} -
循环上下文的语法:
{% for post in posts %} {% if loop.first %} <h1>{{ post.getPostTitle() }}</h1> {% endif %} {% endif %} -
赋值的语法:
{% set title = 'Learning Phalcon' %} {% set cars = ['BMW', 'Mercedes', 'Audi'] %}
列表很长。此外,您还可以使用表达式、比较运算符、逻辑运算符、过滤器等。让我们写一个简单的模板来看看它是如何工作的:
<!-- app/views/index.volt -->
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>{% block pageTitle %}Learning Phalcon{% endblock%}</title>
</head>
<body>
<div class='header'>{% block header %}Main layout header{% endblock%}</div>
<div class='content'>{% block content %}This is the main layout content{% endblock %}</div>
</body>
</html>
<!-- app/views/post/detail.volt
{% extends 'index.volt' %}
{% block pageTitle %}
{{ post.getPostTitle() }}
{% endblock %}
{% block header %}
Post layout
{% endblock %}
{% block content %}
<p>{{ post.getPostContent() }}</p>
{% endblock%}
注意
您可以在docs.phalconphp.com/en/latest/reference/views.html查看视图组件的完整文档,以及 Volt 的docs.phalconphp.com/en/latest/reference/volt.html。
会话组件
此组件提供面向对象的包装来访问会话数据。要启动会话,我们需要将服务添加到 DI 容器中:
<?php
$di['session'] = function () {
$session = new Phalcon\Session\Adapter\Files();
$session->start();
return $session;
};
以下是与会话一起工作的代码示例:
<?php
public function testSessionAction()
{
// Set a session variable
$this->session->set('username', 'john');
// Check if a session variable is defined
if ($this->session->has('username')) {
$this->view->setVar('username', $this->session->get('username'));
}
// Remove a session variable
$this->session->remove('username');
// Destroy the session
$this->session->destroy();
}
如果您检查 Phalcon 的孵化器,有许多可用的适配器,例如 Redis、数据库、Memcache 和 Mongo。您也可以实现自己的适配器。
注意
您可以在docs.phalconphp.com/en/latest/reference/session.html查看官方文档。
缓存组件
为了提高某些应用程序的性能,您需要缓存数据。例如,我们可以缓存帖子的查询结果。为什么?想象一下有 100 万次查看或帖子。通常,您将查询数据库,但这意味着 100 万次查询(如果您在使用它,并且对于 ORM——这意味着至少 300 万次查询)。为什么?当您查询时,ORM 将表现得像这样:
-
它将检查表是否存在于信息模式中:
SELECT IF(COUNT(*)>0, 1 , 0) FROM `INFORMATION_SCHEMA`.`TABLES` WHERE `TABLE_NAME`='user' -
然后,它将检查是否正在执行表的“描述”:
DESCRIBE `user` -
然后,是否正在执行实际的查询:
SELECT * FROM user. -
如果
用户表有关系,ORM 将为每个关系重复执行前面的每个步骤。
为了解决这个问题,我们将帖子对象保存到我们的缓存系统中。
个人来说,我使用 Redis 和 Igbinary。Redis 可能是最强大的工具,因为它将数据存储在内存中,并在磁盘上保存数据以实现冗余。这意味着每次你从缓存请求数据时,你都会从内存中获取它。Igbinary(pecl.php.net/package/igbinary)是标准 php 序列化器的替代品。以下是一个示例缓存服务:
<?php
$di['redis'] = function () {
$redis = new \Redis();
$redis->connect(
'127.0.0.1',
6379
);
return $redis;
};
$di['cache'] = function () use ($di, $config) {
$frontend = new \Phalcon\Cache\Frontend\Igbinary(array(
'lifetime' => 86400
));
$cache = new \Phalcon\Cache\Backend\Redis($frontend, array(
'redis' => $di['redis'],
'prefix' => 'learning_phalcon'
));
return $cache;
};
缓存组件有以下常用方法:
<?php
// Save data in cache
$this-cache->save('post', array(
'title' => 'Learning Phalcon',
'slug' => 'learning-phalcon',
'content' => 'Article content'
));
// Get data from cache
$post = $this->cache->get('post');
// Delete data from cache
$this->cache->delete('post');
摘要
在本章中,我们安装了所需的软件,创建了 Web 服务器的配置文件,并且你对 Phalcon 的内部结构有了一些了解。在接下来的章节中,我们将通过示例学习,一切都会变得更加清晰。
请慢慢来,在继续之前,先阅读一下你不太熟悉的任何内容。
在下一章中,我们将探讨如何设置我们的项目的 MVC 结构和环境。
第二章:设置项目 MVC 结构和环境
在上一章中,我们总结了 Phalcon 最常见的部分。接下来,我们将尝试为我们的项目设置“Hello world”页面。在本章中,我们将涵盖以下主题:
-
MVC 简介——什么是 MVC?
-
MVC 结构
-
创建配置文件和引导
-
准备初始 DI 接口和路由
-
在模块中使用路由组件
-
创建基本布局
什么是 MVC?
我非常确信,如果您正在阅读这本书,您已经熟悉 MVC 模式,但对于初学者,我们将尝试用几句话解释它。
MVC 被定义为一种架构模式,代表模型-视图-控制器;它主要用于 Web 开发,但在需要 图形用户界面(GUI)的软件中得到了广泛应用。为了使这个介绍快速,让我们解释这些组件:
-
模型:这通常用作数据库表的抽象层和验证,但也可以用于处理应用程序中的任何类型的逻辑。
-
视图:通常,视图代表控制器将要渲染的模板(可以是 HTML 文件)。
-
控制器:在 Web 应用程序中,控制器处理所有 HTTP 请求并发送适当的响应。此响应可以表示渲染模板、输出 JSON 数据等。
注意
对于确切定义,我建议您查看维基百科上 MVC 模式的页面 code.tutsplus.com/tutorials/mvc-for-noobs--net-10488)。
让我们快速看一下新闻/博客应用程序的 MVC 示例,假设用户将请求 http://www.learning-phalcon.localhost/article/list。为了匹配此 URL,我们需要实现路由组件,但我们将在这接下来的章节中介绍。
模型
如前所述,模型是数据库表的抽象层,并且很可能在 99% 的情况下,您会为此目的使用它。在这个例子中,我们将扩展具有一些内置方法(如 find 方法)的 Phalcon\Mvc\Model 组件。默认情况下,此方法将返回名为 article 的表中找到的所有记录。
假设我们拥有以下 MySQL 表结构:
CREATE TABLE IF NOT EXISTS `article` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`article_short_title` varchar(255) COLLATE utf8_unicode_ci NOT NULL,
`article_long_title` varchar(255) COLLATE utf8_unicode_ci NOT NULL,
`article_slug` varchar(255) COLLATE utf8_unicode_ci NOT NULL,
`article_description` text COLLATE utf8_unicode_ci NOT NULL,
PRIMARY KEY (`id`),
KEY `id` (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_unicode_ci AUTO_INCREMENT=1;
对于这个表,我们的模型看起来会是这样:
<?php
namespace \App\Core\Models\Article;
class Article extends \Phalcon\Mvc\Model
{
protected $id;
protected $article_short_title;
protected $article_long_title;
protected $article_slug;
protected $article_description;
public function getId()
{
return $this->id;
}
public function getArticleShortTitle()
{
return $this->article_short_title;
}
public function getArticleLongTitle()
{
return $this->article_long_title;
}
public function getArticleSlug()
{
return $this->article_slug;
}
public function getArticleDescription()
{
return $this->article_description;
}
public function setId($id)
{
$this->id = $id;
}
public function setArticleShortTitle($article_short_title)
{
$this->article_short_title = $article_short_title;
}
public function setArticleLongTitle($article_long_title)
{
$this->article_long_title = $article_long_title;
}
public function setArticleSlug($article_slug)
{
$this->article_slug = $article_slug;
}
public function setArticleDescription($article_description)
{
$this->article_description = $article_description;
}
}
如果我们需要重写默认的 find 方法,我们可以在我们的模型中创建一个。例如:
public static function find($parameters = null)
{
return parent::find($parameters);
}
视图
让我们考虑以下 PHP/HTML 模板作为我们的视图:
<div class="list">
<?php foreach ($articles as $article) {?>
<article>
<h1><?php echo $article->getArticleShortTitle();?></h1>
<p><?php echo $article->getArticleLongTitle() ?></p>
<a href="<?php echo $article->getArticleSlug(); ?>">Read more</a>
</article>
<?php } ?>
</div>
小贴士
$article 是我们模型的一个实例。这就是为什么我们可以从它调用我们的获取器。
控制器
控制器将处理请求并将信息发送到模型中的适当方法。在这个例子中,控制器将扩展 \Phalcon\Mvc\Controller 组件:
<?php
namespace App\Frontend\Controllers;
use \App\Core\Models\Article;
class ArticleController extends \Phalcon\Mvc\Controller
{
public function listAction()
{
$articles = Article::find();
$this->view->setVar('articles', $articles);
}
}
如您所见,我们创建了一个名为listAction的公共方法,它从模型中调用find方法,并将结果分配给我们的view组件。您可能已经注意到控制器的命名空间中包含Frontend一词。这是因为我们将使用多模块应用程序。(我们将在本章后面的部分讨论这一点。)
通过这种方式,我们将结束对 MVC 或 Phalcon MVC 的简要介绍。接下来,我们将讨论 MVC 应用程序的文件夹结构。
MVC 结构
这个主题(就像许多其他主题一样)相当敏感。它取决于您的经验有多少,以及您习惯如何构建项目。在 Web 应用程序中,大多数时候我们都有模型、视图(模板)、控制器和资源(图像、JavaScript 文件和样式表)。基于这一点,我喜欢以下结构,因为它易于理解文件的位置及其用途。
对于单模块应用程序,我们可以有以下结构:

对于多模块应用程序,我们可以有以下结构:

如您所见,了解一个文件的具体用途及其所在位置相当简单。最终,您应该选择适合您需求的架构,但请记住,如果您将在团队中工作,它应该足够直观,以便任何新成员都能理解。
为我们的项目创建结构
现在,我们将为我们的项目创建结构。在第一章中,我们创建了/var/www/learning-phalcon.localhost文件夹。如果您有其他位置,请前往那里并创建以下目录结构:

接下来,让我们创建一个名为index.php的文件,该文件将处理我们的应用程序。这个文件将是我们的 Web 服务器的默认文件:
<?php
header('Content-Type: text/html; charset=utf-8');
mb_internal_encoding("UTF-8");
require_once __DIR__.'/../modules/Bootstrap.php';
$app = new Bootstrap('frontend');
$app->init();
?>
在前两行中,我们设置了头部和内部编码为 UTF-8。如果您打算使用特殊字符/变音符号,这是一个好习惯。在第四行中,我们包含了一个名为Bootstrap.php的文件。这个文件是项目的引导文件,我们将在接下来的几分钟内创建其内容。在下一行中,我们使用默认模块(frontend)创建一个新的 Bootstrap 实例,并初始化它。
我们需要找到一种方法来自动加载应用程序中的任何文件,而无需手动包含它。我们将利用\Phalcon\Loader组件,该组件将在命名空间中注册所有我们的模块。在config文件夹中,我们将创建一个名为loader.php的新文件,其内容如下:
<?php
$loader = new \Phalcon\Loader();
$loader->registerNamespaces(array(
'App\Core' => __DIR__ . '/../modules/Core/',
'App\Frontend' => __DIR__ . '/../modules/Frontend/',
'App\Api' => __DIR__ . '/../modules/Api/',
'App\Backoffice' => __DIR__ . '/../modules/Backoffice/',
));
$loader->register();
?>
PSR
PSR 是一组在 PHP 开发中使用的标准,由一组人支持,即PHP 框架互操作性小组。这些标准包括以下内容:
-
自动加载标准
-
基本编码标准
-
编码风格指南
-
日志接口
Phalcon\Loader 组件符合 PSR-4 (github.com/php-fig/fig-standards/blob/master/accepted/PSR-4-autoloader.md),它帮助我们按需加载所需的文件。这样,我们提高了应用程序的速度。同时,你可以在官方文档(docs.phalconphp.com/en/latest/reference/loader.html)中找到更多关于这个组件的信息。
创建配置文件和 Bootstrap
几乎任何应用程序都有一些将被重用的常量(数据库凭证、SMTP 凭证等)。对于我们的应用程序,我们将创建一个全局配置文件。这个文件将是 \Phalcon\Config 组件的一个实例。切换到 config 目录并创建它,内容如下:
<?php
return new \Phalcon\Config(array(
'application' => array(
'name' => 'Learning Phalcon'
),
'root_dir' => __DIR__.'/../',
'redis' => array(
'host' => '127.0.0.1',
'port' => 6379,
),
'session' => array(
'unique_id' => 'learning_phalcon',
'name' => 'learning_phalcon',
'path' => 'tcp://127.0.0.1:6379?weight=1'
),
'view' => array(
'cache' => array(
'dir' => __DIR__.'/../cache/volt/'
)
),
));
Phalcon\Config 组件简化了我们应用程序中配置数据的访问。默认情况下,数据以对象形式返回(例如,我们可以通过 $config->application->name path 访问应用程序名称),但它也提供了一个魔法方法来以数组形式返回数据——$config->toArray()。如果你使用 $config->toArray(),那么你将使用 $config['application']['name'] 语法来访问应用程序名称。关于这个组件的另一个有趣的事实是,我们可以使用 $config->merge($new_config) 语法将另一个数组合并到其中。
现在我们有了自动加载器和配置,让我们设置我们的 Bootstrap 文件。为此,在 modules 文件夹中创建一个名为 Bootstrap.php 的文件,并包含以下内容:
<?php
class Bootstrap extends \Phalcon\Mvc\Application
{
private $modules;
private $default_module = 'frontend';
public function __construct($default_module)
{
$this->modules = array(
'core' => array(
'className' => 'App\Core\Module',
'path' => __DIR__ . '/Core/Module.php'
),
'api' => array(
'className' => 'App\Api\Module',
'path' => __DIR__ . '/Api/Module.php'
),
'frontend' => array(
'className' => 'App\Frontend\Module',
'path' => __DIR__ . '/Frontend/Module.php'
),
'backoffice' => array(
'className' => 'App\Backoffice\Module',
'path' => __DIR__ . '/Backoffice/Module.php'
),
);
$this->default_module = $default_module;
}
private function _registerServices()
{
$default_module = $this->default_module;
$di = new \Phalcon\DI\FactoryDefault();
$config = require __DIR__.'/../config/config.php';
$modules = $this->modules;
include_once __DIR__.'/../config/loader.php';
include_once __DIR__.'/../config/services.php';
include_once __DIR__.'/../config/routing.php';
$this->setDI($di);
}
public function init()
{
$debug = new \Phalcon\Debug();
$debug->listen();
$this->_registerServices();
$this->registerModules($this->modules);
echo $this->handle()->getContent();
}
}
我们的 Bootstrap 文件扩展了 \Phalcon\Mvc\Application (docs.phalconphp.com/en/latest/reference/applications.html),这使我们能够访问 registerModules() 方法。类构造函数注册了所有我们的模块并设置了默认模块。_registerServices() 方法初始化 DI 并包含我们应用程序所需的文件。最后,init() 方法初始化应用程序。在这里,我们使用了 \Phalcon\Debug 组件,因为我们需要在任何时间都能调试应用程序。这不应该在生产环境中启用。
到目前为止,我们创建了文件夹结构、配置文件、自动加载器和 Bootstrap。我们将进一步创建服务、路由和前端模块文件。
准备初始 DI 接口和路由器
在 Bootstrap 中,我们没有两个文件:services.php 和 routing.php。services.php 文件将包含我们应用程序将使用的全局服务信息,而 routing.php 文件将包含我们的路由信息。让我们首先在我们的 config 文件夹中创建一个名为 services.php 的文件,并包含以下内容:
<?php
use \Phalcon\Logger\Adapter\File as Logger;
$di['session'] = function () use ($config) {
$session = new \Phalcon\Session\Adapter\Redis(array(
'uniqueId' => $config->session->unique_id,
'path' => $config->session->path,
'name' => $config->session->name
));
$session->start();
return $session;
};
$di['security'] = function () {
$security = new \Phalcon\Security();
$security->setWorkFactor(10);
return $security;
};
$di['redis'] = function () use ($config) {
$redis = new \Redis();
$redis->connect(
$config->redis->host,
$config->redis->port
);
return $redis;
};
$di['url'] = function () use ($config, $di) {
$url = new \Phalcon\Mvc\Url();
return $url;
};
$di['voltService'] = function($view, $di) use ($config) {
$volt = new \Phalcon\Mvc\View\Engine\Volt($view, $di);
if (!is_dir($config->view->cache->dir)) {
mkdir($config->view->cache->dir);
}
$volt->setOptions(array(
"compiledPath" => $config->view->cache->dir,
"compiledExtension" => ".compiled",
"compileAlways" => true
));
return $volt;
};
$di['logger'] = function () {
$file = __DIR__."/../logs/".date("Y-m-d").".log";
$logger = new Logger($file, array('mode' => 'w+'));
return $logger;
};
$di['cache'] = function () use ($di, $config) {
$frontend = new \Phalcon\Cache\Frontend\Igbinary(array(
'lifetime' => 3600 * 24
));
$cache = new \Phalcon\Cache\Backend\Redis($frontend, array(
'redis' => $di['redis'],
'prefix' => $config->application->name.':'
));
return $cache;
};
$di 变量是可用的,因为我们已经在 Bootstrap 的 _registerServices() 方法中初始化了它。$di 是 \Phalcon\DI\FactoryDefault() 的一个实例。让我们尝试理解我们设置的每个组件:
-
$di['session']默认情况下是可用的,但我们覆盖它,因为我们想使用 Redis 来存储我们的会话。 -
$di['security']默认情况下是可用的,但我们覆盖它,因为我们想设置比默认值更高的工作因子。我们将使用此组件来加密我们的密码。 -
$di['redis']连接到 Redis 服务器。我们传递来自我们的配置文件的参数。\Redis类已经可用,因为我们已经在第一章中安装了它(php5-redis)。 -
$di['url']默认情况下是可用的。我们之所以覆盖它,是为了与 Phalcon 的旧版本保持向后兼容。在过去,如果没有定义,我就无法访问它。自从 Phalcon 1.3 版本以来,它按预期工作。 -
$di['voltService']是一个自定义的 DI 组件,我们将用它来使用 Volt 模板引擎(你很快就会了解 Volt)。 -
$di['logger']是一个自定义的 DI 组件,它使用\Phalcon\Logger\Adapter\File。我们将使用它来记录不同的错误/警告。 -
$di['cache']也是一个自定义的 DI 组件,它使用 Igbinary 作为前端缓存,并使用 Redis 作为后端。如果您没有 Igbinary,您需要通过以下命令安装它:sudo pecl install igbinary。请注意,在安装 Igbinary 之后,您可能需要重新安装php5-redis。
由于我们将使用一些在 Phalcon 中默认不可用的组件,我们需要从 phalcon/incubator (github.com/phalcon/incubator) 安装它们。Incubator 是由社区开发的一系列组件的集合,这些组件可能或可能不会包含在 Phalcon 的核心中。我们现在需要的组件之一是 \Phalcon\Cache\Backend\Redis。
我们将使用 Composer (getcomposer.org/) 来管理我们的包依赖。要在 learning-phalcon.localhost 文件夹中安装 composer,请执行以下命令:
$ curl -s http://getcomposer.org/installer | php
现在,你应该在你的根目录中有一个名为 composer.phar 的新文件。接下来,让我们通过执行以下命令来安装 phalcon/incubator:
$ php composer.phar require phalcon/incubator dev-master
这将安装其他依赖项,如 Swift Mailer,因此可能需要几分钟才能完成。如果您检查文件夹结构,您将看到已创建一个名为 vendor 的新目录。这是 composer 的默认安装文件夹,所有包都将驻留在此。
然而,这还不够。为了自动加载来自 vendor 的文件,我们需要对我们的 public/index.php 文件进行一些小的修改,通过添加 composer 的自动加载器。新的 index.php 文件应该看起来像这样:
<?php
header('Content-Type: text/html; charset=utf-8');
mb_internal_encoding("UTF-8");
require_once __DIR__.'/../vendor/autoload.php';
require_once __DIR__.'/../modules/Bootstrap.php';
$app = new Bootstrap('frontend');
$app->init();
在模块中使用路由组件
我们将继续本章,通过为我们的应用程序创建路由。为此,切换到config目录,并创建一个名为routing.php的文件,内容如下:
<?php
$di['router'] = function() use ($default_module, $modules, $di, $config) {
$router = new \Phalcon\Mvc\Router(false);
$router->clear();
$moduleRouting = __DIR__.'/../apps/'.ucfirst($default_module).'/Config/routing.php';
if (file_exists($moduleRouting) && is_file($moduleRouting)) {
$router = include $moduleRouting;
} else {
$router->add('#^/(|/)$#', array(
'module' => $default_module,
'controller' => 'index',
'action' => 'index',
));
$router->add('#^/([a-zA-Z0-9\_]+)[/]{0,1}$#', array(
'module' => $default_module,
'controller' => 1,
));
$router->add('#^/{0,1}([a-zA-Z0-9\_]+)/([a-zA-Z0-9\_]+)(/.*)*$#', array(
'module' => $default_module,
'controller' => 1,
'action' => 2,
'params' => 3,
));
}
return $router;
};
在此文件中,我们使用了\Phalcon\Mvc\Router组件。我们检查模块是否有任何路由信息,如果有,就加载它;如果没有,就创建默认的路由规则。如果你一直跟随着我们,你应该有以下的目录结构:

在第一章中,我们已创建并启用了 Web 服务器的配置文件。此外,我们还编辑了主机文件,并且www.learning-phalcon.localhost指向我们的本地主机(127.0.0.1)。让我们尝试在我们的浏览器中访问http://www.learning-phalcon.localhost。
小贴士
请使用http://。否则,Chrome 以及其他浏览器可能无法访问此 URL,因为.dev不是一个注册的顶级域名。
如果你成功访问了应用程序,你应该会看到一个类似于以下截图的错误页面:

让我们通过创建我们Frontend模块所需的文件来修复这个错误。转到modules/Frontend文件夹,并创建一个名为Module.php的文件,内容如下:
<?php
namespace App\Frontend;
use Phalcon\Mvc\ModuleDefinitionInterface;
class Module implements ModuleDefinitionInterface
{
/**
* Registers the module auto-loader
*/
public function registerAutoloaders(\Phalcon\DiInterface di = null) {}
/**
* Registers the module-only services
*
* @param Phalcon\DI $di
*/
public function registerServices(\Phalcon\DiInterface $di)
{
$config = include __DIR__ . "/Config/config.php";
$di['config'] = $config;
include __DIR__ . "/Config/services.php";
}
}
现在,将此文件复制到每个模块,并更改命名空间。例如,位于Api模块中的Module.php文件应该有App\Api的命名空间。现在,你的模块目录结构应该如下所示:

如果你刷新页面,你会得到另一个错误,显示Phalcon\DI\Exception: Service 'view' was not found in the dependency injection container。这是因为每个模块都将有自己的config文件夹,我们需要在那里创建文件。转到modules/Frontend/目录,并创建一个名为Config的新文件夹,首字母大写。
注意
我们使用大写字母,因为它在命名空间内更容易阅读和加载。
现在,在modules/Frontend/Config/目录下创建一个名为config.php的文件,内容如下:
<?php
$config = require __DIR__.'/../../../config/config.php';
$module_config = array(
'application' => array(
'controllersDir' => __DIR__ . '/../Controllers/',
'modelsDir' => __DIR__ . '/../Models/',
'viewsDir' => __DIR__ . '/../Views/',
'baseUri' => '/',
'cryptSalt' => '5up3r5tr0n6p@55',
'publicUrl' => 'http://www.learning-phalcon.localhost'
));
$config->merge($module_config);
return $config;
在第一行,我们将全局配置文件的内容赋值给$config变量。然后,我们设置模块配置,并将这些信息合并到我们的全局$config变量中。接下来,让我们在同一文件夹(modules/Frontend/Config/)中创建路由和服务文件:
services.php:
<?php
$di['dispatcher'] = function () use ($di) {
$eventsManager = $di->getShared('eventsManager');
$dispatcher = new \Phalcon\Mvc\Dispatcher();
$dispatcher->setEventsManager($eventsManager);
$dispatcher->setDefaultNamespace('App\Frontend\Controllers');
return $dispatcher;
};
$di['url']->setBaseUri(''.$config->application->baseUri.'');
$di['view'] = function () {
$view = new \Phalcon\Mvc\View();
$view->setViewsDir(__DIR__ . '/../Views/Default/');
$view->registerEngines(array(
".volt" => 'voltService'
));
return $view;
};
在services.php文件中,我们覆盖了 DI 的 URL 和分发器组件,并创建了一个自定义视图服务,该服务将使用我们在全局服务文件(config/services.php)中声明的voltService。
routing.php:
<?php
$router = new \Phalcon\Mvc\Router(false);
$router->clear();
$router->add('/', array(
'module' => 'frontend',
'controller' => 'index',
'action' => 'index'
));
return $router;
我们需要在这里的 routing.php 文件,因为我们将要为我们的前端模块创建自定义路由。接下来我们需要的是控制器。通常来说,创建一个基础文件,并让其他所有文件都扩展这个基础文件是一个好的实践。这样你将避免代码重复。当然,你也可以使用其他方法的 traits,但在这个项目中,我们将大部分时间使用基础文件。
因此,让我们在 modules/Frontend/ 中创建 Controllers 目录,并在 modules/Frontend/Controllers/ 目录中创建一个空白的基础控制器:
$ cd modules/Frontend/
$ mkdir Controllers
$ touch Controllers/BaseController.php
现在,将以下内容放入 BaseController.php 文件中:
<?php
namespace App\Frontend\Controllers;
class BaseController extends \Phalcon\Mvc\Controller
{
}
接下来,在这里创建另一个名为 IndexController.php 的文件,并包含以下内容:
<?php
namespace App\Frontend\Controllers;
class IndexController extends BaseController
{
public function indexAction()
{
}
}
如果你检查 routing.php 文件,你会注意到默认路由指向索引控制器 → 索引动作。在 Phalcon 中,标准是任何控制器都应该有 Controller 后缀,任何与路由匹配的公共动作都应该有 Action 后缀。
让我们看看我们的 modules/Frontend 目录结构。它应该正好是这样的:

如果你尝试刷新 http://www.learning-phalcon.localhost 上的页面,你会看到一个空白页面。这是完全正常的。接下来,让我们将 Controllers 和 Config 文件夹从我们的 Frontend 模块复制到每个剩余的模块(Api、Core 和 Backoffice)。在我们复制文件后,我们需要更改命名空间,并将与前端相关的内容替换为新模块的名称。
例如,在我们将文件复制到 Api 模块之后,我们需要做以下操作:
-
在
Controllers/文件夹中将App\Frontend\Controllers命名空间替换为App\Api\Controllers。 -
在
Config/routing.php中将单词 "frontend" 替换为单词 "api"。 -
在
services.php中将\App\Frontend\Controllers替换为App\Api\Controllers。 -
将模块名的小写形式附加到
config.php文件中的baseUri键上。结果应该是'baseUri' => '/api/'。
完成后,新的目录结构应该是这样的:

创建基本布局
现在,是时候稍微关注一下布局(模板)了。我们将使用 twitter-bootstrap 进行 CSS 和 jQuery。然后,我们将创建第一个视图,以便结束这一章。
导航到 public/folder 并创建一个名为 assets 的文件夹。然后,进入 assets 并创建一个名为 default 的文件夹:
$ cd public
$ mkdir -p assets/default
我正在使用 Bower (bower.io/) 作为我的资产包管理器。它对于 PHP 包来说就像是 composer。
如果你没有安装 Bower 并且不想使用它,你需要在你的 public/default/assets 文件夹中创建一个名为 bower_components 的文件夹,并从 GitHub 克隆 twitter-bootstrap 仓库。你还需要下载 jQuery 并将其解压到 bower_components 文件夹中。
$ cd public/default/assets/bower_components
$ git clone https://github.com/twbs/bootstrap.git
如果你拥有 Bower,那么只需前往public/default/assets文件夹并安装 twitter Bootstrap:
$ cd public/default/assets
$ bower install bootstrap
这将自动安装 jQuery,因为 Bootstrap 需要 jQuery,而 Bower 足够智能,能够检查依赖项。
在不久的将来,我们还需要一些自定义的 JavaScript、CSS 文件和图片。我们需要在 public/assets/default 文件夹中创建这些目录,并且我们还将创建两个名为lp.js和lp.css的空文件。你的 public 文件夹的目录结构应该是这样的:

让我们回到我们的frontend模块。导航到modules/Frontend并创建一个名为Views的文件夹。然后,在Views文件夹中,创建另一个名为Default的文件夹:
$ cd modules/Frontend
$ mkdir -p Views/Default
记住,我们正在使用 Volt (docs.phalconphp.com/en/latest/reference/volt.html) 作为我们的模板引擎。我们在第一章中已经讨论了 Volt 的语法,随着我们继续前进,我们将更深入地探讨这个主题,但会在适当的时候。现在,我们只想完成我们的项目结构,并为前端模块渲染一个示例布局。
这样我们可以确保到目前为止我们做的一切都符合预期。在services.php的依赖注入中,我们将文件扩展名.volt 分配给了我们的模板引擎。因此,我们将要创建的所有视图都将具有.volt 扩展名。让我们创建主布局。导航到modules/Frontend/Views/Default并创建一个名为layout.volt的新文件,内容如下:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block pageTitle %}Learning Phalcon{% endblock %}</title>
{{ stylesheetLink('../assets/default/bower_components/bootstrap/dist/css/bootstrap.min.css') }}
{{ stylesheetLink('../assets/default/css/lp.css') }}
<!--[if lt IE 9]>
<script src="img/html5shiv.min.js"></script>
<script src="img/respond.min.js"></script>
<![endif]-->
</head>
<body>
{% block body %}
<h1>Main layout</h1>
{% endblock %}
{{ javascriptInclude("../assets/default/bower_components/jquery/dist/jquery.min.js") }}
{{ javascriptInclude("../assets/default/bower_components/bootstrap/dist/js/bootstrap.min.js") }}
{{ javascriptInclude("../assets/default/js/lp.js") }}
{% block javascripts %} {% endblock %}
</body>
</html>
正如我们之前提到的,我们现在不会讨论 volt 的语法。为了渲染模板,还需要执行一个额外的步骤。我们需要创建一个名为index的新文件夹;然后,在index文件夹中,我们还需要创建一个名为index.volt的文件。这将与IndexController → IndexAction相匹配。
$ cd modules/Frontend/Views/Default
$ mkdir index
$ cd index
$ touch index.volt
index.volt文件的内容如下:
{% extends 'layout.volt' %}
{% block body %}
I did it !
{% endblock %}
我们前端模块的最终目录结构应该是这样的:

现在,让我们尝试刷新页面http://www.learning-phalcon.localhost。如果你看到以下截图中的页面,那么你就成功了!

摘要
在本章中,我们学习了 MVC 的基础知识,为我们的项目创建了文件夹结构,并了解了一些关于如何使用 DI 组件、路由组件和视图组件的方法。我们还创建了视图,并从前端模块渲染了第一个页面。
在接下来的章节中,我们将学习 Phalcon 的 ORM 和 ODM,并且我们会继续添加功能,直到我们拥有一个功能齐全的在线报纸网站。
第三章. 学习 Phalcon 的 ORM 和 ODM
现在你已经对 Phalcon 的内部结构有了一些了解,我们也拥有了我们的项目结构,我们可以继续进行更严肃的事情——数据库。在本章中,我们将涵盖以下主题:
-
SQL 和 NoSQL 数据库之间的主要区别
-
学习如何连接到数据库
-
ORM/ODM CRUD 操作(创建、读取、更新和删除)和事务
-
了解 ORM 的一般缺点,以及我们如何通过缓存方法来提高性能
SQL 和 NoSQL 数据库之间的主要区别
MySQL 很棒!它是一个功能强大的关系型数据库管理系统(RDBMS),拥有很大的市场份额,并得到了一个庞大社区的支持。它是开源的(尽管存在企业版),而且几乎每个 PHP 应用程序都将它作为主要的数据库系统。
但有时候,你会注意到 MySQL 并不足以满足你的需求。也许你听说过人们谈论 MongoDB、CouchDB、Cassandra 等等。在我们的项目中,我们将使用 MongoDB,所以我将谈谈它。
通常,当你想要开发实时分析、缓存和日志;存储大数据,如评论或点赞;以及处理许多其他情况时,你会使用 NoSQL 系统,如 MongoDB。
SQL 数据库和 NoSQL 数据库之间的一些区别如下:
-
NoSQL 不是关系型的
-
NoSQL 不可靠;或者说,在复杂系统中使用它并不安全,因为它不支持事务
-
关系型数据库需要一个具有定义属性的架构来存储数据,但 NoSQL 数据库通常允许自由流动的操作
在我们的项目后期,我们将主要使用 MongoDB 进行日志和评论。我们已经在第一章中安装了 MongoDB。
让我们看看一些使用示例:
| SQL | MongoDB |
|---|---|
SELECT a,b FROM users |
$db->users->find([], ["a" => 1, "b" => 1]); |
SELECT * FROM users WHERE age=33 |
$db->users->find(["age" => 33]); |
在官方 PHP 网站上,你可以查看完整的 SQL 到 MongoDB 映射图表(php.net/manual/ro/mongo.sqltomongo.php)。
连接到数据库
在上一章中,我们添加了全局配置文件和每个模块的配置文件。为了能够连接到数据库,我们首先需要在配置文件中添加一些行。
让我们回顾一下我们的目录结构:

为了连接到数据库,我们需要创建它。创建一个名为learning_phalcon的数据库。你可以使用以下命令行快速完成此操作:
$ mysql -u YOURUSERNAME -p -e 'create database learning_phalcon;'
打开全局配置文件(config/config.php),并添加以下行:
'database' => array(
'adapter' => 'Mysql',
'host' => 'localhost',
'username' => 'Input your username here',
'password' => 'Input your password here',
'dbname' => 'learning_phalcon',
),
现在我们已经有了数据库的配置参数,我们必须创建一个服务。打开全局服务文件(config/service.php),并添加以下行:
$di['db'] = function () use ($config) {
return new \Phalcon\Db\Adapter\Pdo\Mysql(array(
"host" => $config->database->host,
"username" => $config->database->username,
"password" => $config->database->password,
"dbname" => $config->database->dbname,
"options" => array(
\PDO::MYSQL_ATTR_INIT_COMMAND => "SET NAMES 'UTF8'",
\PDO::ATTR_CASE => \PDO::CASE_LOWER,
\PDO::MYSQL_ATTR_USE_BUFFERED_QUERY => true,
\PDO::ATTR_PERSISTENT => true
)
));
};
现在我们可以保存并关闭此文件。接下来,我们将在数据库中创建一个名为article的表,并将一条样本记录插入到这个表中:
USE learning_phalcon;
CREATE TABLE IF NOT EXISTS `article` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`article_short_title` varchar(255) COLLATE utf8_unicode_ci NOT NULL,
`article_long_title` varchar(255) COLLATE utf8_unicode_ci NOT NULL,
`article_slug` varchar(255) COLLATE utf8_unicode_ci NOT NULL,
`article_description` text COLLATE utf8_unicode_ci NOT NULL,
PRIMARY KEY (`id`),
KEY `id` (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_unicode_ci AUTO_INCREMENT=1 ;
INSERT INTO `learning_phalcon`.`article` (
`id` ,
`article_short_title` ,
`article_long_title` ,
`article_slug` ,
`article_description`
)
VALUES (
NULL , 'Test article short title', 'Test article long title', 'test-article-short-title', 'Test article description'
);
为了测试数据库连接,我们将使用我们的Frontend和Core模块。在Core模块中,我们将为文章表创建一个模型。根据上一章,Frontend模块的目录结构应该如下所示:

对于Core模块,结构应该如下所示:

我们将在一个名为Models的新文件夹中创建article表的模型。在modules/Core下创建Models目录:
$ cd modules/Core
$ mkdir Models
在Models目录下,创建两个新的文件:Base.php和Article.php。我们现在将查看这些文件:
-
Base.php的内容如下:<?php namespace App\Core\Models; class Base extends \Phalcon\Mvc\Model { } -
Article.php的内容如下:<?php namespace App\Core\Models; class Article extends \Phalcon\Mvc\Model { protected $id; protected $article_short_title; protected $article_long_title; protected $article_slug; protected $article_description; public function setId($id) { $this->id = $id; return $this; } public function setArticleShortTitle($article_short_title) { $this->article_short_title = $article_short_title; return $this; } public function setArticleLongTitle($article_long_title) { $this->article_long_title = $article_long_title; return $this; } public function setArticleSlug($article_slug) { $this->article_slug = $article_slug; return $this; } public function setArticleDescription($article_description) { $this->article_description = $article_description; return $this; } public function getId() { return $this->id; } public function getArticleShortTitle() { return $this->article_short_title; } public function getArticleLongTitle() { return $this->article_long_title; } public function getArticleSlug() { return $this->article_slug; } public function getArticleDescription() { return $this->article_description; } }
我个人喜欢尽可能干净地工作。我们将使用一个中间文件——管理器——来处理所有复杂的逻辑。这意味着你不会在控制器中使用模型,也不会通过添加查询或其他类型的数据来修改模型。模型应该尽可能干净。另一方面,有些人喜欢将复杂的逻辑移动到模型中。这是你的选择,但在这本书中,我们将使用管理器。话虽如此,让我们为文章创建管理器:
-
前往
modules/Core/并创建一个名为Managers的文件夹:$ cd modules/Core/ $ mkdir Managers -
创建两个名为
BaseManager.php和ArticleManager.php的新文件,并添加以下内容:-
BaseManager.php文件将被放置在modules/Core/Managers/下:<?php namespace App\Core\Managers; class BaseManager extends \Phalcon\Mvc\User\Module { } -
ArticleManager.php文件将被放置在modules/Core/Managers/下:<?php namespace App\Core\Managers; use App\Core\Models\Article; class Article extends Base { public function find($parameters = null) { return Article::find($parameters); } }
-
Core模块的新目录结构现在应该如下所示:

到目前为止一切顺利!让我们尝试使用这个管理器来列出Article表中的记录。为此,我们首先需要将其声明为一个服务。为此,请执行以下步骤:
-
打开全局服务文件(
config/service.php),并添加以下内容:$di['core_article_manager'] = function() { return new App\Core\Managers\ArticleManager(); };我们将使用
frontend模块来完成这个测试。 -
导航到
Frontend目录,编辑modules/Frontend/Config/routing.php文件,并添加以下内容:$router->add('#^/articles[/]{0,1}$#', array( 'module' => 'frontend', 'controller' => 'article', 'action' => 'list' )); $router->add('#^/articles/([a-zA-Z0-9\-]+)[/]{0,1}$#', array( 'module' => 'frontend', 'controller' => 'article', 'action' => 'read', 'slug' => 1 ));第一个路由模式将把对
http://www.learning-phalcon.localhost/articles的任何请求指向frontend模块、article控制器和listAction操作。第二种模式将指向文章控制器中的不同操作,命名为
readAction,并将 slug 参数传递给此操作。 -
接下来,我们将创建
article控制器和模板。导航到modules/Frontend/Controllers,并创建一个名为ArticleController.php的文件,内容如下:<?php namespace App\Frontend\Controllers; class ArticleController extends BaseController { public function listAction() { $article_manager = $this->getDI()->get('core_article_manager'); $this->view->articles = $article_manager->find(); } }在
listAction中,我们从 DI 获取article管理器,并将find()方法的结果分配给名为articles的视图变量。 -
现在,让我们为这个操作创建一个模板。导航到
modules/Frontend/Views/Default,并创建一个名为article的新目录:$ cd modules/Frontend/Views/Default $ mkdir article -
在
article文件夹中,创建一个名为list.volt的文件,并将其内容添加如下:{% extends 'layout.volt' %} {% block body %} <ul> {% for article in articles %} <li><a href="{{ url('article/' ~ article.getArticleSlug()) }}">{{ article.getArticleShortTitle() }}</a></li> {% endfor %} </ul> {% endblock %}
Frontend 目录结构应如下所示:

如果你一切都按照书中的步骤来做,那么你已经准备好了。现在你可以访问 http://www.learning-phalcon.localhost/articles,你应该能看到如这里所示的文章测试内容:

干得好!你现在已连接到数据库,并拥有了第一个模型和管理器。我们将继续本章,进行数据操作、验证以及简单的 MySQL 和 MongoDB 查询。
ORM/ODM 操作(创建、更新、删除、事务和验证)
在我们继续之前,让我们通过添加一些列来使我们的文章表变得更加复杂。我们将添加三个额外的列:is_published、created_at 和 updated_at。
is_published 字段将是一个布尔类型(在 MySQL 中,它将具有 0 或 1 的值),而 created_at 和 updated_at 字段将具有 datetime 类型。它们将保存有关我们的文章何时创建以及何时更新的信息。你可以使用以下代码修改 article 表并添加这些字段:
ALTER TABLE `article` ADD `is_published` BOOLEAN NOT NULL DEFAULT FALSE ,
ADD `created_at` DATETIME NOT NULL ,
ADD `updated_at` DATETIME NULL DEFAULT NULL ;
我们还需要对我们的 Article 模型进行修改,并添加这些新字段的获取器和设置器。打开 modules/Core/Models/Article.php 文件,并添加以下内容:
protected $is_published;
protected $created_at;
protected $updated_at;
public function setIsPublished($is_published)
{
$this->is_published = $is_published;
return $this;
}
public function setCreatedAt($created_at)
{
$this->created_at = $created_at;
return $this;
}
public function setUpdatedAt($updated_at)
{
$this->updated_at = $updated_at;
return $this;
}
public function getIsPublished()
{
return $this->is_published;
}
public function getCreatedAt()
{
return $this->created_at;
}
public function getUpdatedAt()
{
return $this->created_at;
}
由于我们将会使用的大部分 CRUD 操作将由 Backoffice 模块处理,因此我们将按照前端模块的方式设置此模块。此模块的实际开发将在本书的后续章节中进行。目前,我们将为 Article 表启用快速简单的 CRUD 操作。
让我们回顾一下 Backoffice 目录结构。到目前为止,你应该有以下结构:

为了使其功能正常,我们需要做以下事情:
-
添加路由信息
-
创建控制器和操作
-
创建视图
添加路由信息
编辑全局路由文件 config/routing.php,并添加以下内容:
foreach ($modules as $moduleName => $module){
if ($default_module == $moduleName) {
continue;
}
$moduleRouting = __DIR__.'/../modules/'.ucfirst($moduleName).'/Config/routing.php';
include $moduleRouting;
}
从 Backoffice 模块的 modules/Backoffice/Config/routing.php 中删除(或覆盖)路由文件,并添加一个包含以下内容的新文件:
<?php
$router->add('#^/backoffice(|/)$#', array(
'module' => 'backoffice',
'controller' => 'index',
'action' => 'index',
));
$router->add('#^/backoffice/([a-zA-Z0-9\_]+)[/]{0,1}$#', array(
'module' => 'backoffice',
'controller' => 1,
));
$router->add('#^/backoffice[/]{0,1}([a-zA-Z0-9\_]+)/([a-zA-Z0-9\_]+)(/.*)*$#', array(
'module' => 'backoffice',
'controller' => 1,
'action' => 2,
'params' => 3,
));
创建控制器和操作
导航到 modules/Backoffice/Controllers/,创建一个名为 ArticleController.php 的新文件,并包含以下内容:
<?php
namespace App\Backoffice\Controllers;
class ArticleController extends BaseController
{
public function indexAction()
{
return $this->dispatcher->forward(['action' => 'list']);
}
public function listAction()
{
$article_manager = $this->getDI()->get('core_article_manager');
$this->view->articles = $article_manager->find();
}
}
创建视图
从 Frontend 复制视图。我们将在第七章 The Backoffice Module (Part 1) 中对其进行适配,后端模块(第一部分):
$ cd modules/Backoffice
$ cp -r ../Frontend/Views .
现在,让我们修改一下视图,以便我们可以有一个漂亮的Backoffice模块。转到modules/Backoffice/Views/,打开layout.volt文件,并做出以下更改。
查找以下行:
<title>{% block pageTitle %}Learning Phalcon{% endblock %}</title>
将其替换为以下行:
<title>{% block pageTitle %}Backoffice - Learning Phalcon{% endblock %}</title>
在public/assets/default/css/中创建一个名为lp.backoffice.css的新文件,并将以下内容添加到其中:
body { padding-top: 50px; }
.sub-header { padding-bottom: 10px; border-bottom: 1px solid #eee; }
.navbar-fixed-top { border: 0; }
.sidebar { display: none; }
@media (min-width: 768px) {
.sidebar {position: fixed;top: 51px;bottom: 0;left: 0;z-index: 1000;display: block;padding: 20px;overflow-x: hidden;overflow-y: auto;background-color: #f5f5f5;border-right: 1px solid #eee;}
}
.nav-sidebar { margin-right: -21px; margin-bottom: 20px; margin-left: -20px; }
.nav-sidebar > li > a { padding-right: 20px; padding-left: 20px; }
.nav-sidebar > .active > a,
.nav-sidebar > .active > a:hover,
.nav-sidebar > .active > a:focus { color: #fff; background-color: #428bca; }
.main { padding: 20px; }
@media (min-width: 768px) {
.main { padding-right: 40px; padding-left: 40px; }
}
.main .page-header { margin-top: 0; }
然后,我们在layout.volt文件中包含前面的文件。我们通过查找以下行来完成此操作:
{{ stylesheetLink('../assets/default/css/lp.css') }}
我们将其替换为以下行:
{{ stylesheetLink('../assets/default/css/lp.backoffice.css') }}
在相同的layout.volt文件中,删除以下代码片段:
{% block body %}
<h1>I did it !</h1>
{% endblock %}
在<body>和</body>标签之间添加以下内容:
<nav class="navbar navbar-inverse navbar-fixed-top" role="navigation">
<div class="container-fluid">
<div class="navbar-header">
<button type="button" class="navbar-toggle collapsed" data-toggle="collapse" data-target="#navbar" aria-expanded="false" aria-controls="navbar">
<span class="sr-only">Toggle navigation</span>
<span class="icon-bar"></span>
<span class="icon-bar"></span>
<span class="icon-bar"></span>
</button>
<a class="navbar-brand" href="#">Learning Phalcon</a>
</div>
<div id="navbar" class="navbar-collapse collapse">
<ul class="nav navbar-nav navbar-right">
<li><a href="#">Sign out</a></li>
</ul>
</div>
</div>
</nav>
<div class="container-fluid">
<div class="row">
<div class="col-sm-3 col-md-2 sidebar">
<ul class="nav nav-sidebar">
<li class="active"><a href="{{ url('article/list') }}">Articles <span class="sr-only">(current)</span></a></li>
<li><a href="#">Other menu item</a></li>
</ul>
</div>
<div class="col-sm-9 col-sm-offset-3 col-md-10 col-md-offset-2 main">
{% block body %}
<h1 class="page-header">Dashboard</h1>
<h2 class="sub-header">Section title</h2>
<div class="table-responsive">
</div>
{% endblock %}
</div>
</div>
</div>
我们已经编辑完layout.volt文件,但我们需要进行一个额外的更改。打开modules/Backoffice/Views/Default/article/list.volt,并用以下代码替换其内容:
{% extends 'layout.volt' %} {% block body %}
<h1 class="page-header">Articles</h1>
<h2 class="sub-header">List</h2>
<div class="table-responsive">
<table class="table table-striped">
<thead>
<tr>
<th>#</th>
<th>Title</th>
<th>Is published</th>
<th>Created at</th>
<th>Updated at</th>
<th>Options</th>
</tr>
</thead>
<tbody>
{% for article in articles %}
<tr>
<td>{{ article.getId() }}</td>
<td>{{ article.getArticleShortTitle() }}</td>
<td>{{ article.getIsPublished() }}</td>
<td>{{ article.getCreatedAt() }}</td>
<td>{{ article.getUpdatedAt() }}</td>
<td>
<a href="{{ url('article/edit/' ~ article.getId()) }}">Edit</a> |
<a href="{{ url('article/delete/' ~ article.getId()) }}">Delete</a> |
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endblock %}
在所有这些更改之后,新的目录结构应该看起来如下所示:

让我们在浏览器中打开http://www.learning-phalcon.localhost/backoffice/article/list。如果一切顺利,您应该能够看到新的Backoffice布局和我们的测试文章,列表如下所示:

现在我们有了 UI,您可以开始学习 Phalcon 的 ORM。您需要知道 Phalcon 提供了三种与数据库交互的方式:
-
使用 ORM
-
使用 PHQL
-
使用原始 SQL
我们将在本章中学习所有这些。让我们从 ORM 开始。
使用 ORM 进行 CRUD 操作
通过使用 ORM,实际上您在代码中几乎不需要编写任何 SQL。一切都是面向对象的,并且使用模型来执行操作。第一个,也是最基本,的操作是检索数据。在以前的日子里,您会这样做:
$result = mysql_query("SELECT * FROM article");
我们模型所扩展的类是Phalcon\Mvc\Model。这个类内置了一些非常有用的方法,例如find()、findFirst()、count()、sum()、maximum()、minimum()、average()、save()、create()、update()和delete()。
CRUD – 读取数据
我们已经在article管理器中调用Article::find()时使用了find()方法。默认情况下,此方法将返回article表中的所有记录,并按自然顺序排序。它还接受一个带有参数的数组。以下代码示例将解释这一点:
$article_slug = "test-article-short-title";
$result = Article::find(
[
"article_slug = :article_slug:",
"bind" => ["article_slug" => $article_slug]
"order" => "created_at DESC",
"limit" => 1
]
);
在前面的示例中,我们正在搜索包含test-article-short-title文章短标题的记录。我们按created_at字段降序绑定数据,并限制返回的行数为一条。参数数组的第一个键始终应该是条件。绑定参数是避免 SQL 注入的好做法。我建议您始终使用它。
Article::find()的结果是一个对象数组。这意味着如果我们需要遍历结果,我们可以这样做:
foreach ($result as $article) {
echo $article->getTitle();
}
让我们在article表中添加两条新记录,这样您就可以亲眼看到正在发生的事情:
INSERT INTO `learning_phalcon`.`article` (`id` ,`article_short_title` ,`article_long_title` ,`article_slug` ,`article_description` ,`is_published` ,`created_at` ,`updated_at`)
VALUES (NULL , 'Test article short title 2', 'Test article long title 2', 'test-article-short-title-2', 'Test article description 2', '0', '2014-12-14 05:13:26', NULL);
INSERT INTO `learning_phalcon`.`article` (`id` ,`article_short_title` ,`article_long_title` ,`article_slug` ,`article_description` ,`is_published` ,`created_at` ,`updated_at`)
VALUES (NULL , 'Test article short title 3', 'Test article long title 3', 'test-article-short-title-3', 'Test article description 3', '0', '2014-12-14 05:13:26', NULL);
如果你现在访问http://www.learning-phalcon.localhost/backoffice/article/list,你应该能够看到新记录,如本截图所示:

接下来,我们将进行一些排序测试。为了参考,前一个截图显示的默认顺序是自然顺序,ID 为 1、2 和 3。请记住这一点,因为我们将在接下来的几行中引用这些 ID。
打开article控制器modules/Backoffice/Controllers/ArticleController.php,然后删除以下行:
$this->view->articles = $article_manager->find();
现在添加以下行,这将按创建日期降序排列文章:
$articles = $article_manager->find([
'order' => 'created_at DESC'
]);
$this->view->articles = $articles;
如果你刷新http://www.learning-phalcon.localhost/backoffice/article/list页面,你会看到记录的顺序不同。你应该看到的顺序是:3、2 和 1。
随意练习并尝试按不同的列排序,并添加限制。
另一个有用的方法是findFirst()。此方法接受与find()相同的参数,但结果将是一个Article模型的实例;这意味着你不需要在记录之间迭代:
$article = Article::findFirst();
echo $article->getTitle();
一些有用的方法是魔术方法,findBy*()和findFirstBy*()。例如,如果你需要通过 slug 搜索文章,你可以使用这些魔术方法这样做:
$articles = Article::findByArticleSlug('test-article-short-title');
foreach ($articles as $article) {
echo $article->getId();
}
$article = Article:;findFirstByArticleSlug('test-article-short-title');
echo $article->getId();
CRUD – 创建数据
使用 ORM 创建数据比听起来容易。我们将利用模型。记住我告诉你的——我喜欢尽可能保持模型干净。这就是为什么我们大多数时候都会创建并使用管理器。在modules/Core/Managers/ArticleManager.php中打开article管理器,并添加以下代码:
public function create($data)
{
$article = new Article();
$article->setArticleShortTitle($data['article_short_title']);
$article->setArticleLongTitle($data['article_long_title']);
$article->setArticleDescription($data['article_description']);
$article->setArticleSlug($data['article_slug']);
$article->setIsPublished(0);
$article->setCreatedAt(new \Phalcon\Db\RawValue('NOW()'));
if (false === $article->create()) {
foreach ($article->getMessages() as $message) {
$error[] = (string) $message;
}
throw new \Exception(json_encode($error));
}
return $article;
}
接下来,我们将在控制器中添加一个虚拟的createAction方法。打开modules/Backoffice/Controllers/ArticleController.php,并添加以下内容:
public function createAction()
{
$this->view->disable();
$article_manager = $this->getDI()->get('core_article_manager');
try {
$article = $article_manager->create([]);
echo $article->getArticleShortTitle(), " was created.";
} catch (\Exception $e) {
echo $e->getMessage();
}
}
当你访问http://www.learning-phalcon.localhost/backoffice/article/create时,你会看到一些错误,类似于本截图所示:

这很正常,因为我们没有向我们的create()方法传递任何参数。通过向创建方法添加这些参数来修改createAction方法:
$article = $article_manager->create([
'article_short_title' => 'Test article short title 5',
'article_long_title' => 'Test article long title 5',
'article_description' => 'Test article description 5',
'article_slug' => 'test-article-short-title-5'
]);
如果我们刷新http://www.learning-phalcon.localhost/backoffice/article/create页面,我们应该会看到一个类似于以下所示的成功消息:

小贴士
每次你刷新这个页面,数据库中都会插入一条新记录。你可以访问http://www.learning-phalcon.localhost/backoffice/article/list来查看新记录。
让我们快速分析一下create()方法:
我们实例化Article模型,并使用为其编写的 setter 分配值。然后,我们调用内置的create()方法来创建数据。如果有任何错误,我们读取它们,并使用这些错误抛出异常(JSON 编码),否则我们返回新创建的对象。
小贴士
你也可以使用save()方法代替create()。
如果你有一个大表(有数十个列),当你创建对象时,你可能想使用内置的assign()方法,而不是通过每个列的设置器来分配。你可以使用键值数组来做这件事,其中键是列的名称,例如:
$article = $article_manager->create([
'article_short_title' => 'Test article short title 5',
'article_long_title' => 'Test article long title 5',
'article_description' => 'Test article description 5',
'article_slug' => 'test-article-short-title-5'
]);
// create() method from manager:
$article = new Article();
$article->assign($data);
$article->create();
你可能会想知道为什么created_at被分配了\Phalcon\Db\RawValue('NOW()')。好吧,无论何时你需要分配数据库驱动程序特定的/内置数据,你都需要使用\Phalcon\Db\RawValue()。
在我们的例子中,我们使用它来调用NOW() MySQL 函数,该函数返回当前日期和时间。如果你正在处理日期敏感数据,我建议你使用 PHP 日期而不是依赖于任何数据库时间戳。
CRUD – 更新数据
更新数据就像创建数据一样简单。我们唯一需要做的是找到我们想要更新的记录。打开文章管理器,并添加以下代码:
public function update($id, $data)
{
$article = Article::findFirstById($id);
if (!$article) {
throw new \Exception('Article not found', 404);
}
$article->setArticleShortTitle($data['article_short_title']);
$article->setUpdatedAt(new \Phalcon\Db\RawValue('NOW()'));
if (false === $article->update()) {
foreach ($article->getMessages() as $message) {
$error[] = (string) $message;
}
throw new \Exception(json_encode($error));
}
return $article;
}
如你所见,我们正在将一个新的变量$id传递给update方法,并搜索 ID 等于$id变量值的文章。为了举例,这个方法现在只会更新文章标题和updated_at字段。
接下来,我们将创建一个新的虚拟方法,就像我们为文章创建的那样,名为create。打开modules/Backoffice/Controllers/ArticleController.php,并添加以下代码:
public function updateAction($id)
{
$this->view->disable();
$article_manager = $this->getDI()->get('core_article_manager');
try {
$article = $article_manager->update($id, [
'article_short_title' => 'Modified article 1'
]);
echo $article->getId(), " was updated.";
} catch (\Exception $e) {
echo $e->getMessage();
}
}
如果你现在访问http://www.learning-phalcon.localhost/backoffice/article/update/1,你应该能看到1 已更新的响应。返回到文章列表,你会看到新的标题,并且更新列将有一个新的值。
CRUD – 删除数据
删除数据更容易,因为我们不需要做更多的事情,只需调用内置的delete()方法。打开文章管理器,并添加以下代码:
public function delete($id)
{
$article = Article::findFirstById($id);
if (!$article) {
throw new \Exception('Article not found', 404);
}
if (false === $article->delete()) {
foreach ($article->getMessages() as $message) {
$error[] = (string) $message;
}
throw new \Exception(json_encode($error));
}
return true;
}
我们将再次创建一个虚拟方法来删除记录。打开modules/Backoffice/Controllers/ArticleControllers.php,并添加以下代码:
public function deleteAction($id)
{
$this->view->disable();
$article_manager = $this->getDI()->get('core_article_manager');
try {
$article_manager->delete($id);
echo "Article was deleted.";
} catch (\Exception $e) {
echo $e->getMessage();
}
}
要测试这个,只需访问http://www.learning-phalcon.localhost/backoffice/article/delete/1。如果一切顺利,你应该会看到文章已被删除的消息。返回到文章列表,你将看不到 ID 为1的文章了。
这四个基本方法是:创建、读取、更新和删除。在本书的后面部分,我们将大量使用这些方法。
小贴士
如果你需要/想要,可以使用 Phalcon 开发者工具自动生成 CRUD。更多信息请查看github.com/phalcon/phalcon-devtools。
使用 PHQL
个人而言,我不是 PHQL 的粉丝。我更喜欢使用 ORM 或原始查询。但如果你觉得用它很舒服,请随意使用。PHQL 与编写原始 SQL 查询非常相似。主要区别在于您需要传递一个模型而不是表名,并使用模型管理器服务或直接调用 \Phalcon\Mvc\Model\Query 类。以下是一个类似于内置 find() 方法的示例:
public function find()
{
$query = new \Phalcon\Mvc\Model\Query("SELECT * FROM App\Core\Models\Article", $this->getDI());
$articles = $query->execute();
return $articles;
}
要使用模型管理器,我们需要注入这个新服务。打开全局服务文件,config/service.php,并添加以下代码:
$di['modelsManager'] = function () {
return new \Phalcon\Mvc\Model\Manager();
};
现在我们将使用 modelsManager 服务重写 find() 方法:
public function find()
{
$query = $this->modelsManager->createQuery("SELECT * FROM App\Core\Models\Article");
$articles = $query->execute();
return $articles;
}
如果我们需要绑定参数,方法可以像这样:
public function find()
{
$query = $this->modelsManager->createQuery("SELECT * FROM App\Core\Models\Article WHERE id = :id:");
$articles = $query->execute(array(
'id' => 2
));
return $articles;
}
注意
在我们的项目中,我们不会使用 PHQL。如果您对此感兴趣,您可以在官方文档中找到更多信息,请参阅docs.phalconphp.com/en/latest/reference/phql.html。
使用原始 SQL
有时,使用原始 SQL 是执行复杂查询的唯一方法。让我们看看自定义 find() 方法和自定义 update() 方法将如何看起来:
<?php
use Phalcon\Mvc\Model\Resultset\Simple as Resultset;
class Article extends Base
{
public static function rawFind()
{
$sql = "SELECT * FROM robots WHERE id > 0";
$article = new self();
return new Resultset(null, $article, $article->getReadConnection()->query($sql));
}
public static function rawUpdate()
{
$sql = "UPDATE article SET is_published = 1";
$this->getReadConnection()->execute($sql);
}
}
如您所见,rawFind() 方法返回一个 \Phalcon\Mvc\Model\Resultset\Simple 实例。rawUpdate() 方法仅执行查询(在本例中,我们将标记所有文章为已发布)。您可能已经注意到了 getReadConnection() 方法。当您需要遍历大量数据或,例如,使用主从连接时,此方法非常有用。以下是一个示例代码片段:
<?php
class Article extends Base
{
public function initialize()
{
$this->setReadConnectionService('a_slave_db_connection_service'); // By default is 'db'
$this->setWriteConnectionService('db');
}
}
注意
与模型一起工作可能是一件复杂的事情。我们无法在这本书中涵盖所有内容,但我们将使用许多常见技术来实现我们项目的这部分。请抽出一点时间,了解更多关于与模型一起工作的信息,请参阅docs.phalconphp.com/en/latest/reference/models.html。
数据库事务
如果您需要执行多个数据库操作,那么在大多数情况下,您需要确保每个操作都成功,以保持数据完整性。良好的数据库架构并不总是足以解决潜在的一致性问题。这就是您应该使用事务的情况。以下是一个虚拟钱包的例子,它可以表示为以下几个表所示。
User 表看起来如下:
| ID | NAME |
|---|---|
| 1 | 约翰·多伊 |
Wallet 表看起来如下:
| ID | USER_ID | BALANCE |
|---|---|---|
| 1 | 1 | 5000 |
Wallet transactions 表看起来如下:
| ID | WALLET_ID | AMOUNT | DESCRIPTION |
|---|---|---|---|
| 1 | 1 | 5000 | 奖励信用 |
| 2 | 1 | -1800 | 苹果商店 |
我们如何创建一个新用户,向其钱包充值,然后作为购买行为的后果扣除金额?这可以通过使用事务的三种方式实现:
-
手动事务
-
隐式事务
-
独立事务
手动事务示例
当我们只使用一个连接且事务不是很复杂时,手动事务是有用的。例如,如果在更新操作期间发生任何错误,我们可以回滚更改而不影响数据完整性:
<?php
class UserController extends Phalcon\Mvc\Controller
{
public function saveAction()
{
$this->db->begin();
$user = new User();
$user->name = "John Doe";
if (false === $user->save() {
$this->db->rollback();
return;
}
$wallet = new Wallet();
$wallet->user_id = $user->id;
$wallet->balance = 0;
if (false === $wallet->save()) {
$this->db->rollback();
return;
}
$walletTransaction = new WalletTransaction();
$walletTransaction->wallet_id = $wallet->id;
$walletTransaction->amount = 5000;
$walletTransaction->description = 'Bonus credit';
if (false === $walletTransaction1->save()) {
$this->db->rollback();
return;
}
$walletTransaction1 = new WalletTransaction();
$walletTransaction1->wallet_id = $wallet->id;
$walletTransaction1->amount = -1800;
$walletTransaction1->description = 'Apple store';
if (false === $walletTransaction1->save()) {
$this->db->rollback();
return;
}
$this->db->commit();
}
}
隐式事务示例
当我们需要在相关表/现有关系上执行操作时,隐式事务非常有用:
<?php
class UserController extends Phalcon\Mvc\Controller
{
public function saveAction()
{
$walletTransactions[0] = new WalletTransaction();
$walletTransactions[0]->wallet_id = $wallet->id;
$walletTransactions[0]->amount = 5000;
$walletTransactions[0]->description = 'Bonus credit';
$walletTransactions[1] = new WalletTransaction();
$walletTransactions[1]->wallet_id = $wallet->id;
$walletTransactions[1]->amount = -1800;
$walletTransactions[1]->description = 'Apple store';
$wallet = new Wallet();
$wallet->user_id = $user->id;
$wallet->balance = 0;
$wallet->transactions = $walletTransactions;
$user = new User();
$user->name = "John Doe";
$user->wallet = $wallet;
}
}
独立事务示例
独立事务总是在单独的连接中执行,并且需要一个事务管理器:
<?php
use Phalcon\Mvc\Model\Transaction\Manager as TxManager,
Phalcon\Mvc\Model\Transaction\Failed as TxFailed;
class UserController extends Phalcon\Mvc\Controller
{
public function saveAction()
{
try {
$manager = new TxManager();
$transaction = $manager->get();
$user = new User();
$user->setTransaction($transaction);
$user->name = "John Doe";
if ($user->save() == false) {
$transaction->rollback("Cannot save user");
}
$wallet = new Wallet();
$wallet->setTransaction($transaction);
$wallet->user_id = $user->id;
$wallet->balance = 0;
if ($wallet->save() == false) {
$transaction->rollback("Cannot save wallet");
}
$walletTransaction = new WalletTransaction();
$walletTransaction->setTransaction($transaction);;
$walletTransaction->wallet_id = $wallet->id;
$walletTransaction->amount = 5000;
$walletTransaction->description = 'Bonus credit';
if ($walletTransaction1->save() == false) {
$transaction->rollback("Cannot create transaction");
}
$walletTransaction1 = new WalletTransaction();
$walletTransaction1->setTransaction($transaction);
$walletTransaction1->wallet_id = $wallet->id;
$walletTransaction1->amount = -1800;
$walletTransaction1->description = 'Apple store';
if ($walletTransaction1->save() == false) {
$transaction->rollback("Cannot create transaction");
}
$transaction->commit();
} catch(TxFailed $e) {
echo "Error: ", $e->getMessage();
}
}
ODM/MongoDB
我们不会过多地讨论 ODM。它主要支持与 ORM 相同的行为。CRUD 操作可以像我们使用 ORM 时一样进行。当然,在这里我们不能使用事务,因为 MongoDB 不是一个事务型数据库。
另一个重要的事情是我们需要将变量声明为公共的,而不是受保护的,就像我们在文章模型中做的那样。这是在 Phalcon 版本 1.3.4 中的情况,但在版本 2.0 中,事情可能会有所变化。
一个很大的不同点在于我们传递给find()方法的参数。假设我们为 ORM 使用了以下类似代码:
Article::find([
'article_slug' => 'test-article-title'
]);
对于 ODM,我们需要这样做:
Article::find([
[
'article_slug' => 'test-article-title'
]
]);
注意
请阅读更多关于这些差异的信息,请参阅docs.phalconphp.com/en/latest/reference/odm.html 和 php.net/manual/ro/mongo.sqltomongo.php。
由于我们稍后将会使用 MongoDB,因此现在我们只需设置连接。打开config/services.php全局服务文件,并添加以下代码:
$di['mongo'] = function() {
$mongo = new MongoClient();
return $mongo->selectDB("bitpress");
};
$di['collectionManager'] = function(){
return new Phalcon\Mvc\Collection\Manager();
};
ORM – 缺点和缓存
如果你正在开发小型到中型项目,或者如果你与一个由超过三个开发者组成的大团队一起工作,那么使用 ORM——通常来说——是一个好主意。这是因为首先,它迫使你遵循一些规则,其次,开发将会更快。
让我们以SELECT * FROM article查询为例。使用原始查询,MySQL 日志将返回以下内容:
141214 23:35:53 572 Connect root@localhost on
572 Query select @@version_comment limit 1
572 Query SELECT DATABASE()
572 Init DB learning_phalcon
572 Query SELECT * FROM article
572 Quit
通过使用 ORM 和find()方法,你的 MySQL 日志将看起来像以下这样:
141214 23:37:26 490 Query SELECT IF(COUNT(*)>0, 1 , 0) FROM `INFORMATION_SCHEMA`.`TABLES` WHERE `TABLE_NAME`='article'
490 Query DESCRIBE `article`
490 Query SELECT `article`.`id`, `article`.`article_short_title`, `article`.`article_long_title`, `article`.`article_slug`, `article`.`article_description`, `article`.`is_published`, `article`.`created_at`, `article`.`updated_at` FROM `article` ORDER BY `article`.`created_at` DESC
ORM 首先检查表是否存在。然后执行表的describe操作,之后执行我们需要的查询。我并不是说 ORM 的逻辑是错误的。我只是试图指出完成一项工作所需的操作数量。当你有多个表之间的关系时,事情会变得相当混乱,你可能会得到数百个查询来返回仅 10 条记录的数据。
为了避免每次查询数据库服务器,我们可以使用自动缓存方法。Phalcon 接受一个名为cache的参数,该参数可以通过find()方法传递。要启用缓存,我们需要一个modelsCache服务。打开config/services.php全局服务文件并添加以下代码:
$di['modelsCache'] = $di['cache'];
现在,让我们通过添加缓存键来修改 modules/Backoffice/Controllers/ArticleController.php 中的 listAction 函数。最终的函数如下:
public function listAction() {
$article_manager = $this->getDI()->get('core_article_manager');
$articles = $article_manager->find([
'order' => 'created_at DESC',
'cache' => [
'key' => 'articles',
'lifetime' => 3600
]
]);
$this->view->articles = $articles;
}
缓存键包含两部分:key 是键名,而 lifetime 代表以秒为单位的时间。就是这样!在接下来的一个小时里,你的数据库将不再被查询。这是一个简单的例子,我建议你注意你正在缓存什么类型的数据以及缓存多长时间。此外,使缓存失效可能变得复杂且非常困难。我们将在接下来的章节中探讨缓存,你将能够看到更多有趣的内容。
小贴士
和往常一样,请花些时间阅读官方文档docs.phalconphp.com/en/latest/reference/models-cache.html,这样你可以了解更多关于缓存数据的信息。
摘要
在本章中,你了解了 ORM 和 ODM 的一般知识以及如何使用内置的主要方法执行 CRUD 操作。你还了解了数据库事务和 ORM 缓存,以及如何使用 PHQL 或原始 SQL 查询。
在下一章中,我们将开始开发我们的数据库架构,你将了解更多关于 ORM 的内容。我们将创建表单并实现验证。我们还将开发一个 CLI 应用程序来帮助我们更快地测试代码。
第四章:数据库架构、模型和 CLI 应用程序
现在我们已经了解了 Phalcon 的 ORM 和 ODM 的基础知识,我们可以创建数据库架构和项目所需的大部分模型。我们还将创建一些 CLI 任务,以帮助我们提高工作效率。由于代码量很大,当引用第一章中的一些部分时,我将使用缩写CSC(检查源代码)。
本章将涵盖以下主题:
-
数据库架构
-
模型
-
CLI 应用程序
数据库架构
本书的主要目标是通过示例学习,我们通过开发一个在线新闻/杂志网站来实现这一点。我们将假设以下表为必填项:
-
用户 -
UserGroup -
UserProfile -
Article -
ArticleCategory -
ArticleTranslation -
ArticleCategoryArticle -
Hashtag -
ArticleHashtagArticle
这些是基本表,我们将在后面的章节中添加更多。我喜欢在命名约定中使用单数术语,但这只是一个选择。为了提高效率,我建议使用如 PhpMyAdmin 或 MySQL Workbench 等工具。让我们从第一个表开始。
用户表
User表将包含有关用户的基本信息:
CREATE TABLE IF NOT EXISTS `user` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`user_first_name` varchar(16) COLLATE utf8_unicode_ci NOT NULL,
`user_last_name` varchar(16) COLLATE utf8_unicode_ci NOT NULL,
`user_email` varchar(32) COLLATE utf8_unicode_ci NOT NULL,
`user_password` varchar(128) COLLATE utf8_unicode_ci NOT NULL,
`user_group_id` int(11) DEFAULT NULL,
`user_is_active` tinyint(1) NOT NULL DEFAULT '0',
`user_created_at` datetime NOT NULL,
`user_updated_at` datetime DEFAULT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `idx_email` (`user_email`),
KEY `idx_user_group_id` (`user_group_id`),
KEY `idx_is_active` (`user_is_active`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_unicode_ci AUTO_INCREMENT=1 ;
group_id和profile_id字段将与UserGroup和UserProfile表相关联。在我们创建了这些表之后,我们也将创建这些关系。
用户组表
UserGroup表将包含有关用户组的信息,每个用户都将属于可用的组之一。我们不会使用用户和组之间的一对多关系,但如果您需要,请随意实现:
CREATE TABLE IF NOT EXISTS `user_group` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`user_group_name` varchar(16) COLLATE utf8_unicode_ci NOT NULL,
`user_group_created_at` datetime NOT NULL,
`user_group_updated_at` datetime DEFAULT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_unicode_ci AUTO_INCREMENT=1 ;
UserProfile 表
如果您想为用户创建一个个人资料,UserProfile很有用。我们将保存有关用户位置和出生日期的信息:
CREATE TABLE IF NOT EXISTS `user_profile` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`user_profile_user_id` int(11) NOT NULL,
`user_profile_location` varchar(64) COLLATE utf8_unicode_ci NOT NULL,
`user_profile_birthday` date NOT NULL,
`user_profile_created_at` datetime NOT NULL,
`user_profile_updated_at` datetime DEFAULT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `idx_user_profile_user_id` (`user_profile_user_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_unicode_ci AUTO_INCREMENT=1 ;
为了简单起见,用户位置字段将是自由文本,而不是基于坐标的位置。现在我们已经有了所有用户表,让我们创建它们之间的关系/约束:
ALTER TABLE `user_profile`
ADD CONSTRAINT `user_profile_ibfk_1` FOREIGN KEY (`user_profile_user_id`) REFERENCES `user` (`id`) ON DELETE CASCADE ON UPDATE NO ACTION;
ALTER TABLE `user`
ADD CONSTRAINT `user_ibfk_2` FOREIGN KEY (`user_profile_id`) REFERENCES `user_profile` (`id`) ON UPDATE NO ACTION,
ADD CONSTRAINT `user_ibfk_1` FOREIGN KEY (`user_group_id`) REFERENCES `user_group` (`id`) ON UPDATE NO ACTION;
最后,您的数据库结构应该看起来像以下截图所示:

模型
现在我们有了用户架构,在继续数据库的其他部分之前,让我们创建模型和一个简单的 CLI 任务来注册新用户。
如果您已经安装了 Phalcon 开发者工具,您可以使用它们来生成模型,或者您可以手动创建它们。您也可以在本章的源代码中找到它们。
小贴士
使用模型生成器不会在表之间创建关系。您必须手动创建它们。
我们所有的模型都将扩展上一章中创建的Base模型。接下来,我将向您展示包含模型重要部分的几行代码,不包括获取器、设置器和受保护变量。
用户模型
User模型将位于apps\Core\Models命名空间下的apps/Core/Models/目录中:
<?php
namespace App\Core\Models;
class User extends Base {
public static function find($parameters = array()) {
return parent::find($parameters);
}
public static function findFirst($parameters = array()) {
return parent::findFirst($parameters);
}
public function initialize() {
$this->hasOne('id', 'App\Core\Models\UserProfile', 'user_profile_user_id', array(
'alias' => 'profile',
'reusable' => true
));
$this->hasOne('user_group_id', 'App\Core\Models\UserGroups', 'id', array(
'alias' => 'group',
'reusable' => true
));
}
}
initialize()方法类似于构造函数,因此我们将在这里放置在模型加载时需要执行的代码的大部分。
在前面的示例中,我们在initialize()方法中创建了模型之间的关系。我们已经讨论了关系,但你总是可以在官方网站上阅读更多内容:docs.phalconphp.com/en/latest/reference/models.html#relationships-between-models。
该模型包含两个其他方法用于快速访问(find 和 findFirst)。请记住,Phalcon 的 ORM 支持使用魔术方法进行调用,例如,如果你想通过 ID 查找用户,你可以使用findFirstById();如果你想通过电子邮件查找第一个用户,你可以使用findFirstByEmail();等等。
如果你使用 Phalcon 开发者工具生成模型,find()和findFirst()方法将自动创建。
用户组模型
UserGroup将位于apps\Core\Models命名空间下的apps/Core/Models/目录中:
<?php
namespace App\Core\Models;
class UserGroup extends Base{
public static function find($parameters = array()){
return parent::find($parameters);
}
public static function findFirst($parameters = array()){
return parent::findFirst($parameters);
}
public function initialize(){
$this->hasMany('id', 'App\Core\Models\User', 'group_id',array(
'alias' => 'users'
));
}
}
该模型与用户之间存在 1-n 的关系,这意味着当你调用$group->users时,命令将返回分配给users组的所有用户的名称。
用户资料模型
UserProfile模型将位于apps\Core\Models命名空间下的apps/Core/Models/目录中:
<?php
namespace App\Core\Models;
class UserProfile extends Base
{
public static function find($parameters = array())
{
return parent::find($parameters);
}
public static function findFirst($parameters = array())
{
return parent::findFirst($parameters);
}
public function initialize()
{
$this->hasOne('user_profile_user_id', 'App\Core\Models\User', 'id', array(
'alias' => 'user',
'reusable' => true
));
}
}
UserProfile模型与用户之间存在 1-1 的关系,这意味着资料与单个用户紧密耦合。
我们已经准备好了。让我们看看我们的modules\Core\Models目录结构。它应该看起来像这样:

Article.php在列表中,因为我们是在上一章中创建的。现在我们可以继续创建一个 CLI 任务来注册新用户。
通常,你开发 CLI 应用程序用于在 cron 作业中使用,创建实用程序等。本书将针对不同情况开发一些任务。其中之一是通过命令行注册新用户。
注册新用户
在modules\文件夹中创建一个名为Task的新目录:
$ cd modules
$ mkdir Task
前往Task目录,创建一个名为BaseTask.php的新文件,并将以下内容附加到其中:
<?php
class BaseTask extends \Phalcon\CLI\Task
{
public function consoleLog($s_message, $color = 'green', $endline = true)
{
$start = "\033[";
$end = "\033[0m\n";
$bash_color = '0;32';
$colors = array(
'green' => '0;32',
'red' => '0;31',
'yellow' => '0;33',
'blue' => '0;34',
'grey' => '0;30',
);
if (isset($colors[$color])) {
$bash_color = $colors[$color];
}
echo $start, $bash_color, 'm', $s_message;
if ($endline) echo $end;
}
public function countdown($time)
{
for ($i=1;$i<=$time;$i++) {
sleep(1);
$this->consoleLog(($time-$i).' seconds left ...', 'red');
}
}
public function quit($s_message)
{
$this->consoleLog($s_message, 'red');
exit;
}
public function log($s_message, $log_file='/tmp/app.log')
{
error_log(PHP_EOL.$s_message.PHP_EOL, 3, $log_file);
}
protected function confirm($message='Are you sure you want to process it')
{
echo "\033[0;31m".$message.' [y/N]: '."\033[0m";
$confirmation = trim( fgets( STDIN ) );
if ($confirmation !== 'y') {
exit (0);
}
}
}
注意
我几年前写了这个文件的内容,为了“美化”我的命令行脚本。如果你对此不满意,请随意删除或更改它。
接下来,我们需要为我们的 CLI 应用程序创建一个引导程序,但在我们这样做之前,我们需要安装一些依赖项。假设你已经安装了 Composer (getcomposer.org),编辑composer.json并添加以下内容:
{
"require": {
"phalcon/incubator": "dev-master",
"crada/php-apidoc": "@dev"
}
}
使用以下命令更新 Composer:
$ php composer.phar update
返回到modules\文件夹,创建一个名为cli.php的新文件。
我们将尝试拆分并解释以下代码的内容:
#!/usr/bin/env php
<?php
umask(0022);
set_time_limit(1200);
require_once __DIR__.'/../vendor/autoload.php';
use Phalcon\DI\FactoryDefault\CLI as CliDI;
use Phalcon\CLI\Console as ConsoleApp;
use Crada\Apidoc\Extractor;
在这些第一行中,我们包含了由 Composer 生成的自动加载器,并使用了 Phalcon 的 DI 和 CLI 以及Extractor辅助工具,这些工具将用于解析方法的注释:
class Cli
{
private $arguments;
private $params;
private $console;
public function __construct($argv)
{
$di = new CliDI();
include __DIR__ .'/../config/loader.php';
$config = include __DIR__ . '/../config/config.php';
$di->set('config', $config);
include __DIR__ . '/../config/services.php';
$console = new ConsoleApp();
$console->setDI($di);
foreach ($argv as $k => $arg) {
if ($k == 1) {
$this->arguments['task'] = $arg;
} elseif ($k == 2) {
$this->arguments['action'] = $arg;
} elseif ($k >= 3) {
$this->params[] = $arg;
}
}
if (count($this->params) > 0) {
$this->arguments['params'] = $this->params;
}
$this->console = $console;
}
前面的类构造函数将为我们设置 DI 并加载运行任务所需的配置文件。它还将读取分配给任务的任何参数:
public function readTasks() {
if ($handle = opendir(__DIR__.'/Task/')) {
require_once __DIR__.'/Task/BaseTask.php';
$util = new BaseTask();
$util->consoleLog('Learning Phalcon CLI','grey');
$util->consoleLog(str_repeat('-', 80),'grey');
while (false !== ($entry = readdir($handle))) {
if ($entry != '.' && $entry != '..' && $entry != 'BaseTask.php' && preg_match("/\.php$/",$entry)) {
$entries[] = $entry;
}
}
asort($entries);
$charCountActionName = 0;
foreach ($entries as $entry) {
$task = str_replace('Task.php', '', $entry);
require_once __DIR__.'/Task/'.$entry;
$tmp_className = str_replace('.php','',$entry);
$tmp = new $tmp_className();
$taskName = PHP_EOL.strtolower(preg_replace('/\B([A-Z])/', '_$1', $task));
$taskDescription = '';
$util->consoleLog(str_pad($taskName,
25).$taskDescription, 'yellow');
$st_classMethods = get_class_methods($tmp);
asort($st_classMethods);
foreach ($st_classMethods as $value) {
if (preg_match('/Action/', $value)) {
$theActionName = str_pad(str_replace('Action',
'', $value), 6);
if (strlen($theActionName) >
$charCountActionName) {
$charCountActionName = strlen(
$theActionName);
}
}
}
foreach ($st_classMethods as $value) {
if (preg_match('/Action/', $value)) {
$theActionName = str_replace('Action', '',
$value);
$theActionDescription = '';
$annotations = Extractor::getMethodAnnotations(
$tmp_className, $value);
if (count($annotations) > 0) {
foreach ($annotations as $key =>
$st_values) {
if ($key == 'Description') {
$theActionDescription .= implode(', ', $st_values);
}
}
}
$util->consoleLog(str_pad($theActionName,
$charCountActionName + 5)."\033
0;28m".$theActionDescription, 'green');
}
}
}
closedir($handle);
}
}
我们使用readTask方法来读取每个任务的注释,并列出我们应用程序中可用的任务。通过在终端中执行$ php modules/cli.php,你会更好地理解这个方法的目的:
public function getArguments()
{
return $this->arguments;
}
public function getConsole()
{
return $this->console;
}
}
最后,我们需要初始化我们新创建的类。我们通过以下几行代码来完成:
try {
$cli = new Cli($argv);
$arguments = $cli->getArguments();
if (0 === count($arguments)) {
$cli->readTasks();
} else {
$console = $cli->getConsole();
$console->handle($arguments);
}
} catch (\Phalcon\Exception $e) {
echo $e->getMessage();
}
…
我们需要注册新的任务文件夹。打开config/loader.php文件,并添加以下内容:
$loader->registerDirs(array(
__DIR__ . '/../modules/Task/'
));
我们现在准备好创建我们的第一个任务。让我们创建一个测试任务,以确保我们的代码正在正常工作。转到modules/Task/文件夹,创建一个名为UserTask.php的新文件,并包含以下内容:
<?php
class UserTask extends BaseTask
{
/**
* @Description("Test action")
*/
public function testAction()
{
$this->consoleLog('OK');
}
}
从你的项目根目录执行任务:
$ php modules/cli.php user test
你应该会看到以下截图类似的内容:
![注册新用户
到目前为止,我们有了user*表的数据库结构和模型。我们还有一个工作的 CLI 应用程序和一个虚拟测试任务。我们可以继续进行用户任务。
接下来,我们将开发一个可以从 CLI 访问的用户注册过程。我们首先需要做的是在我们的管理器中实现一个注册方法。这个管理器目前还不存在,但我们将创建它,在modules/Core/Managers/中(从上一章中的ArticleManager.php)。
前往modules/Core/Managers/,并创建一个名为UserManager.php的新文件,并包含以下内容:
<?php
namespace App\Core\Managers;
use \App\Core\Models\User;
use \App\Core\Models\UserGroup;
use \App\Core\Models\UserProfile;
class UserManager extends BaseManager
{
public function find($parameters = null)
{
return User::find($parameters);
}
/**
* Create a new user
*
* @param array $data
* @return string|\App\Core\Models\User
*/
public function create($data)
{
$security = $this->getDI()->get('security');
$user = new User();
$user->setUserFirstName($data['user_first_name']);
$user->setUserLastName($data['user_last_name']);
$user->setUserEmail($data['user_email']);
$user->setUserPassword($security->hash($data[
'user_password']));
$user->setUserIsActive($data['user_is_active']);
if (false === $user->create()) {
foreach ($user->getMessages() as $message) {
$error[] = (string) $message;
}
throw new \Exception(json_encode($error));
}
return $user;
}
}
小贴士
注意,我们正在使用安全服务来散列用户的密码。hash方法使用 bcrypt 算法。
然后,我们需要注册新创建的管理器。为此,打开位于config/service.php的配置文件,并添加以下内容:
$di['core_user_manager'] = function () {
return new \App\Core\Managers\UserManager();
};
现在,我们可以实现用户创建任务。打开modules/Task/UserTask.php,并附加以下内容:
/**
* @Description("Create a new user")
* @Example("php modules/cli.php user create F_NAME L_NAME EMAIL@DOMAIN.TLD PASSWORD IS_ACTIVE")
*/
public function createAction($params = null) {
if (!is_array($params) || count($params) < 5) {
$this->quit('Usage: php modules/cli.php user create F_NAME L_NAME EMAIL@DOMAIN.TLD PASSWORD IS_ACTIVE');
}
$this->confirm('You will create a user with the following data: '.implode(' | ', $params));
$manager = $this->getDI()->get('core_user_manager');
try {
$user = $manager->create(array(
'user_first_name' => $params[0],
'user_last_name' => $params[1],
'user_email' => $params[2],
'user_password' => $params[3],
'user_is_active' => $params[4],
));
$this->consoleLog(sprintf(
"User %s %s has been created. ID: %d",
$user->getUserFirstName(),
$user->getUserLastName(),
$user->getId()
));
} catch (\Exception $e) {
$this->consoleLog("There were some errors creating the user: ","red");
$errors = json_decode($e->getMessage(), true);
foreach ($errors as $error) {
$this->consoleLog(" - $error", "red");
}
}
}
在createAction()方法的头两行中,我们只是对参数进行简单的验证,并要求开发者确认输入。你现在可以执行任务:
$ php modules/cli.php user create john doe john.doe@john.tld P@ss0rd!1
但你将得到一个类似于以下截图的错误:

抛出了user_created_at is required异常,因为在我们从用户管理器的create()方法中,我们没有添加这个字段——我们也不会添加它。相反,我们将使用 Phalcon 的"可时间戳"行为。
打开User模型(modules/Core/Models/User.php),并将以下代码添加到initialize()方法中:
$this->addBehavior(new Timestampable(array(
'beforeValidationOnCreate' => array(
'field' => 'user_created_at',
'format' => 'Y-m-d H:i:s'
),
'beforeValidationOnUpdate' => array(
'field' => 'user_updated_at',
'format' => 'Y-m-d H:i:s'
),
)));
注意
将此行为添加到所有使用 *_created_at 和 *_updated_at 的模型中。同时,别忘了使用 use \Phalcon\Mvc\Model\Behavior\Timestampable;。
现在,你可以再次执行用户创建任务。如果一切顺利,你应该会看到类似于以下截图的内容:

现在我们有一个用于用户和用户管理器的功能化的 CLI 应用程序,但用户没有个人资料也没有组。我们将修改用户管理器的 create() 方法,以便能够分配组和创建个人资料。由于 user_group 表为空,我们需要插入一些数据:
INSERT INTO `user_group` (`id`, `user_group_name`, `user_group_created_at`, `user_group_updated_at`) VALUES
(1, 'User', '2015-01-13 00:00:00', NULL);
在这里,我们创建了一个名为 User 的新组。这将作为默认组。接下来,我们将修改用户管理器的 create() 方法,以便能够将现有组分配给用户。新的 create() 方法将如下所示:
public function create($data, $user_group_name = 'User') {
$security = $this->getDI()->get('security');
$user = new User();
$user->setUserFirstName($data['user_first_name']);
$user->setUserLastName($data['user_last_name']);
$user->setUserEmail($data['user_email']);
$user->setUserPassword($security->hash($data['user_password']));
$user->setUserIsActive($data['user_is_active']);
$user_group_id = $this->findFirstGroupByName($user_group_name)->getId();
$user->setUserGroupId($user_group_id);
if (false === $user->create()) {
foreach ($user->getMessages() as $message) {
$error[] = (string) $message;
}
throw new \Exception(json_encode($error));
}
return $user;
}
我们还需要创建 findFirstGroupByName() 方法。将以下内容追加到 UserManager.php 文件中:
public function findFirstGroupByName($user_group_name) {
return UserGroup::findFirstByUserGroupName($user_group_name);
}
在我们再次运行 user create 任务之前,我们需要确保数据完整性,避免重复的电子邮件。由于数据库结构,我们不允许插入重复记录(email 列是唯一的),并且 create() 方法将抛出一个类似于 SQLSTATE[23000]: Integrity constraint violation: 1062 Duplicate entry 'john.doe@john.tld' for key 'idx_email' 的 SQL 异常。
为了避免这种情况,我们将使用内置的验证器。在这种情况下,我们将实现其中两个:一个唯一性验证器和电子邮件验证器,都是针对 user_email 列的。我们通过在 modules/Core/Models/User.php 文件中的用户模型中添加以下代码来实现这一点:
public function validation() {
$this->validate(new \Phalcon\Mvc\Model\Validator\Email(array(
"field" => "user_email",
"message" => "Invalid email address"
)));
$this->validate(new \Phalcon\Mvc\Model\Validator\Uniqueness(array(
"field" => "user_email",
"message" => "The email is already registered"
)));
return $this->validationHasFailed() != true;
}
现在我们有了验证器,我们可以确信电子邮件格式正确,并且确实存在于我们的数据库中。让我们执行相同的任务来看看会发生什么:
$ php modules/cli.php user create john doe john.doe@john.tld P@ss0rd!1
如果你一切操作正确,你应该会看到类似于以下截图的响应:

现在,通过更改电子邮件地址并检查组 ID 是否已分配给新用户来再次执行任务。让我们称它为 me@me.com:
$ php modules/cli.php user create john doe me@me.com P@ss0rd! 1
如果你得到类似于以下截图的响应,这意味着你做得非常出色!

小贴士
你可以在 docs.phalconphp.com/en/latest/reference/models.html#validating-data-integrity 了解更多关于验证数据完整性的信息。
创建用户个人资料
我们接下来需要做的几乎是重复相同的步骤来创建用户个人资料。UserManager.php 中的最终 create() 方法应该如下所示:
public function create($data, $user_group_name = 'User') {
$security = $this->getDI()->get('security');
$user = new User();
$user->setUserFirstName($data['user_first_name']);
$user->setUserLastName($data['user_last_name']);
$user->setUserEmail($data['user_email']);
$user->setUserPassword($security->hash($data['user_password']));
$user->setUserIsActive($data['user_is_active']);
$user_group_id = $this->findFirstGroupByName($user_group_name)->getId();
$user->setUserGroupId($user_group_id);
$profile = new UserProfile();
$profile->setUserProfileLocation($data['user_profile_location']);
$profile->setUserProfileBirthday($data['user_profile_birthday']);
$user->profile = $profile;
return $this->save($user);
}
为了避免代码重复,我们将在 BaseManager 文件中创建一个 save() 方法。打开 modules/Core/Managers/BaseManager.php 并追加以下代码:
public function save($object, $type = 'save') {
switch($type) {
case 'save':
$result = $object->save();
break;
case 'create':
$result = $object->create();
break;
case 'update':
$result = $object->update();
break;
}
if (false === $result) {
foreach ($object->getMessages() as $message) {
$error[] = (string) $message;
}
throw new \Exception(json_encode($error));
}
return $object;
}
我们需要做的最后一个更改是在UserTask.php文件中。打开它,并通过替换$user = $manager->create …代码块来更新createAction()方法:
$user = $manager->create(array(
'user_first_name' => $params[0],
'user_last_name' => $params[1],
'user_email' => $params[2],
'user_password' => $params[3],
'user_is_active' => $params[4],
'user_profile_location' => $params[5],
'user_profile_birthday' => $params[6],
));
我们可以尝试再次执行任务并测试新用户是否已创建,并且是否也创建了一个个人资料:
$ php modules/cli.php user create john doe other@email.com P@ss0rd! 1 Barcelona 1985-03-25
您应该看到类似以下内容:

如果您查看数据库中的记录,您应该得到一个与用户关联的个人资料,如下所示:

记住,当您使用 ORM 创建或更新记录时,不强制使用 setter。Phalcon 有一个名为assign()的方法,它接受key => value数组,其中键是表中定义的列名,例如,我们的create()方法也可以这样:
public function create($data, $user_group_name = 'User') {
$security = $this->getDI()->get('security');
$user = new User();
$user->assign(array(
'user_first_name' => $data['user_first_name'],
'user_last_name' => $data['user_last_name'],
'user_email' => $data['user_email'],
'user_password' => $security->hash($data['user_password']),
'user_is_active' => $data['user_is_active']
));
$user_group_id = $this->findFirstGroupByName($user_group_name)->getId();
$user->setUserGroupId($user_group_id);
$profile = new UserProfile();
$profile->assign(array(
'user_profile_location' => $data['user_profile_location'],
'user_profile_birthday' => $data['user_profile_birthday'],
));
$user->profile = $profile;
return $this->save($user);
}
我们准备通过创建剩余的数据库结构来进一步推进这个项目。让我们从Article表开始。首先,从您的数据库中删除现有的表,然后创建一个新的:
DROP TABLE IF EXISTS `article`;
CREATE TABLE IF NOT EXISTS `article` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`article_user_id` int(11) NOT NULL,
`article_is_published` tinyint(1) NOT NULL DEFAULT '0',
`article_created_at` datetime NOT NULL,
`article_updated_at` datetime DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `id` (`id`),
KEY `article_user_id` (`article_user_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_unicode_ci AUTO_INCREMENT=1 ;
ALTER TABLE `article`
ADD CONSTRAINT `fk_user_id` FOREIGN KEY (`article_user_id`) REFERENCES `user` (`id`) ON DELETE CASCADE ON UPDATE NO ACTION;
为了简单起见,将通过article_user_id列将文章分配给用户。
注意
如果您想实现更复杂的功能,例如“可归责”行为,您可以在这里阅读一篇有趣的文章 blog.phalconphp.com/post/47652831003/tutorial-creating-a-blameable-behavior-with。
正如您所看到的,我们从article表中删除了所有文本字段。这是因为我们将创建另一个名为article_translation的表。这样,我们将能够创建多语言的文章/网站内容。article_translation表如下:
CREATE TABLE IF NOT EXISTS `article_translation` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`article_translation_article_id` int(11) NOT NULL,
`article_translation_short_title` varchar(255) COLLATE utf8_unicode_ci NOT NULL,
`article_translation_long_title` varchar(255) COLLATE utf8_unicode_ci NOT NULL,
`article_translation_slug` varchar(255) COLLATE utf8_unicode_ci NOT NULL,
`article_translation_description` text COLLATE utf8_unicode_ci NOT NULL,
`article_translation_lang` char(2) COLLATE utf8_unicode_ci DEFAULT 'en',
PRIMARY KEY (`id`),
KEY `id` (`id`),
KEY `article_translation_article_id` (`article_translation_article_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_unicode_ci AUTO_INCREMENT=1 ;
ALTER TABLE `article_translation`
ADD CONSTRAINT `fk_article_id` FOREIGN KEY (`article_translation_article_id`) REFERENCES `article` (`id`) ON DELETE CASCADE ON UPDATE NO ACTION;
article_lang列将接受语言的两位字母 ISO 代码(ISO 639-1)。任何新闻、博客或杂志有两个主要因素:类别和标签/关键词。我们将创建文章和类别之间以及文章和标签之间的多对多关系。首先,让我们创建表:
CREATE TABLE IF NOT EXISTS `category` (
`id` smallint(5) NOT NULL AUTO_INCREMENT,
`category_is_active` tinyint(1) NOT NULL DEFAULT '1',
`category_created_at` datetime NOT NULL,
`category_updated_at` datetime DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `category_is_active` (`category_is_active`),
KEY `category_created_at` (`category_created_at`),
KEY `category_updated_at` (`category_updated_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_unicode_ci AUTO_INCREMENT=1 ;
CREATE TABLE IF NOT EXISTS `category_translation` (
`category_translation_category_id` smallint(5) NOT NULL,
`category_translation_name` varchar(64) COLLATE utf8_unicode_ci NOT NULL,
`category_translation_slug` varchar(128) COLLATE utf8_unicode_ci NOT NULL,
`category_translation_lang` char(2) COLLATE utf8_unicode_ci NOT NULL,
PRIMARY KEY (`category_translation_category_id`),
UNIQUE KEY `category_translation_slug` (`category_translation_slug`),
KEY `category_translation_lang` (`category_translation_lang`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_unicode_ci;
ALTER TABLE `category_translation`
ADD CONSTRAINT `category_translation_ibfk_1` FOREIGN KEY (`category_translation_category_id`) REFERENCES `category` (`id`) ON DELETE CASCADE ON UPDATE NO ACTION;
提示
如果您查看 incubator 的代码(github.com/phalcon/incubator/tree/master/Library/Phalcon/Mvc/Model/Behavior),您会看到如果需要实现嵌套集,有一个很好的解决方案。
由于我们将使用多对多关系,我们需要在文章和类别之间创建一个中间表:
CREATE TABLE IF NOT EXISTS `article_category_article` (
`article_id` int(11) NOT NULL,
`category_id` smallint(5) NOT NULL,
KEY `idx_article_id` (`article_id`),
KEY `idx_category_id` (`category_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_unicode_ci;
ALTER TABLE `article_category_article`
ADD CONSTRAINT `article_category_article_ibfk_2` FOREIGN KEY (`category_id`) REFERENCES `category` (`id`) ON DELETE CASCADE ON UPDATE NO ACTION,
ADD CONSTRAINT `article_category_article_ibfk_1` FOREIGN KEY (`article_id`) REFERENCES `article` (`id`) ON DELETE CASCADE ON UPDATE NO ACTION;
创建了这些表之后,让我们创建模型和管理器。Article 模型已经存在;删除它并创建一个新的,带有新的 getter 和 setter。接下来的代码示例将不会包含 getter 和 setter,所以您必须手动创建它们或查看本章的源代码。
类别模型
可以在 modules/Core/Models/Category.php 文件中看到 Category 模型。以下代码包含一个重要的方法——initialize()。在这里,我们创建模型之间的关系并为日期和时间字段添加某些行为:
<?php
namespace App\Core\Models;
class Category extends Base
{
public function initialize()
{
/*
* @param string $fields
* @param string $intermediateModel
* @param string $intermediateFields
* @param string $intermediateReferencedFields
* @param string $referencedModel
* @param string $referencedFields
* @param array $options
* @return \Phalcon\Mvc\Model\Relation
*/
$this->hasManyToMany(
"id",
"App\Core\Models\ArticleCategoryArticle",
"category_id",
"article_id",
"App\Core\Models\Article",
"id",
array('alias' => 'articles')
);
$this->hasMany('id', 'App\Core\Models\CategoryTranslation','category_translation_category_id', array(
'alias' => 'translations',
'foreignKey' => true
));
$this->addBehavior(new Timestampable(array(
'beforeValidationOnCreate' => array(
'field' => 'category_created_at',
'format' => 'Y-m-d H:i:s'
),
'beforeValidationOnUpdate' => array(
'field' => 'category_updated_at',
'format' => 'Y-m-d H:i:s'
),
)));
}
}
分类翻译模型
分类翻译模型使用 \Phalcon\Utils\Slug 生成 slug。它使用 Uniqueness 验证器来确保新生成 slug 的唯一性。这种验证是通过查询数据库来完成的:
<?php
namespace App\Core\Models;
use \Phalcon\Mvc\Model\Validator\Uniqueness;
use \Phalcon\Utils\Slug;
class CategoryTranslation extends Base{
public function initialize() {
$this->belongsTo('category_translation_category_id', 'App\Core\Models\Category', 'id', array(
'foreignKey' => true,
'reusable' => true,
'alias' => 'category'
));
}
public function validation()
{
$this->validate(new Uniqueness(array(
"field" => "category_translation_slug",
"message" => "Category slug should be unique"
)));
return $this->validationHasFailed() != true;
}
public function beforeValidation()
{
if ($this->category_translation_slug == '') {
$this->category_translation_slug = Slug::generate($this->category_translation_name).'-'.$this->category_translation_category_id;
}
}
}
我们使用 \Phalcon\Utils\Slug 为 category 生成 slug。同样的,这也适用于文章翻译模型。
文章翻译模型
此模型,就像分类翻译模型一样,正在验证 slug 字段是否唯一,并使用 \Phalcon\Utils\Slug 生成 slug。此模型定义如下。我们将从 modules/Core/Models/ArticleTranslation.php 文件中引用模型:
<?php
namespace App\Core\Models;
use \Phalcon\Mvc\Model\Validator\Uniqueness;
use \Phalcon\Utils\Slug;
class ArticleTranslation extends Base
{
public function initialize()
{
$this->belongsTo('article_translation_article_id', 'App\Core\Models\Article', 'id', array(
'foreignKey' => true,
'reusable' => true,
'alias' => 'article'
));
}
public function validation()
{
$this->validate(new Uniqueness(array(
"field" => "article_translation_slug",
"message" => "Article slug should be unique"
)));
return $this->validationHasFailed() != true;
}
public function beforeValidation()
{
if ($this->article_translation_slug == '') {
$this->article_translation_slug = Slug::generate($this->article_translation_short_title).'-'.$this->article_translation_article_id;
}
}
}
文章模型
文章模型与分类模型类似,区别在于关系和字段名称。我们将从 modules/Core/Models/Article.php 文件中引用模型:
<?php
namespace App\Core\Models;
use \Phalcon\Mvc\Model\Behavior\Timestampable;
class Article extends Base
{
public function initialize() {
$this->hasMany('id', 'App\Core\Models\ArticleTranslation', 'article_translation_article_id', array(
'alias' => 'translations',
'foreignKey' => true
));
$this->hasOne('article_user_id', 'App\Core\Models\User', 'id', array(
'alias' => 'user',
'reusable' => true
));
$this->hasManyToMany(
"id",
"App\Core\Models\ArticleCategoryArticle",
"article_id",
"category_id",
"App\Core\Models\Category",
"id",
array(
'alias' => 'categories'
));
$this->addBehavior(new Timestampable(array(
'beforeValidationOnCreate' => array(
'field' => 'article_created_at',
'format' => 'Y-m-d H:i:s'
),
'beforeValidationOnUpdate' => array(
'field' => 'article_updated_at',
'format' => 'Y-m-d H:i:s'
),
)));
}
}
文章-分类-文章模型
文章-分类-文章模型是一个中间表和模型,用于在文章和分类之间的多对多关系中。我们将从 modules/Core/Models/Article.php 文件中引用此模型:
<?php
namespace App\Core\Models;
class ArticleCategoryArticle extends Base
{
public function initialize()
{
$this->belongsTo('category_id', 'App\Core\Models\Category',
'id', array('alias' => 'category')
);
$this->belongsTo('article_id', 'App\Core\Models\Article',
'id', array('alias' => 'article')
);
}
}
文章和分类之间的最终关系如下所示:

我们已经有了模型。现在,让我们继续创建经理和创建文章的简单任务。文章经理已经存在,但我们将更改 create() 方法。在此之前,我们需要编写具有 create() 方法的分类经理并启用它。
在 modules/Core/Managers/ 中创建一个名为 CategoryManager.php 的文件,并添加以下内容:
<?php
namespace App\Core\Managers;
use \App\Core\Models\Category;
use \App\Core\Models\CategoryTranslation;
class CategoryManager extends BaseManager
{
/**
* Create method
* @param array $input_data
* @throws \Exception
* @return \App\Core\Models\Category
*/
public function create(array $input_data)
{
$default_data = array(
'translations' => array(
'en' => array(
'category_translation_name' => 'Category name',
'category_translation_slug' => '',
'category_translation_lang' => 'en',
)
),
'category_is_active' => 0
);
$data = array_merge($default_data, $input_data);
$category = new Category();
$category->setCategoryIsActive($data['category_is_active']);
$categoryTranslations = array();
foreach ($data['translations'] as $lang => $translation) {
$tmp = new CategoryTranslation();
$tmp->assign($translation);
array_push($categoryTranslations, $tmp);
}
$category->translations = $categoryTranslations;
return $this->save($category, 'create');
}
}
我们还需要注册新的经理。打开 config/service.php 并添加以下代码:
$di['core_category_manager'] = function () {
return new \App\Core\Managers\CategoryManager();
};
$default_data 数组旨在始终记住我们需要使用的输入结构。现在我们可以通过创建一个任务来测试一切。让我们称它为 Article 任务。在 modules/Tasks/ArticleTask.php 中创建新文件并添加以下代码:
<?php
class ArticleTask extends BaseTask
{
/**
* @Description("Create a new category with the default data as it is defined in the manager->create() method")
* @Example("php modules/cli.php article createCategory")
*/
public function createCategoryAction()
{
$manager = $this->getDI()->get('core_category_manager');
try {
$category = $manager->create(array());
$this->consoleLog(sprintf(
"The category has been created. ID: %d",
$category->getId()
));
} catch (\Exception $e) {
$this->consoleLog("There were some errors creating the category: ","red");
$errors = json_decode($e->getMessage(), true);
if (is_array($errors)) {
foreach ($errors as $error) {
$this->consoleLog(" - $error", "red");
}
} else {
$this->consoleLog(" - $errors", "red");
}
}
}
}
此任务将创建一个新的分类并为它生成一个 slug。执行此任务:
$ php modules/cli.php article createCategory
你应该看到以下截图类似的内容:

我们几乎拥有了创建新文章所需的一切。让我们回到我们的 ArticleManager.php 文件,并用这个方法替换现有的 create() 方法:
public function create($input_data)
{
$default_data = array(
'article_user_id' => 1,
'article_is_published' => 0,
'translations' => array(
'en' => array(
'article_translation_short_title' => 'Short title',
'article_translation_long_title' => 'Long title',
'article_translation_description' => 'Description',
'article_translation_slug' => '',
'article_translation_lang' => 'en',
)
),
'categories' => array()
);
$data = array_merge($default_data, $input_data);
$article = new Article();
$article->setArticleUserId($data['article_user_id']);
$article->setArticleIsPublished(
$data['article_is_published']);
$articleTranslations = array();
foreach ($data['translations'] as $lang => $translation) {
$tmp = new ArticleTranslation();
$tmp->assign($translation);
array_push($articleTranslations, $tmp);
}
$article->translations = $articleTranslations;
return $this->save($article, 'create');
}
来自 ArticleTask.php 的 createAction() 方法,它将使我们能够创建新文章,如下所示:
public function createAction()
{
$manager = $this->getDI()->get('core_article_manager');
try {
$article = $manager->create(array(
'article_user_id' => 12
));
$this->consoleLog(sprintf(
"The article has been created. ID: %d",
$article->getId()
));
} catch (\Exception $e) {
$this->consoleLog("There were some errors creating the article: ","red");
$this->consoleLog($e->getMessage(),"yellow");
$errors = json_decode($e->getMessage(), true);
if (is_array($errors)) {
foreach ($errors as $error) {
$this->consoleLog(" - $error", "red");
}
} else {
$this->consoleLog(" - $errors", "red");
}
}
}
注意以下代码:
$article = $manager->create(array(
'article_user_id' => 12
));
在这种情况下,我已经分配了一个我在数据库中已有的用户 ID。检查你的数据库并添加特定的用户 ID。通常,这将是被认证用户的 ID。
您现在可以运行任务,应该会看到类似于下一张截图的内容:
$ php modules/cli.php article create

新创建的文章尚未分配任何类别。
我们将用一个小总结来结束这一章,当我们在开发 API 模块时,您将了解更多关于模型的知识。在此期间,我建议您尝试开发hashtag和article_hashtag_article表、任务和模型。在这里写关于这一点没有意义,因为这和我们对类别所做的是同一件事(只是名称有所改变)。此外,您可以在本章的源代码中找到它。
小贴士
和往常一样,请抽出一些时间阅读官方文档,网址为docs.phalconphp.com/en/latest/reference/models.html,在那里您可以了解更多关于如何使用模型的信息。
摘要
在本章中,我们为我们的项目创建了数据库结构,您学习了如何创建 CLI 应用程序。我们创建了模型和管理器,并了解了表之间的关系是如何工作的。您还了解了模型行为(“可时间戳化”)、模型验证以及相关记录的存储。
下一章将介绍开发 API 模块,我们将有机会发现更多与模型工作、搜索数据、验证用户等相关的技术。
第五章。API 模块
应用程序编程接口(API)是向第三方公开服务最常见的方式,最近,大多数软件都是由 API 驱动的。为什么?因为,如果你的应用程序有一个 API,不仅容易实现一个完整的 HTML + JS 前端,而且如果你开发一个移动应用程序,也可以使用它。在本章中,我们将实现项目所需的大部分功能,涵盖以下主题:
-
使用 API - 推荐做法
-
在本地机器上启用 SSL
-
创建模块结构
-
使用 Phalcon PHP 编写一个完整的 REST 模块
-
保护 API
-
记录 API
使用 API - 推荐做法
如果你完全不了解 API,我建议你至少阅读一下关于开发 API 的基础知识。以最简单的方式,一个 API 响应可以用纯 PHP 创建,如下所示:
$data = [
'name' => 'John Doe',
'age' => 50
];
echo json_encode($data);
接下来,我们将讨论在开发 API 时你应该遵循的一些通用规则,如下所述:
-
使用复数名词而不是动词,使用具体名称,并利用 HTTP 动词(
GET、POST、PUT和DELETE)来操作它们:这种格式是错误的:
GET /getAllArticles GET /getArticle POST /newArticle这种格式是好的:
GET /articles (Retrieve all articles) GET /article/12 (Retrieve article with id 12) POST /article (Create a new article) PUT /article/12 (Update article with id 12) DELETE /article/12 (Delete article with id 12) -
当响应不涉及资源时使用动词:
GET /search?title=Learning+Phalcon注意
始终为你的 API 打上版本号。这样,当你对应用程序进行更改时,你可以确保向后兼容性。这里给出了一些示例:
https://learning-phalcon.localhost/api/v1 https://api.learning-phalcon.localhost/v1/ -
始终使用安全连接(HTTPS),如前文信息框所示。
-
允许数据过滤和排序:
GET /articles?author=John GET /articles?author=John&sort=created_at -
使用
camelCase而不是snake_case。我知道使用 snake case 会更容易阅读,我也同意你的观点。但是,由于(我假设)你打算以 JSON 格式表示你的数据,你应该使用 JavaScript 命名约定。无论如何,这是一个建议。多年过去了,我仍然无法适应这些情况下的 camel case。在这本书中,我将使用 snake case。
如果业务决策没有强迫你公开 XML 格式,请选择 JSON。从我的观点来看,XML 有点过时了。
这些只是一些基本的规则。你将在本章后面学习更多。
小贴士
如果你不太了解 API,请查看以下资源,例如 blog.apigee.com/taglist/restful,www.vinaysahni.com/best-practices-for-a-pragmatic-restful-api,或 Web API 设计,布赖恩·穆洛伊(Brian Mulloy)的电子书(38 页)。
在本地机器上启用 SSL
我们将考虑 API 的一条规则:始终使用安全连接。假设你正在使用 Nginx,这可以通过以下四个简单步骤完成:
-
创建一个目录,
/etc/nginx/ssl:$ sudo mkdir /etc/nginx/ssl -
使用 Phalcon PHP 生成一个新的证书:
$ sudo openssl req -x509 -nodes -days 365 -newkey rsa:2048 -keyout /etc/nginx/ssl/nginx.key -out /etc/nginx/ssl/nginx.crt在此阶段,你将被要求提供一些关于新证书的信息,如下所示:
![在我们的本地机器上启用 SSL]()
-
打开
learning-phalcon.localhost配置文件(/etc/nginx/sites-available/learning-phalcon.localhost)并启用 SSL:server { listen 80; listen 443 ssl; ssl_certificate /etc/nginx/ssl/nginx.crt; ssl_certificate_key /etc/nginx/ssl/nginx.key; #....rest of the code } -
然后重新加载 Nginx 配置:
$ sudo service nginx reload
现在,你可以尝试访问 https://learning-phalcon.localhost/。在任何你使用的浏览器中,你将收到一个警告,说明服务器证书不受信任。这是正常的,因为它没有被任何权威机构签名。在 Chrome 中,你应该点击以下截图中的 高级 链接,然后点击 继续访问 learning-phalcon.localhost(不安全)链接(如下一个截图所示)。其他浏览器将有类似的链接:

点击 高级 后,将打开一个新页面,其外观应该如下截图所示:

注意,你的连接实际上并没有被加密。这样做的目的是为了我们能够通过 HTTPS 访问我们的项目。
创建模块结构
我们已经在之前的章节中创建了基本结构。目录结构应该看起来像这样:

这是可以的。我们需要在这里做的是启用路由并添加一些方法到 BaseController 中,以便我们能够继续前进。让我们通过以下步骤开始这个过程:
-
从
api模块打开routing.php文件,删除其内容,并放入以下代码:<?php $versions = [ 'v1' => '/api/v1', 'v2' => '/api/v2' ]; $router->removeExtraSlashes(true); // Articles group $articles = new \Phalcon\Mvc\Router\Group(array( 'module' => 'api', 'controller' => 'articles' )); $articles->setPrefix($versions['v1'].'/articles'); $articles->addGet('', array( 'module' => 'api', 'controller' => 'articles', 'action' => 'list' )); $router->mount($articles); -
接下来,我们添加一个包含我们 API 可用版本的数组,并告诉路由器删除额外的斜杠。因此,对
/api/v1/articles的请求将与对/api/v1/articles/的请求相同。 -
之后,我们利用路由器的分组能力创建一个新的文章分组。
-
最后,我们将
articles分组挂载到路由器上。
关于路由系统,我们需要解决几个问题。它们如下:
-
我们将在全局路由文件(
config/routing.php)中添加新内容,如下所示:<?php $di['router'] = function () use ($default_module, $modules, $di, $config) { $router = new \Phalcon\Mvc\Router(false); $router->clear(); $moduleRouting = __DIR__.'/../modules/'.ucfirst($default_module).'/Config/routing.php'; if (file_exists($moduleRouting) && is_file($moduleRouting)) { include $moduleRouting; } else { $router->add('#^/(|/)$#', array( 'module' => $default_module, 'controller' => 'index', 'action' => 'index', )); $router->add('#^/([a-zA-Z0-9\_]+)[/]{0,1}$#', array( 'module' => $default_module, 'controller' => 1, )); $router->add('#^/{0,1}([a-zA-Z0-9\_]+)/([a-zA-Z0-9\_]+)(/.*)*$#', array( 'module' => $default_module, 'controller' => 1, 'action' => 2, 'params' => 3, )); } foreach ($modules as $moduleName => $module) { if ($default_module == $moduleName) { continue; } $moduleRouting = __DIR__.'/../modules/'.ucfirst($moduleName).'/Config/routing.php'; if (file_exists($moduleRouting) && is_file($moduleRouting)) { include $moduleRouting; } } return $router; }; -
删除
modules/App/Core/Config/routing.php文件——我们不会为核心模块设置任何路由。这个模块更像是一个库。 -
最后,将
modules/Frontend/Config/routing.php的内容替换为以下内容:<?php $router->add('/', array( 'module' => 'frontend', 'controller' => 'index', 'action' => 'index' )); $router->add('#^/articles[/]{0,1}$#', array( 'module' => 'frontend', 'controller' => 'article', 'action' => 'list' )); $router->add('#^/articles/([a-zA-Z0-9\-]+)[/]{0,1}$#', array( 'module' => 'frontend', 'controller' => 'article', 'action' => 'read', 'slug' => 1 ));
新的路由器组使用了一个名为 Articles 的控制器,该控制器不存在。现在,让我们继续进行以下步骤:
-
让我们创建
ArticlesController.php并包含以下内容:<?php namespace App\Api\Controllers; class ArticlesController extends BaseController { public function listAction() { $this->view->disable(); echo __METHOD__; } }以下截图显示了运行
ArticlesController.php文件后的输出:![创建模块结构]()
现在,如果你访问
https://learning-phalcon.localhost/api/v1/articles,你应该看到前面截图所示的内容。 -
接下来,让我们修改我们的基本控制器。打开
BaseController.php并添加以下内容:<?php namespace App\Api\Controllers; use \Phalcon\Http\Response; class BaseController extends \Phalcon\Mvc\Controller { protected $statusCode = 200; protected $headers = [ 'Access-Control-Allow-Origin' => '*', 'Access-Control-Allow-Headers' => 'X-Requested-With, content-type, access-control-allow-origin, accept, apikey', 'Access-Control-Allow-Methods' => 'GET, PUT, POST, DELETE, OPTIONS','Access-Control-Allow-Credentials' => 'true' ]; protected $payload = ''; protected $format = 'json'; protected function initResponse($status = 200) { $this->statusCode = $status; $this->headers = array(); $this->payload = ''; } protected function _getContent($payload) { return json_encode($payload); } protected function output() { $payload = $this->getPayload(); $status = $this->getStatusCode(); $description = $this->getHttpCodeDescription($status); $headers = $this->getHeaders(); $response = (new Response()) ->setStatusCode($status, $description) ->setContentType('application/json', 'UTF-8') ->setContent(json_encode($payload, JSON_PRETTY_PRINT)) ; foreach ($headers as $key => $value) { $response->setHeader($key, $value); } $this->view->disable(); return $response; } protected function render($st_output, $statusCode = 200){ $this->initResponse(); $this->setStatusCode($statusCode); $this->setPayload($st_output); return $this->output(); } }注意
注意,我们省略了一些方法。要查看完整的类,请查看本章的源代码。
-
现在,让我们编辑
ArticlesController.php中的listAction()函数。新的listAction()函数将看起来像这样:public function listAction() { try { $st_output = [ 'method' => __METHOD__ ]; return $this->render($st_output); } catch (\Exception $e) { return $this->render($e->getMessage(), 500); } }
我们现在可以重新打开https://learning-phalcon.localhost/api/v1/articles并检查结果。你应该会看到如截图所示的 JSON 编码文本:

我们已经有了基础知识。让我们继续推进我们的项目并开发其 API。
使用 Phalcon PHP 编写一个完全功能的 REST 模块
在开始之前,我建议您使用一个 RESTful 客户端,这将帮助您更快地测试事物。我个人更喜欢 DHC(这是一个 Chrome 扩展),您可以在chrome.google.com/webstore/detail/dhc-resthttp-api-client/aejoelaoggembcahagimdiliamlcdmfm?hl=en找到它。
我们将开发Articles、Categories、Hashtags和Users的 CRUD 操作。让我们从Articles开始。
文章
我们已经创建了控制器,因此通过在https://learning-phalcon.localhost/api/v1/articles上执行GET方法,你应该会得到一个响应。让我们实现文章列表的文章管理器,以便我们可以检索真实数据。
首先,我们将对Article模型进行一些更改并重写toArray()方法。打开modules/Core/Models/Article.php并追加以下代码:
public function getTranslations($arguments = null) {
return $this->getRelated('translations', $arguments);
}
public function getCategories($arguments = null) {
return $this->getRelated('categories', $arguments);
}
public function getHashtags($arguments = null) {
return $this->getRelated('hashtags', $arguments);
}
public function getUser($arguments = null) {
return $this->getRelated('user', $arguments);
}
public function toArray($columns = null) {
$output = parent::toArray($columns);
$output['article_translations'] = $this->getTranslations([
'columns' => [
'article_translation_short_title',
'article_translation_long_title',
'article_translation_slug',
'article_translation_description',
'article_translation_lang'
]
])->toArray();
$output['article_categories'] = $this->getCategories()->filter(function($category){
return $category->toArray(['id','category_translations']);
});
$output['article_hashtags'] = $this->getHashtags([
'columns' => [
'id',
'hashtag_name'
]
])->filter(function($hashtag){
return $hashtag->toArray();
});
$output['article_author'] = $this->getUser([
'columns' => [
'user_first_name',
'user_last_name',
'user_email'
]
])->toArray();
return $output;
}
如您所见,我们添加了与文章相关的一切:翻译、作者信息、类别和标签。
由于类别有翻译,我们还将重写类别模型的toArray()方法。打开modules/Core/Models/Category.php并添加以下代码:
public function getTranslations($arguments = null) {
return $this->getRelated('translations', $arguments);
}
public function toArray($columns = null) {
$output = parent::toArray($columns);
$output['category_translations'] = $this->getTranslations([
'columns' => [
'category_translation_name',
'category_translation_slug',
'category_translation_lang'
]
])->toArray();
return $output;
}
现在我们需要做的是在文章管理器中实现一个新的方法。我们从文章控制器调用该方法,我们应该准备好第一次调用。
打开modules/Core/Managers/ArticleManager.php并追加以下代码:
public function restGet(array $parameters = null, array $options = null, $page = 1, $limit = 10) {
$articles = $this->find($parameters);
$result = $articles->filter(function($article){
return $article->toArray();
});
$paginator = new \Phalcon\Paginator\Adapter\NativeArray([
'data' => $result,
'limit' => $limit,
'page' => $page
]);
$data = $paginator->getPaginate();
if ($data->total_items > 0) {
return $data;
}
if (isset($parameters['bind']['id'])) {
throw new \Exception('Not Found', 404);
} else {
throw new \Exception('No Content', 204);
}
}
你会看到方法名是restGet。我喜欢在我的严格用于 API 的方法中添加rest前缀。这是一个个人偏好;您可以为您的项目使用任何命名约定。
restGet()方法将抛出异常。如果我们请求文章列表并且请求成功但我们数据库中没有文章,我们使用 HTTP 代码 204。简单来说,这意味着,“您的请求是好的,但我没有内容”。如果我们尝试通过 ID 获取文章但该文章在我们的数据库中不存在,我们使用 HTTP 404 (not found)。
最后一步是从我们的控制器调用此方法。打开modules/Api/Controllers/ArticlesController.php并更新listAction()方法,如下所示:
public function listAction() {
try {
$manager = $this->getDI()->get('core_article_manager');
$page = $this->request->getQuery('p', 'int', 0);
$st_output = $manager->restGet([], [], $page);
return $this->render($st_output);
} catch (\Exception $e) {
return $this->render([
'code' => $e->getCode(),
'message' => $e->getMessage()
], $e->getCode());
}
}
就这样!从您最喜欢的 API 客户端,向http://learning-phalcon.localhost/api/v1/articles发送GET请求,或者从命令行使用 CURL 执行:
$ curl -i -X GET \
'http://learning-phalcon.localhost/api/v1/articles'
如果你做得很好,你应该能看到以下截图所示的响应:

我们现在有了渲染文章最常见数据所需的所有信息。例如,如果你打算用 jQuery 获取这些数据,很容易:
$.get('http://learning-phalcon.localhost/api/v1/articles', function(data){
// render a list with articles
});
你也可以在请求中添加页码,如下所示:http://learning-phalcon.localhost/api/v1/articles?p=2。
让我们继续我们的 CRUD 操作如下:
-
我们现在将创建一个服务来检索单个文章。打开
api模块中的routing.php文件,并将以下路由添加到$articles组中:$articles->addGet('/{id}', array( 'module' => 'api', 'controller' => 'articles', 'action' => 'get' )); -
然后,我们在
ArticlesController.php中添加get()方法:public function getAction($id) { try { $manager = $this->getDI()->get('core_article_manager'); $st_output = $manager->restGet([ 'id = :id:', 'bind' => [ 'id' => $id ], ]); return $this->render($st_output); } catch (\Exception $e) { return $this->render([ 'code' => $e->getCode(), 'message' => $e->getMessage() ], $e->getCode()); } }
就这样!你现在可以请求数据库中存在的文章,你应该得到完全相同的结构。此外,items键将只包含这篇文章。在我的情况下,它是 ID 等于6的文章:
$ curl -i -X GET 'http://learning-phalcon.localhost/api/v1/articles/6'
如果你请求一个不存在的文章,你应该得到一个类似于以下截图的响应:

接下来,我们将实现文章的update方法如下:
-
首先,我们需要添加路由信息。打开
modules/Api/Config/routing.php并添加以下代码:$articles->addPut('/{id}', array( 'module' => 'api', 'controller' => 'articles', 'action' => 'update' ));小贴士
注意,我们使用
PUT,这是更新资源的推荐方法。 -
在
ArticlesController.php中创建一个名为updateAction()的新方法,并添加以下代码:public function updateAction($id) { try { $manager = $this->getDI()->get('core_article_manager'); if ($this->request->getHeader('CONTENT_TYPE') == 'application/json') { $data = $this->request->getJsonRawBody(true); } else { $data = [$this->request->getPut()]; } if (count($data[0]) == 0) { throw new \Exception('Please provide data', 400); } $result = $manager->restUpdate($id, $data); return $this->render($result); } catch (\Exception $e) { return $this->render([ 'code' => $e->getCode(), 'message' => $e->getMessage() ], $e->getCode()); } }在
updateAction()中,我们检查内容类型头是否为application/json类型。如果是,我们从请求对象中调用getJsonRawBody()。布尔参数true表示我们强制解码为数组。如果数据通过表单接收,我们将使用getPut()方法。 -
将数据作为 JSON 体提交是我认为的最佳方法。使用 jQuery,你可以非常简单地做到这一点,如下所示:
var data = [{ "article_is_published" : 1 }]; $.ajax({ type: "PUT", url: "/api/v1/articles/6", processData: false, contentType: 'application/json', data: JSON.stringify(data), success: function(response) { console.log(response); } });
现在,让我们看看我们的restUpdate()方法看起来如何。打开ArticleManager.php并添加以下代码:
public function restUpdate($id, $data) {
$article = Article::findFirstById((int)$id);
if (!$article) {
throw new \Exception('Not found', 404);
}
$article->setArticleIsPublished($data[0]['article_is_published']);
if (false === $article->update()) {
foreach ($article->getMessages() as $message) {
throw new \Exception($message->getMessage(), 500);
}
}
return $article->toArray();
}
如您所见,目前我们只将更新一个字段:article_is_published。如果文章已成功更新,您将得到新的更新文章作为响应(查看以下截图)。现在让我们测试一下:
$ curl -i -X PUT -H "Content-Type:application/json" -d '[{"article_is_pu blished": 0}]' 'http://learning-phalcon.localhost/api/v1/articles/6'

如果我们不提供任何数据,我们将得到一个400 Bad Request消息,如下所示:

干得好!到目前为止,我们已经公开了一个具有三种方法的服务:GET用于文章列表,GET用于单个文章,PUT用于更新文章。
我们将继续开发剩余的两个方法:DELETE(用于删除)和POST(用于创建)。让我们从更容易的一个开始,即DELETE。为此,让我们执行以下步骤:
-
打开 API 路由文件,并添加以下代码:
$articles->addDelete('/{id}', array( 'module' => 'api', 'controller' => 'articles', 'action' => 'delete' )); -
接下来,在
ArticlesController.php中创建一个名为deleteAction()的方法:public function deleteAction($id) { try { $manager = $this->getDI()->get('core_article_manager'); $st_output = $manager->restDelete($id); return $this->render($st_output); } catch (\Exception $e) { return $this->render([ 'code' => $e->getCode(), 'message' => $e->getMessage() ], $e->getCode()); } } -
最后,在
ArticlesManager.php中创建restDelete()方法:public function restDelete($id) { $article = Article::findFirstById((int)$id); if (!$article) { throw new \Exception('Not found', 404); } if (false === $article->delete()) { foreach ($article->getMessages() as $message) { throw new \Exception($message->getMessage(), 500); } } return true; }
在测试之前,我们必须对 Articles.php 模型进行一个小改动,即在翻译的外键中添加 \Phalcon\Mvc\Model\Relation::ACTION_CASCADE,否则我们将收到一个错误消息,说 “Record is referenced by model App\Core\Models\ArticleTranslation”。这个改动是因为文章和翻译之间现有的关系。当我们删除文章时,其翻译将被自动删除。
打开 modules/Core/Models/Article.php 文件,将翻译的关系替换为以下代码片段:
$this->hasMany('id', 'App\Core\Models\ArticleTranslation', 'article_translation_article_id', array(
'alias' => 'translations',
'foreignKey' => array(
'action' => \Phalcon\Mvc\Model\Relation::ACTION_CASCADE
)
));
我们现在可以测试我们的代码,结果应该类似于以下截图所示。如果没有找到文章,你将收到一个 404 错误而不是 200:
$ curl -i -X DELETE 'http://learning-phalcon.localhost/api/v1/articles/1'

就这些!你可以通过简单地向正确的 URL 发送 DELETE 请求来删除文章。
现在,让我们继续实现 POST 操作(用于创建文章)。为此,执行以下步骤:
-
打开
modules/Api/Config/routing.php文件并添加以下代码:$articles->addPost('', array( 'module' => 'api', 'controller' => 'articles', 'action' => 'create' )); -
在
ArticlesController.php中实现createAction()方法:public function createAction() { try { $manager = $this->getDI()->get('core_article_manager'); if ($this->request->getHeader('CONTENT_TYPE') == 'application/json') { $data = $this->request->getJsonRawBody(true); } else { $data = $this->request->getPost(); } if (count($data) == 0) { throw new \Exception('Please provide data', 400); } $st_output = $manager->restCreate($data); return $this->render($st_output); } catch (\Exception $e) { return $this->render([ 'code' => $e->getCode(), 'message' => $e->getMessage() ], $e->getCode()); } } -
管理器(
ArticleManager.php)将包含一个名为restCreate()的新方法,但我们也会更新create()方法:public function restCreate($data) { $result = $this->create($data); return $result->toArray(); } public function create($input_data) { $default_data = array( 'article_user_id' => 1, 'article_is_published' => 0, 'translations' => array( 'en' => array( 'article_translation_short_title' => 'Short title', 'article_translation_long_title' => 'Long title', 'article_translation_description' => 'Description', 'article_translation_slug' => '', 'article_translation_lang' => 'en', ) ), 'categories' => array(), 'hashtags' => array(), ); $data = array_merge($default_data, $input_data); $article = new Article(); $article->setArticleIsPublished($data['article_is_published']); $articleTranslations = array(); foreach ($data['translations'] as $lang => $translation){ $tmp = new ArticleTranslation(); $tmp->assign($translation); array_push($articleTranslations, $tmp); } if (count($data['categories']) > 0) { $article->categories = Category::find([ "id IN (".implode(',', $data['categories']).")" ])->filter(function($category){ return $category; }); } if (count($data['hashtags']) > 0) { $article->hashtags = Hashtag::find([ "id IN (".implode(',', $data['hashtags']).")" ])->filter(function($hashtag){ return $hashtag; }); } $user = User::findFirstById((int) $data['article_user_id']); if (!$user) { throw new \Exception('User not found', 404); } $article->setArticleUserId($data['article_user_id']); $article->translations = $articleTranslations; return $this->save($article, 'create'); }
让我们测试新的代码。创建一个 JSON 主体内容,并将 POST 方法数据发送到 /api/v1/articles,如下所示:
$ curl -i -X POST -H "Content-Type:application/json" -d '{"article_user_id":12,"article_is_published":1,"translations":{"en":{"article_translation_short_title":"Test API create","article_translation_long_title":"Test API create","article_translation_description":"Test API create description","article_translation_slug":"test-api-create","article_translation_lang":"en"}},"categories":[9,16],"hashtags":[1]}' 'http://learning-phalcon.localhost/api/v1/articles'
不要忘记替换用户 ID 以及你在数据库中的分类和标签的 ID。结果应该是一个新创建的文章,类似于以下截图:

根据 Articles 中遵循的相同规则,你应该尝试开发其余的端点(分类、标签和用户)。如果你觉得不舒服,你总是可以查看本章的源代码。
保护 API
通常,当你把东西放到网上时,它就不再安全了。几乎任何东西都可以被黑客攻击。在这种情况下,你能做什么呢?好吧,如果你不是可以负担得起大量人力资源和安全软硬件投资的亿万富翁,你所能做的就是尽量让攻击者的生活变得艰难,并始终监控你的东西。
关于安全和保护 API 的书籍有成百上千本。我们将尝试实现一些基本的安全方法,这些方法可以帮助你避免灾难。
那么,这些方法是什么呢?以下是一个列表:
-
总是使用 SSL
-
添加 API 密钥以提供额外保护
-
限制来自同一 IP 的每秒请求数量
-
限制对资源的访问,例如
DELETE、PUT、POST,仅对认证用户开放
使用 SSL
没有必要详细说明 SSL。使用安全连接是你应该采取的方式。SSL 证书现在相当便宜。例如,www.namecheap.com 的人以每年 80 欧元的单价出售多域名 SSL 证书。
添加 API 密钥以提供额外保护
我们将在全局配置中创建一个 API 密钥白名单。我们将向所有请求追加一个 APIKEY 头,并与配置中的值进行核对。如果 API 密钥不匹配,服务器将响应一个“403 禁止访问”错误。如果你在 JavaScript 环境中使用这个密钥,任何人都能看到它,但至少你可以控制并更改 API 密钥。让我们实现保护措施:
-
打开全局配置文件
config/config.php并将以下代码追加到$config数组中:'apiKeys' => array( '6y825Oei113X3vbz78Ck7Fh7k3xF68Uc0lki41GKs2Z73032T4z8m1I81648JcrY' ) -
在
modules/Core/中创建一个名为Listeners的新目录,并创建一个名为ApiListener.php的新文件,内容如下:<?php namespace App\Core\Listeners; class ApiListener extends \Phalcon\Mvc\User\Plugin{ public function beforeExecuteRoute($event, $dispatcher) { $hasValidKey = $this->checkForValidApiKey(); if (false === $hasValidKey) { return false; } } private function checkForValidApiKey() { $apiKey = $this->request->getHeader('APIKEY'); if (!in_array($apiKey, $this->config->apiKeys->toArray())) { $this->response->setStatusCode(403, 'Forbidden'); $this->response->sendHeaders(); $this->response->send(); $this->view->disable(); return false; } return true; } } -
最后,将这个服务注入到
dispatcher中。打开modules/Api/service.php并将$di['dispatcher']数组替换为以下内容:$di['dispatcher'] = function () use ($di) { $eventsManager = $di->getShared('eventsManager'); $apiListener = new \App\Core\Listeners\ApiListener(); $eventsManager->attach('dispatch', $apiListener); $dispatcher = new Phalcon\Mvc\Dispatcher(); $dispatcher->setEventsManager($eventsManager); $dispatcher->setDefaultNamespace("App\Api\Controllers"); return $dispatcher; };
如果你使用以下命令行发出请求,你会注意到你得到的是“403 禁止访问”错误:
$ curl -i -X GET 'http://learning-phalcon.localhost/api/v1/articles/6'
“403 禁止访问”错误如以下截图所示:

这是因为你没有提供 APIKEY 头。你所需要做的就是提供正确的头和正确的密钥,你将得到这篇文章:
$ curl -i -X GET -H "APIKEY:6y825Oei113X3vbz78Ck7Fh7k3xF68Uc0lki41GKs2Z73032T4z8m1I81648JcrY" 'http://learning-phalcon.localhost/api/v1/articles/6'
就这样!当然,这个方法可以改进,但这超出了本书的范围。此外,你可以将 API 密钥与客户端和/或 IP 地址等进行映射。
限制来自同一 IP 的每秒请求数量
我们将使用 Redis 的一个简单解决方案来限制来自同一 IP 的每秒请求数量。假设我们希望来自同一 IP 的每秒限制为五个请求:
-
打开
ApiListener.php并添加以下方法:private function checkIpRateLimit() { $ip = $this->request->getClientAddress(); $time = time(); $key = $ip.':'.$time; $redis = $this->getDI()->get('redis'); $current = $redis->get($key); if ($current != null && $current > 5) { $this->response->setStatusCode(429, 'Too Many Requests'); $this->response->sendHeaders(); $this->response->send(); $this->view->disable(); return false; } else { $redis->multi(); $redis->incr($key, 1); $redis->expire($key, 5); $redis->exec(); } return true; } -
然后,使用以下代码更新
beforeExecuteRoute()方法:public function beforeExecuteRoute($event, $dispatcher) { $hasValidKey = $this->checkForValidApiKey(); $ipRateLimit = $this->checkIpRateLimit(); if (false === $hasValidKey || false === $ipRateLimit) { return false; } }
那就结束了!你可以通过将5替换为2来轻松测试它,并发出一些请求。你会得到一个 429 响应。你可以结合使用 API 密钥和用户来限制特定用户的请求。
限制认证用户对 DELETE、PUT 和 POST 等资源的访问
如果你打算公开你的 API,你需要确保只有认证用户可以访问某些资源。这意味着你不应该从公共接口访问这些资源,例如前端。一个快速方便的解决方案是使用另一个头(让我们称它为TOKEN),它将在从管理界面进行的 CRUD 操作中使用。让我们执行以下步骤:
-
在这里,我们首先在
ApiListener.php中添加一个新的方法resourceWithToken(),如下所示,然后更新beforeExecuteRoute()方法:private function resourceWithToken() { if (in_array($this->dispatcher->getActionName(), ['update','delete','create'])) { if ($this->request->getHeader('TOKEN') != 'mySecretToken') { $this->response->setStatusCode(405, 'Method Not Allowed'); $this->response->sendHeaders(); $this->response->send(); $this->view->disable(); return false; } return true; } } -
将以下代码追加到
beforeExecuteRoute()方法中:if (false === $this->resourceWithToken()) { return false; }
如果你尝试POST、PUT或DELETE,你会得到一个 405 错误。从现在开始,你需要追加名为 TOKEN 的头,并带有mySecretToken值,如以下示例所示:
$ curl -i -X PUT -H "Content-Type:application/json" -H "APIKEY:6y825Oei113X3vbz8Ck7Fh7k3xF68Uc0lki41GKs2Z73032T4z8m1I81648JcrY" -H "TOKEN:mySecretToken" -d '{"article_user_id":12,"article_is_published":1,"translations":{"en":{"article_translation_short_title":"Test API create","article_translation_long_title":"Test API create","article_translation_description":"Test API create description","article_translation_slug":"test-api-create","article_translation_lang":"en"}},"categories":[9,16],"hashtags":[1]}' 'http://learning-phalcon.localhost/api/v1/articles/6'
记住,如果你使用 JavaScript 从前端调用它,这不会保护你的 API,因为令牌的值将对每个人可见。
有数百种其他解决方案,你应该仔细研究你需要什么。此外,保护你的 API 并不足以。保护整个应用程序,以及服务器(例如,通过使用防火墙)也同样重要。但仅就本章的目的而言,我们所做的一切应该足以保护我们免受最常见的攻击。
读取更多内容,自我学习,并寻求专家的意见。大多数时候,对某人来说似乎是一个好解决方案,可能对你来说并不是一个好解决方案。
记录 API
文档可能是你应该花时间做的最重要的事情之一。当我发现 Phalcon 时,我做的第一件事就是开发一个简单的 API。当我需要为我的 API 创建文档时,我发现自己处于一个奇怪的情况;当时只有少数解决方案,而且大多数都有依赖项。那是在 2013 年夏天左右。
因此,我决定创建自己的 API 文档生成器,没有任何依赖项——只是纯 PHP。我将使用这个工具(它在 GitHub 上公开可用,网址为 github.com/calinrada/php-apidoc)来创建和生成我们项目的 API 文档。
安装
你应该已经有了它,因为我一直在使用它来生成 CLI 任务的注释。如果你错过了它,你可以通过两个简单的步骤来完成:
$ php composer.phar require crada/php-apidoc
$ php composer.phar update
用法
我们将执行几个步骤来正确理解用法:
-
让我们创建一个名为
ApidocTask.php的新 CLI 任务,内容如下:<?php use Crada\Apidoc\Builder; use Crada\Apidoc\Exception; class ApidocTask extends BaseTask { /** * @Description("Build API Documentation") * @Example("php apps/cli.php apidoc generate") */ public function generateAction($params = null) { $classes = [ 'App\Api\Controllers\ArticlesController' ]; try { $builder = new Builder($classes, __DIR__.'/../../docs/api', 'index.html'); $builder->generate(); exec("ln -s ".__DIR__."/../../docs/api ".__DIR__."/../../public/apidoc"); $this->consoleLog('ok! : '.__DIR__.'/../../docs/api/index.html'); } catch (Exception $e) { $this->consoleLog($e->getMessage(), 'red'); } } }我们将使用注解来记录每个方法。
注意
关于这方面的更多信息,请参阅
github.com/calinrada/php-apidoc#usage和github.com/calinrada/php-apidoc#available-methods。 -
打开
ArticlesController.php并将以下内容追加到listActi/on()方法中:/** * @ApiDescription(section="Articles", description="Retrieve a list of articles") * @ApiMethod(type="get") * @ApiRoute(name="/articles") * @ApiParams(name="p", type="integer", nullable=true, description="Page number") * @ApiReturnHeaders(sample="HTTP 200 OK") * @ApiReturn(type="object", sample="{ * 'items': [{ * 'id':'int', * 'article_user_id':'int', * 'article_is_published':'int', * 'article_created_at':'string', * 'article_updated_at':'string', * 'article_translations':[{ * 'article_translation_short_title':'string', * 'article_translation_long_title':'string', * 'article_translation_slug':'string', * 'article_translation_description':'string', * 'article_translation_lang':'string' * }], * 'article_categories':[{ * 'id':'int', * 'category_translations':[{ * 'category_translation_name':'string', * 'category_translation_slug':'string', * 'category_translation_lang':'string' * }] * }], * 'article_hashtags':[{ * 'id':'int', * 'hashtag_name':'string' * }], * 'article_author':{ * 'user_first_name':'string', * 'user_last_name':'string', * 'user_email':'string' * } * }], * 'before':'int', * 'first':'int', * 'next':'int', * 'last':'int', * 'current':'int', * 'total_pages':'int', * 'total_items':'int', *}") */ public function listAction() { }
现在切换到命令提示符并执行以下命令行:
$ php modules/cli.php apidoc generate
任务会在你的公共文件夹中创建一个新的符号链接。现在你可以通过 http://learning-phalcon.localhost/apidoc/ 访问 API 文档,你应该能够看到以下截图中所展示的完全相同的输出:

是时候关闭这一章了。请花时间尽可能多地阅读有关开发 API 的内容,特别是安全 API。
摘要
在这一章中,我们发现了我们如何轻松快速地开发 API。你了解了推荐的实践和几种常见的保护 API 的方法。我们涵盖了新的主题,如路由分组和过滤结果。
在下一章中,我们将切换布局和 JavaScript 集成,但我们将继续适应或更改 API、数据库和模型中的事物。
第六章。资产、认证和 ACL
我们将在这个章节中使用我们的Backoffice模块,因为它将是我们将要开发的第二个模块。
在本章中,我们将涵盖以下主题:
-
资产管理
-
开发认证系统
-
使用访问控制列表(ACL)组件保护应用程序
资产管理
在进一步之前,我想向你介绍 Phalcon 的资产管理器。当你需要处理大量资产时(通常,CSS 文件、图像和 JavaScript 文件),这是一个非常有用的组件。该服务应该已经可用,并且你可以通过 DI 使用以下命令访问它:
$manager = $this->assets;
否则,你可以使用以下命令:
$manager = $this->getDI()->get('assets');
注意
我听说有些人抱怨在安装后,这个服务不存在。如果你使用 Phalcon 版本 1.3.*(你应该使用),那么你不会有任何问题。如果你使用较旧版本,你可能需要将此服务注入到 DI 中:
$di->set('assets', function () {
return new Phalcon\Assets\Manager();
}, true);
现在,让我们打开后台办公室的主布局并进行一些更改。打开modules/Backoffice/Views/Default/layout.volt,并移除所有包含stylesheetLink和javascriptInclude的行。
现在,在<head>和</head>部分之间,添加以下代码:
{{ assets.outputCss('headerCss') }}
{% block css %}{% endblock %}
在</body>关闭标签之前:
{{ assets.outputJs('footerJs') }}
{% block javascripts %} {% endblock %}
outputJs和outputCss方法包含两个参数(headerCss和footerJs)。这些参数是我们将在接下来的几分钟内构建的资产集合的名称。我添加了两个块(css和javascripts),因为我们可能想要为某个页面添加一些特殊资源。
现在,我们将修改BaseController.php文件,并添加资产。打开Backoffice/Controllers/BaseController.php,并附加以下代码:
<?php
namespace App\Backoffice\Controllers;
class BaseController extends \Phalcon\Mvc\Controller
{
public function afterExecuteRoute()
{
$this->buildAssets();
}
/**
* Build the collection of assets
*/
private function buildAssets()
{
$assets_dir = __DIR__.'/../../../public/assets/';
$this->assets
->collection('headerCss')
->addCss($assets_dir.'default/bower_components/bootstrap/dist/css/bootstrap.min.css')
->addCss($assets_dir.'default/css/lp.backoffice.css')
->setTargetPath('assets/default/prod/backoffice.css')
->setTargetUri('../assets/default/prod/backoffice.css')
->join(true)
->addFilter(new \Phalcon\Assets\Filters\Cssmin());
$this->assets
->collection('footerJs')
->addJs($assets_dir.'default/bower_components/jquery/dist/jquery.min.js')
->addJs($assets_dir.'default/bower_components/bootstrap/dist/js/bootstrap.min.js')
->addJs($assets_dir.'default/js/lp.js')
->setTargetPath('assets/default/prod/backoffice.js')
->setTargetUri('../assets/default/prod/backoffice.js')
->join(true)
->addFilter(new \Phalcon\Assets\Filters\Jsmin());
}
}
你可以看到新的私有方法buildAssets(),其中我们创建资产组并使用特殊的过滤器来压缩它们。之后,我们在afterExecuteRoute()中调用此方法。如果你想,你可以通过扩展Phalcon\Assets\FilterInterface类来创建自己的自定义过滤器。请注意,输出将进入一个名为prod的新文件夹。我们必须创建此目录并赋予它适当的权限:
$ cd public/assets/default
$ mkdir prod && chmod 777 prod
如果你处理很多资产,你可能想要在config数组或类似的地方保存一个列表。如果你使用来自 CDN 的资产,你需要传递一些特殊的参数,例如以下内容。
$js->addJs('cnd.mysite.com/jquery.js', true, false);
// An external resource that does not need filtering.
在检查结果之前,我们需要做两件事。首先,从IndexController类中的indexAction()移除任何内容。最终的IndexController.php文件应该看起来像这样:
<?php
namespace App\Backoffice\Controllers;
class IndexController extends BaseController
{
public function indexAction()
{
}
}
然后,打开位于Backoffice/Views/Default/index/index.volt的IndexAction()模板,从中移除任何内容,并将其代码附加到其中:
{% extends 'layout.volt' %}
{% block body %}
Welcome, User !
{% endblock %}
就这样了。你现在应该能够访问http://www.learning-phalcon.localhost/backoffice,结果应该与以下截图显示的完全相同:

图 1
这基本上就是关于资产管理的内容,这是一个简单、强大且有用的工具。
注意
你可以在官方文档中看到自定义过滤器的示例 docs.phalconphp.com/en/latest/reference/assets.html#assets-management。
开发认证系统
在你的应用程序中,总有一些部分需要受到保护。在本节中,我们将实现一个基于我们在前几章中创建的用户表的认证系统,并且我们将使用 Phalcon 的 ACL 组件。
我们不会重新发明轮子,所以部分 HTML 代码是从官方 Bootstrap 网站上获取的 (getbootstrap.com)。此外,你还可以在很久以前我开发的一个插件中找到部分 PHP 代码,该插件可以在 github.com/calinrada/PhalconUserPlugin 找到。话虽如此,让我们开始开发我们的认证系统。
数据库结构
我们将为用户添加几个更多表,并根据在 github.com/phalcon/incubator/tree/master/Library/Phalcon/Acl/Adapter 找到的示例创建新的 ACL 表,因为我们将会使用数据库适配器。孵化器页面包含 SQLite 数据库的结构,但我们将会“转换”它以适应 MySQL。新的 user_* 表的提取方式如下:
CREATE TABLE IF NOT EXISTS `user_failed_logins` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`user_id` int(11) DEFAULT NULL,
`ip_address` char(15) CHARACTER SET utf8 COLLATE utf8_unicode_ci NOT NULL,
`attempted` int(11) unsigned NOT NULL,
PRIMARY KEY (`id`),
KEY `usersId` (`user_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 AUTO_INCREMENT=1 ;
CREATE TABLE IF NOT EXISTS `user_remember_tokens` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`user_id` int(11) NOT NULL,
`token` char(32) CHARACTER SET utf8 COLLATE utf8_unicode_ci NOT NULL,
`user_agent` varchar(255) CHARACTER SET utf8 COLLATE utf8_unicode_ci DEFAULT NULL,
`created_at` int(11) NOT NULL,
PRIMARY KEY (`id`),
KEY `token` (`token`),
KEY `user_id` (`user_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_bin AUTO_INCREMENT=1 ;
CREATE TABLE IF NOT EXISTS `user_success_logins` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`user_id` int(11) NOT NULL,
`ip_address` char(15) CHARACTER SET utf8 COLLATE utf8_unicode_ci NOT NULL,
`user_agent` varchar(255) CHARACTER SET utf8 COLLATE utf8_unicode_ci NOT NULL,
`created_at` datetime NOT NULL,
PRIMARY KEY (`id`),
KEY `usersId` (`user_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_bin AUTO_INCREMENT=1 ;
ALTER TABLE `user_failed_logins`
ADD CONSTRAINT `user_failed_logins_ibfk_1` FOREIGN KEY (`user_id`) REFERENCES `user` (`id`) ON DELETE CASCADE ON UPDATE NO ACTION;
ALTER TABLE `user_remember_tokens`
ADD CONSTRAINT `user_remember_tokens_ibfk_1` FOREIGN KEY (`user_id`) REFERENCES `article_translation` (`id`) ON DELETE CASCADE ON UPDATE NO ACTION;
ALTER TABLE `user_success_logins`
ADD CONSTRAINT `user_success_logins_ibfk_1` FOREIGN KEY (`user_id`) REFERENCES `user` (`id`) ON DELETE CASCADE ON UPDATE NO ACTION;
新的 acl_* 表可以看起来像这样:
CREATE TABLE IF NOT EXISTS `acl_access_list` (
`roles_name` varchar(32) COLLATE utf8_unicode_ci NOT NULL,
`resources_name` varchar(32) COLLATE utf8_unicode_ci NOT NULL,
`access_name` varchar(32) COLLATE utf8_unicode_ci NOT NULL,
`allowed` smallint(3) NOT NULL,
PRIMARY KEY (`roles_name`,`resources_name`,`access_name`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_unicode_ci;
CREATE TABLE IF NOT EXISTS `acl_resources` (
`name` varchar(32) CHARACTER SET utf8 COLLATE utf8_unicode_ci NOT NULL,
`description` varchar(255) CHARACTER SET utf8 COLLATE utf8_unicode_ci DEFAULT NULL,
PRIMARY KEY (`name`)
) ENGINE=InnoDB DEFAULT CHARSET=latin1;
CREATE TABLE IF NOT EXISTS `acl_resources_accesses` (
`resources_name` varchar(32) COLLATE utf8_unicode_ci NOT NULL,
`access_name` varchar(32) COLLATE utf8_unicode_ci NOT NULL,
PRIMARY KEY (`resources_name`,`access_name`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_unicode_ci;
CREATE TABLE IF NOT EXISTS `acl_roles` (
`name` varchar(32) CHARACTER SET utf8 COLLATE utf8_unicode_ci NOT NULL,
`description` varchar(255) CHARACTER SET utf8 COLLATE utf8_unicode_ci DEFAULT NULL,
PRIMARY KEY (`name`)
) ENGINE=InnoDB DEFAULT CHARSET=latin1;
CREATE TABLE IF NOT EXISTS `acl_roles_inherits` (
`roles_name` varchar(32) CHARACTER SET utf8 COLLATE utf8_unicode_ci NOT NULL,
`roles_inherit` varchar(32) CHARACTER SET utf8 COLLATE utf8_unicode_ci NOT NULL,
PRIMARY KEY (`roles_name`,`roles_inherit`)
) ENGINE=InnoDB DEFAULT CHARSET=latin1;
模型
现在我们有了数据库结构,我们需要为新建的 user_* 表生成模型。目前,没有必要用完整的模型填满页面,因为它们现在将只包含获取器和设置器。我们模型的简化版本(不包含获取器和设置器)如下:
<?php
namespace App\Core\Models;
class UserFailedLogins extends Base
{
public function getSource()
{
return 'user_failed_logins';
}
}
<?php
namespace App\Core\Models;
class UserSuccessLogins extends Base
{
public function getSource()
{
return 'user_success_logins';
}
}
<?php
namespace App\Core\Models;
class UserRememberTokens extends Base
{
public function getSource()
{
return 'user_remember_tokens';
}
}
你可以自己添加获取器和设置器,或者查看本章的源代码。
接下来,我们将向用户模型添加关系,以便我们可以快速访问这些新表中的数据。打开 App\Core\Models\User.php 并将以下代码追加到 initialize() 方法中:
$this->hasMany('id', 'App\Core\Models\UserFailedLogins', 'user_id', array(
'alias' => 'failedLogins',
'foreignKey' => array(
'action' => \Phalcon\Mvc\Model\Relation::ACTION_CASCADE
)
));
$this->hasMany('id', 'App\Core\Models\UserSuccessLogins', 'user_id', array(
'alias' => 'successLogins',
'foreignKey' => array(
'action' => \Phalcon\Mvc\Model\Relation::ACTION_CASCADE
)
));
$this->hasMany('id', 'App\Core\Models\UserRememberTokens', 'user_id', array(
'alias' => 'rememberTokens',
'foreignKey' => array(
'action' => \Phalcon\Mvc\Model\Relation::ACTION_CASCADE
)
));
关于 acl_* 表,目前我们不需要创建任何模型。acl 数据库适配器将处理它们的大部分数据。我们也可以手动添加数据或为其创建一个任务。我们已经有数据库表和模型。接下来,我们将创建一个与它们交互的认证组件。
要做到这一点,请导航到 modules/Core/ 目录 并创建一个名为 Security 的新文件夹:
$ cd modules/Core
$ mkdir Security
在 security 文件夹中,创建一个名为 Auth.php 的新文件,并添加以下内容:
<?php
namespace App\Core\Security;
use App\Core\Models\User,
App\Core\Models\UserRememberTokens,
App\Core\Models\UserSuccessLogins,
App\Core\Models\UserFailedLogins;
class Auth extends \Phalcon\Mvc\User\Component
{
/**
* Checks the user credentials
*
* @param array $credentials
* @return boolean
*/
public function check($credentials)
{
$user = User::findFirstByUserEmail(strtolower($credentials['email']));
if ($user == false) {
$this->registerUserThrottling(null);
throw new \Exception('Wrong email/password combination');
}
if (!$this->security->checkHash($credentials['password'], $user->getUserPassword())) {
$this->registerUserThrottling($user->getId());
throw new \Exception('Wrong email/password combination');
}
$this->checkUserFlags($user);
$this->saveSuccessLogin($user);
if (isset($credentials['remember'])) {
$this->createRememberEnviroment($user);
}
$this->setIdentity($user);
}
/**
* Set identity in session
*
* @param object $user
*/
private function setIdentity($user)
{
$st_identity = [
'id' => $user->getId(),
'email' => $user->getUserEmail(),
'name' => $user->getUserFirstName().' '.$user->getUserLastName(),
'roles' => [
'Administrator'
]
];
$this->session->set('identity', $st_identity);
}
/**
* Login user - normal way
*
* @param App\Core\Forms\UserSigninForm $form
* @return \Phalcon\Http\ResponseInterface
*/
public function signin($form)
{
if (!$this->request->isPost()) {
if ($this->hasRememberMe()) {
return $this->loginWithRememberMe();
}
} else {
if ($form->isValid($this->request->getPost()) == false) {
foreach ($form->getMessages() as $message) {
$this->flashSession->error($message->getMessage());
}
} else {
$this->check([
'email' => $this->request->getPost('email'),
'password' => $this->request->getPost('password'),
'remember' => $this->request->getPost('remember')
]);
$redirect = $this->getDI()->get('config')->auth->redirect;
return $this->response->redirect($redirect->success);
}
}
return false;
}
/**
* Creates the remember me environment settings the related cookies and generating tokens
*/
public function saveSuccessLogin($user)
{
$successLogin = new UserSuccessLogins();
$successLogin->setUserId($user->getId());
$successLogin->setIpAddress($this->request->getClientAddress());
$successLogin->setUserAgent($this->request->getUserAgent());
if (!$successLogin->save()) {
$messages = $successLogin->getMessages();
throw new \Exception($messages[0]);
}
}
/**
* Implements login throttling
* Reduces the efectiveness of brute force attacks
*
* @param int $user_id
*/
public function registerUserThrottling($user_id)
{
$failedLogin = new UserFailedLogins();
$failedLogin->setUserId($user_id == null ? new \Phalcon\Db\RawValue('NULL') : $user_id);
$failedLogin->setIpAddress($this->request->getClientAddress());
$failedLogin->setAttempted(time());
$failedLogin->save();
$attempts = UserFailedLogins::count([
'ip_address = ?0 AND attempted >= ?1',
'bind' => [
$this->request->getClientAddress(),
time() - 3600 * 6
]
]);
switch ($attempts) {
case 1:
case 2:
// no delay
break;
case 3:
case 4:
sleep(2);
break;
default:
sleep(4);
break;
}
}
/**
* Check if the user is signed in
*
* @return boolean
*/
public function isUserSignedIn()
{
$identity = $this->getIdentity();
if (is_array($identity)) {
if (isset($identity['id'])) {
return true;
}
}
return false;
}
/**
* Checks if the user is banned/inactive/suspended
*
* @param App\Core\Models\User $user
*/
public function checkUserFlags($user)
{
if (false === $user->getUserIsActive()) {
throw new \Exception('The user is inactive');
}
}
/**
* Returns the current identity
*
* @return array
*/
public function getIdentity()
{
return $this->session->get('identity');
}
/**
* Removes the user identity information from session
*/
public function remove()
{
if ($this->cookies->has('RMU')) {
$this->cookies->get('RMU')->delete();
}
if ($this->cookies->has('RMT')) {
$this->cookies->get('RMT')->delete();
}
$this->session->remove('identity');
}
public function getUser()
{
$identity = $this->session->get('identity');
if (isset($identity['id'])) {
$user = User::findFirstById($identity['id']);
if ($user == false) {
throw new \Exception('The user does not exist');
}
return $user;
}
return false;
}
}
注意
请注意,由于代码量较大,这不是完整的代码。请查看本章的源代码。你可以看到这个文件是扩展\Phalcon\Mvc\User\Component的,这意味着我们已经有访问 DI 的权限,因此我们不需要注入任何服务,因为它们已经可用。
让我们分析一下Auth组件中的几个方法:
-
registerUserThrottling($user_id): 这个方法记录任何带有时间戳的失败登录尝试,并检查来自特定 IP 的用户尝试次数。如果尝试次数超过三次,我们将延迟响应。这是一个简单的方法来减少暴力攻击的有效性。 -
checkUserFlags($user): 这个方法检查用户是否活跃。在这里,你可以添加其他检查,例如,检查用户是否被禁止或暂时停用。 -
saveSuccessLogin($user): 这个方法保存用户的所有成功登录记录,包含用户 ID、IP、用户代理以及日期和时间。 -
createRememberEnviroment($user): 这个方法(请查看第六章的源代码)创建我们将保存在数据库和一些 cookie 中的令牌。如果这个操作成功,下次我们可以使用这些信息自动登录用户。 -
setIdentity($user): 这个方法简单地保存一个包含当前认证用户信息的数组到会话中。我们可以通过使用getIdentity()方法或直接从会话中调用$session->get('identity')来检索这些信息。 -
check($credentials): 这个方法是最重要的。在这里,我们首先检查我们数据库中是否有任何用户注册了提供的电子邮件。如果用户存在,我们使用checkHash()安全组件比较他们的密码与提供的密码。之后,我们检查用户是否活跃,保存成功的登录记录,创建记住我环境,然后通过调用setIdentity()方法将用户信息保存在会话中。 -
signin($form): 我们使用这个方法通过表单登录用户(我们将在不久后创建这个表单)。如果表单有效,我们调用check()方法验证凭据。其余的方法相当容易理解。
我们已经有了Auth组件,但目前它还不可用。我们需要将其添加到我们的依赖注入(DI)中。打开modules/Backoffice/Config/services.php文件并添加以下代码:
$di['auth'] = function () use ($di) {
return new App\Core\Security\Auth();
};
然后,打开config.php文件并将以下代码追加到$module_config数组中:
'auth' => array(
'redirect' => array(
'success' => 'index/index',
'failure' => 'auth/signin',
),
),
组件现在已激活,我们可以使用它了。我们将为登录操作创建模板、表单和控制器。导航到modules/Backoffice/Controllers并创建一个名为AuthController.php的新文件,内容如下:
<?php
namespace App\Backoffice\Controllers;
use App\Core\Forms\UserSigninForm;
class AuthController extends BaseController
{
public function signinAction()
{
$form = new UserSigninForm();
if ($this->request->isPost()) {
try {
$this->auth->signin($form);
} catch (\Exception $e) {
$this->flash->error($e->getMessage());
}
}
$this->view->signinForm = $form;
}
public function signoutAction()
{
$this->auth->remove();
return $this->response->redirect('auth/signin');
}
}
我们还没有UserSinginForm。导航到modules/Core/目录并创建一个名为Forms的新文件夹:
$ cd modules/Core
$ mkdir Forms
在Forms目录下,创建一个名为UserSigninForm.php的新文件,内容如下:
<?php
namespace App\Core\Forms;
use Phalcon\Forms\Form;
use Phalcon\Forms\Element\Text;
use Phalcon\Forms\Element\Password;
use Phalcon\Forms\Element\Submit;
use Phalcon\Forms\Element\Check;
use Phalcon\Forms\Element\Hidden;
use Phalcon\Validation\Validator\PresenceOf;
use Phalcon\Validation\Validator\Email;
use Phalcon\Validation\Validator\Identical;
class UserSigninForm extends Form
{
public function initialize()
{
$email = new Text('email', array(
'placeholder' => 'Email',
));
$email->addValidators(array(
new PresenceOf(array(
'message' => 'The e-mail is required',
)),
new Email(array(
'message' => 'The e-mail is not valid',
)),
));
$this->add($email);
//Password
$password = new Password('password', array(
'placeholder' => 'Password',
));
$password->addValidator(
new PresenceOf(array(
'message' => 'The password is required',
))
);
$this->add($password);
//Remember
$remember = new Check('remember', array(
'value' => 'yes',
));
$remember->setLabel('Remember me');
$this->add($remember);
//CSRF (Cross-Site Request Forgery)
$csrf = new Hidden('csrf');
$csrf->addValidator(
new Identical(array(
'value' => $this->security->getSessionToken(),
'message' => 'CSRF validation failed',
))
);
$this->add($csrf);
$this->add(new Submit('signin', array(
'class' => 'btn btn-lg btn-primary btn-block',
)));
}
}
您可能已经注意到我们正在使用CSRF字段来防止跨站请求伪造攻击。如果您对此一无所知,请花几分钟时间阅读有关它的内容。www.owasp.org/index.php/Cross-Site_Request_Forgery_(CSRF)_Prevention_Cheat_Sheet。
接下来,我们将创建模板。我们将使用getbootstrap.com/examples/signin/上的示例模板,但我们将根据我们的需求对其进行调整。由于我们的主要模板layout.volt包含仅对认证用户可用的信息,我们将克隆此模板并清理它,以便我们可以将其用于登录操作和其他需要简单模板的操作。导航到modules/Backoffice/Views/Default/,通过将其重命名为layout_simple.volt来复制layout.volt文件:
$ cd modules/Backoffice/Views/Default/
$ cp layout.volt layout_simple.volt
然后,从layout_simple.volt中删除代码,并追加新的清理代码:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block pageTitle %}Learning Phalcon{% endblock %}</title>
{{ assets.outputCss('headerCss') }}
{% block css %}{% endblock %}
<!--[if lt IE 9]>
<script src="img/html5shiv.min.js"></script>
<script src="img/respond.min.js"></script>
<![endif]-->
</head>
<body>
<div class="container-fluid">
<div class="row">
<div class="col-sm-12 main">
{% block body %}
{% endblock %}
</div>
</div>
</div>
{{ assets.outputJs('footerJs') }}
{% block javascripts %} {% endblock %}
</body>
</html>
最后一步是创建signingAction()的模板。导航到modules/Backoffice/Views/Default,创建一个名为auth的新文件夹。之后,在auth文件夹中,创建一个名为signin.volt的文件,内容如下:
{% extends 'layout_simple.volt' %}
{% block pageTitle %}Sign in{% endblock %}
{% block css %}
{{ assets.outputCss('signin') }}
{% endblock %}
{% block body %}
<form class="form-signin" method="post" action="">
{{ content() ~ flashSession.output() }}
<h2 class="form-signin-heading">Sign in</h2>
<label for="inputEmail" class="sr-only">Email address</label>
{{ signinForm.render('email', {'class':'form-control', 'required':true, 'autofocus':true, 'type':'email'}) }}
<label for="inputPassword" class="sr-only">Password</label>
{{ signinForm.render('password', {'class':'form-control', 'required':true}) }}
<div class="checkbox">
<label>
{{ signinForm.render('remember') }} Remember me
</label>
</div>
{{ signinForm.render('signin', {'value':'Sign in'}) }}
{{ signinForm.render('csrf', {'value':security.getToken()}) }}
</form>
{% endblock %}
signin.volt模板扩展了新创建的layout_simple.volt。注意新的css块。我们添加了一个名为signin的新css组。我们将在几分钟后启用它。{{ content() ~ flashSession.output() }}行是一个连接,因为flashSession组件在content()文件中没有返回。所以,如果我们只输出content()方法,flashSession消息将不会被看到。
模板缺少一个css文件。我们需要创建它并将其添加到我们的assets集合中。为此,导航到public/assets/default/css/,并创建一个名为lp.backoffice.signin.css的新文件,内容如下:
body {
padding-top: 40px;
padding-bottom: 40px;
background-color: #eee;
}
.form-signin {
max-width: 330px;
padding: 15px;
margin: 0 auto;
}
.form-signin .form-signin-heading,
.form-signin .checkbox {
margin-bottom: 10px;
}
.form-signin .checkbox {
font-weight: normal;
}
.form-signin .form-control {
position: relative;
height: auto;
-webkit-box-sizing: border-box;
-moz-box-sizing: border-box;
box-sizing: border-box;
padding: 10px;
font-size: 16px;
}
.form-signin .form-control:focus {
z-index: 2;
}
.form-signin input[type="email"] {
margin-bottom: -1px;
border-bottom-right-radius: 0;
border-bottom-left-radius: 0;
}
.form-signin input[type="password"] {
margin-bottom: 10px;
border-top-left-radius: 0;
border-top-right-radius: 0;
}
然后,我们将此文件添加到我们的assets集合中。打开modules/Backoffice/Controllers/BaseController.php,并将以下代码追加到buildAssets()方法中:
$this->assets
->collection('signin')
->addCss($assets_dir.'default/css/lp.backoffice.signin.css')
->setTargetPath('assets/default/prod/backoffice.signin.css')
->setTargetUri('../assets/default/prod/backoffice.signin.css')
->addFilter(new \Phalcon\Assets\Filters\Cssmin());
这就足够了。我们的Backoffice模块尚未受保护,但实际上我们可以执行signin操作。使用您的浏览器,访问http://www.learning-phalcon.localhost/backoffice/auth/signin,您应该能够看到以下截图所示的精确结果:

登录页面
如果您已经有了用户名,您可以尝试登录。如果没有,您可以使用我们在第四章中创建的任务创建一个新用户,数据库架构、模型和 CLI 应用程序:
$ php modules/cli.php user create John Doe john.doe@learning-phalcon.localhost myPassw0rd 1 Barcelona 1985-03-25
这将创建一个用户,其电子邮件地址为 john.doe@learning-phalcon.localhost,密码为 myPassw0rd。您可以使用这些详细信息来测试表单。成功后,您将被重定向到索引页面,失败时,您将看到一些错误消息。
现在我们已经有一个完全功能的认证系统,我们可以保护整个应用程序。为此,我们将使用 Phalcon 的 Acl 组件。
使用 ACL 组件保护应用程序
当您有不同角色的用户时,ACL 非常有用。例如,管理员应该有无限访问权限,但编辑员只能访问文章部分。我们已经有 Acl 的数据库结构,所以我们只需要创建一些关系。首先,我们将创建一个名为 user_roles 的新中间表,该表将包含每个用户角色的信息。一个用户可以有多个角色。
CREATE TABLE IF NOT EXISTS `user_role` (
`user_id` int(11) NOT NULL,
`role` varchar(32) CHARACTER SET utf8 COLLATE utf8_unicode_ci NOT NULL,
UNIQUE KEY `user_id_2` (`user_id`,`role`),
KEY `role` (`role`)
) ENGINE=InnoDB DEFAULT CHARSET=latin1;
ALTER TABLE `user_role`
ADD CONSTRAINT `user_role_ibfk_2` FOREIGN KEY (`role`) REFERENCES `acl_roles` (`name`) ON DELETE CASCADE ON UPDATE CASCADE,
ADD CONSTRAINT `user_role_ibfk_1` FOREIGN KEY (`user_id`) REFERENCES `user` (`id`) ON DELETE CASCADE ON UPDATE NO ACTION;
我们还可以做的一件事是删除 user_group 表,因为我们不再使用它了。
-
删除
modules/Core/Models/UserGroup.php文件。 -
从
User.php中删除此代码:$this->hasOne('user_group_id', 'App\Core\Models\UserGroups', 'id', array( 'alias' => 'group', 'reusable' => true, )); -
从
user表中删除列并删除user_group表:ALTER TABLE `user` DROP FOREIGN KEY `user_ibfk_1` ; ALTER TABLE `user` DROP `user_group_id` ; DROP TABLE user_group; -
通过导航到
Core/Managers/UserManager.php更新用户create()方法,并删除以下代码行:$user_group_id = $this->findFirstGroupByName($user_group_name)->getId(); $user->setUserGroupId($user_group_id); -
在
create()方法中,将param $user_group_name = 'User'替换为$user_role = 'Guest'。(我们将在不久后实现此功能。)
现在,让我们从 user_role 和 acl_roles 创建模型。记住,我不会写下获取器和设置器,只写重要的内容。
<?php
namespace App\Core\Models;
class UserRole extends Base
{
public function initialize()
{
$this->belongsTo('user_id', 'App\Core\Models\User', 'id', array(
'foreignKey' => true,
'reusable' => true,
'alias' => 'user',
));
$this->belongsTo('user_role', 'App\Core\Models\AclRoles', 'name', array(
'foreignKey' => true,
'reusable' => true,
'alias' => 'role',
));
}
}
<?php
namespace App\Core\Models;
class AclRoles extends Base
{
// Nothing important here for now, just getters and setters
}
为了将现有角色分配给用户,我们需要对 UserManager.php 中的 create() 方法进行一些修改。新方法应如下所示:
public function create($data, $user_role = 'Guest')
{
$security = $this->getDI()->get('security');
$user = new User();
$user->setUserFirstName($data['user_first_name']);
$user->setUserLastName($data['user_last_name']);
$user->setUserEmail($data['user_email']);
$user->setUserPassword($security->hash($data['user_password']));
$user->setUserIsActive($data['user_is_active']);
$o_acl_role = AclRoles::findFirstByName($user_role);
if (!$o_acl_role) {
throw new \Exception("Role $user_role does not exists");
};
$o_user_role[0] = new UserRole();
$o_user_role[0]->setUserRole($user_role);
$user->roles = $o_user_role;
$profile = new UserProfile();
$profile->setUserProfileLocation($data['user_profile_location']);
$profile->setUserProfileBirthday($data['user_profile_birthday']);
$user->profile = $profile;
return $this->save($user);
}
我们将 $o_user_role 定义为对象数组集合的原因是用户和角色之间的关系是一对多。我们还需要修改 UserTask.php 中的 createAction() 方法。打开位于 modules/Tasks/UserTask.php 的文件,并按以下方式追加用户的角色:
$user = $manager->create(array(
'user_first_name' => $params[0],
'user_last_name' => $params[1],
'user_email' => $params[2],
'user_password' => $params[3],
'user_is_active' => $params[4],
'user_profile_location' => $params[5],
'user_profile_birthday' => $params[6],
), 'Guest');
我们将默认使用 Guest。稍后,我们将创建一个方法来为用户添加和删除角色。现在,我们将实现安全检查。切换到 modules/Core/Security 文件夹,创建一个包含以下内容的文件:
<?php
namespace App\Core\Security;
class Acl extends \Phalcon\Mvc\User\Plugin
{
public function beforeDispatch(\Phalcon\Events\Event $event, \Phalcon\Mvc\Dispatcher $dispatcher)
{
$controller = $dispatcher->getControllerName();
$action = $dispatcher->getActionName();
$redirect = $this->getDI()->get('config')->auth->redirect;
if ($controller == 'auth' && $action == 'signin') {
return true;
}
$account = $this->auth->getIdentity();
if (!$account) {
if ($this->getDI()->get('auth')->hasRememberMe()) {
return $this->getDI()->get('auth')->loginWithRememberMe();
}
}
if (!is_array($account) || !array_key_exists('roles', $account)) {
$this->view->disable();
$this->response->setStatusCode(403, 'Forbidden');
$this->flashSession->error('You are not allowed to access this section');
return $this->response->redirect($redirect->failure);
}
$acl = $this->getDI()->get('acl');
foreach ($account['roles'] as $role) {
if ($acl->isAllowed($role, $controller, $action) == \Phalcon\Acl::ALLOW) {
return true;
}
}
$this->view->disable();
$this->response->setStatusCode(403, 'Forbidden');
return $this->response->redirect($redirect->failure);
}
}
基本上,使用 beforeDispatch() 方法,我们检查用户请求的内容,是否已认证,以及他们拥有的角色是否允许他们访问某个资源。我们需要启用 Acl 服务并将 Acl 附加到事件管理器。在 config/services.php(全局)中添加 Acl 服务的设置:
$di['acl'] = function () use ($di) {
$acl = new \Phalcon\Acl\Adapter\Database([
'db' => $di['db'],
'roles' => 'acl_roles',
'rolesInherits' => 'acl_roles_inherits',
'resources' => 'acl_resources',
'resourcesAccesses' => 'acl_resources_accesses',
'accessList' => 'acl_access_list',
]);
$acl->setDefaultAction(\Phalcon\Acl::DENY);
return $acl;
};
然后,用以下代码更新分发器:
$di['dispatcher'] = function () use ($di) {
$eventsManager = $di->getShared('eventsManager');
$eventsManager->attach('dispatch', new App\Core\Security\Acl($di));
$dispatcher = new \Phalcon\Mvc\Dispatcher();
$dispatcher->setEventsManager($eventsManager);
$dispatcher->setDefaultNamespace("App\Backoffice\Controllers");
return $dispatcher;
};
我们还需要更新 Auth.php 中的 setIdentity() 方法。用以下代码替换它以从数据库中获取用户角色:
private function setIdentity($user)
{
$roles = [];
foreach ($user->roles as $role) {
$roles[] = $role->getUserRole();
}
$st_identity = [
'id' => $user->getId(),
'email' => $user->getUserEmail(),
'name' => $user->getUserFirstName().' '.$user->getUserLastName(),
'roles' => $roles
];
$this->session->set('identity', $st_identity);
}
如果你严格按照步骤操作,并且一切都按照书本上的方法来做,你应该能够访问 http://www.learning-phalcon.localhost/backoffice/;浏览器将会将你重定向到 登录 页面(也就是我们之前看到的 登录 页面)。
我们几乎就要结束这一章了。接下来我们将创建一个处理 Acl 的任务,并且在未来当我们需要修改某人的权限时,我们会使用这个任务。让我们看看一个简单的 Acl 任务可以是什么样的。
切换到 modules/Tasks 并创建一个名为 AclTask.php 的新文件,内容如下:
<?php
class AclTask extends BaseTask
{
/**
*
* @var \Phalcon\Acl\Adapter\Database
*/
private $acl;
public function __construct()
{
$this->acl = $this->getDI()->get('acl');
}
/**
* @Description("Install the initial(default) acl resources")
*/
public function initAction()
{
$roles = array(
'Administrator' => new \Phalcon\Acl\Role('Administrator'),
'Guest' => new \Phalcon\Acl\Role('Guest'),
);
foreach ($roles as $role) {
$this->acl->addRole($role);
}
$userResources = array(
'index' => array('index'),
);
foreach ($userResources as $resource => $actions) {
//$this->acl->addResource(new \Phalcon\Acl\Resource($resource), $actions);
foreach ($actions as $action) {
$this->acl->allow('Administrator', $resource, $action);
}
}
$this->consoleLog('Default resources created');
}
}
我们只创建了一个名为 initAction() 的方法,它将创建两个默认的 acl 角色:管理员 和 访客。管理员将能够访问一切,而访客角色将无法访问任何内容。运行此任务:
$ php modules/cli.php acl init
现在你应该能够在数据库中看到已经插入的两个角色的记录。如果你看到了它们,你可以导航到 user_role 表,为你的用户插入一个 管理员 角色,然后尝试登录,然后删除 管理员 角色并添加 访客 角色。我们将在下一章中为这个任务添加更多方法。
摘要
在这一章中,你学习了资产管理以及访问控制列表。我们还为我们的应用程序开发了一个认证系统。我们将继续我们的旅程,开发 Backoffice 模块,在那里你将了解更多关于表单、Volt 和模型的知识。
第七章。后台模块(第一部分)
除非你正在开发一个静态网站,否则你需要一个部分/模块,管理员可以在此处添加和管理内容,例如文章、分类和用户。这就是“后台”介入的地方。在本章中,我们将开发管理我们网站所需的CRUD(创建、读取、更新和删除)操作的部分。我们还将使用我们在第五章中开发的 API 的一部分,API 模块。我们将更多地使用表单和验证。我们将分两部分介绍本章,即:
-
标签 CRUD
-
分类 CRUD
编辑主布局
让我们从对主布局的一些修改开始本章。编辑位于modules/Backoffice/Views/Default/layout.volt的主布局,并添加以下代码:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block pageTitle %}Learning Phalcon{% endblock %}</title>
{{ assets.outputCss('headerCss') }}
{% block css %}{% endblock %}
<!--[if lt IE 9]>
<script src="img/html5shiv.min.js"></script>
<script src="img/respond.min.js"></script>
<![endif]-->
</head>
<body>
{% include 'common/topbar.volt' %}
<div class="container-fluid">
<div class="row">
<div class="col-sm-3 col-md-2 sidebar">
{% include 'common/sidebar.volt' %}
</div>
<div class="col-sm-9 col-sm-offset-3 col-md-10 col-md-offset-2 main">
{% block body %}
<h1 class="page-header">Dashboard</h1>
<h2 class="sub-header">Section title</h2>
<div class="table-responsive">
</div>
{% endblock %}
</div>
</div>
</div>
{{ assets.outputJs('footerJs') }}
{% block javascripts %} {% endblock %}
</body>
</html>
你可以看到我们正在使用include来包含两个新文件:topbar.volt和sidebar.volt。在 Volt 中,你可以使用include方法或partial()方法。partial和include之间的主要区别是,partial方法在运行时被包含,而include文件编译内容并将其作为包含视图的一部分返回。我更喜欢include,因为它可以提高性能。如果你需要将变量分配给将被包含的文件,你需要避免文件扩展名。以下是一个示例:
{% include 'common/sidebar' with {'categories': categories} %}
小贴士
你可以在docs.phalconphp.com/en/latest/reference/volt.html#include了解更多关于include的信息。
两个新文件的代码与之前主布局中的代码相同,但为了侧边栏进行了一些小的修改。让我们创建文件夹和文件。转到modules/Backoffice/Views/Default/并创建一个名为common的新文件夹。在这个新文件夹中,创建两个新文件,分别命名为sidebar.volt和topbar.volt,代码如下。
common/topbar.volt
下面是位于页面顶部的导航栏的代码。它包含一个指向主页的链接和一个用于注销的链接:
<nav class="navbar navbar-inverse navbar-fixed-top" role="navigation">
<div class="container-fluid">
<div class="navbar-header">
<button type="button" class="navbar-toggle collapsed" data-toggle="collapse" data-target="#navbar" aria-expanded="false" aria-controls="navbar">
<span class="sr-only">Toggle navigation</span>
<span class="icon-bar"></span>
<span class="icon-bar"></span>
<span class="icon-bar"></span>
</button>
<a class="navbar-brand" href="{{ url('') }}">Learning Phalcon</a>
</div>
<div id="navbar" class="navbar-collapse collapse">
<ul class="nav navbar-nav navbar-right">
<li><a href="{{ url('auth/signout') }}">Sign out</a></li>
</ul>
</div>
</div>
</nav>
common/sidebar.volt
以下是为侧边栏(左侧菜单)和包含从我们的应用程序到不同控制器的链接的代码:
{% set c_name = dispatcher.getControllerName() %}
<ul class="nav nav-sidebar">
<li{% if c_name == 'article' %} class="active"{% endif %}>
<a href="{{ url('article/list') }}">Articles</a></li>
<li{% if c_name == 'category' %} class="active"{% endif %}>
<a href="{{ url('category/list') }}">Categories</a></li>
<li{% if c_name == 'hashtag' %} class="active"{% endif %}>
<a href="{{ url('hashtag/list') }}">Hashtags</a></li>
<li{% if c_name == 'user' %} class="active"{% endif %}>
<a href="{{ url('user/list') }}">Users</a></li>
</ul>
关于这两个文件,有一些新的内容。我们正在使用一个名为url()的方法,侧边栏已经包含了一些逻辑,我们注意到 DI 中的分发器无需从控制器分配即可使用。
默认情况下,Volt 可以访问许多方法。使用 URL 服务的url()方法是其中之一。有关支持的方法列表,你可以查看官方文档docs.phalconphp.com/en/latest/reference/volt.html#functions。有时,你需要一些从 Volt 无法访问的特殊函数。在这种情况下,你需要扩展 Volt 引擎并实现自己的方法。如何扩展 Volt?
在我们的案例中,我们可以在voltService DI 中直接这样做,它可以在config/services.php中找到,例如,我们想要添加一个名为randomGen()的方法,该方法生成一定数量的随机字符串,它位于modules/Core/Library/Util.php中。voltService DI 将如下所示:
$di['voltService'] = function ($view, $di) use ($config) {
$volt = new \Phalcon\Mvc\View\Engine\Volt($view, $di);
// ... code
$compiler = $volt->getCompiler();
$compiler->addFunction('randomGen', function($resolvedArgs, $exprArgs) {
return 'App\Core\Library\Util::randomGen(' . $resolvedArgs . ')';
});
//...code
return $volt;
};
我们可以通过以下语法在 Volt 中调用此方法:
{{ randomGen(5) }}
上述方法将生成五个随机字符串。
备注
你可以在docs.phalconphp.com/en/latest/reference/volt.html#extending-volt了解更多关于扩展 Volt 的信息。
在侧边栏文件(sidebar.volt)中,我们使用IF语句来检查当前控制器的名称。该名称可以通过分发器获得,并将其分配给名为c_name的变量。我们IF语句和set的等效 PHP 代码如下:
<?php
$c_name = $this->dispatcher->getControllerName();
if (c_name == 'article') {
// Link is active
}
当然,还有其他生成此菜单代码的方法,但尽量多使用 Volt,这样你就能习惯其语法。现在我们已经做了一些修改,让我们访问http://www.learning-phalcon.localhost/backoffice/。如果没有错误,你应该会看到以下截图所示的内容:

清理内核模块
让我们也清理我们的Core模块。我们将使用此文件夹作为库的集合,而不是将其用作模块。首先,移除以下文件:
modules/Core/Config/config.php
modules/Core/Config/services.php
modules/Core/Controllers/IndexController.php
modules/Core/Module.php
然后从modules/Bootstrap.php中移除以下行:
'core' => array(
'className' => 'App\Core\Module',
'path' => __DIR__.'/Core/Module.php',
),
现在我们有一个干净的内核,我们的引导程序将不再将其注册为我们模块的一部分。我们将对modules/Core/Controllers/BaseController.php和modules/Backoffice/Controllers/BaseController.php进行修改,以便这个控制器将扩展modules/Core/Controllers/BaseController.php:
-
在
modules/Api/Controllers/BaseController.php文件中:<?php namespace App\Api\Controllers; use Phalcon\Http\Response; class BaseController extends \App\Core\Controllers\BaseController { // code } -
在
modules/Backoffice/Controllers/BaseController.php文件中:<?php namespace App\Backoffice\Controllers; class BaseController extends \App\Core\Controllers\BaseController { // code }
这些修改帮助我们可以在所有模块中重用一些代码。
标签 CRUD
我们在末尾留下文章的原因是,当我们实现它时,我们需要将其分配给标签、类别和用户。在进一步操作之前,我们将对layout.volt和BaseController进行轻微修改。我们将从BaseController.php中移除以下行:
->addCss($assets_dir.'default/bower_components/bootstrap/dist/css/bootstrap.min.css')
此外,我们将在layout.volt中添加此行,在{{ assets.outputCss('headerCss') }}之前:
{{stylesheetLink('../assets/default/bower_components/bootstrap/dist/css/bootstrap.min.css') }}
我们这样做是因为 bootstrap 的 CSS 已经压缩过了,如果我们再次这样做,bootstrap 字体将无法正确渲染。记住也要对layout_simple.volt应用相同的修改。
在第五章,API 模块,当我们开发 API 模块时,我们的任务之一是创建所需的其余模型和管理器。它们包括 hashtag 管理器和模型。如果你没有这样做,不用担心。你可以在源代码中找到它。在这里,我将向你展示我们为了为 hashtag 开发 CRUD 操作所需的代码部分。你必须做的第一件事是在 API 模块中创建控制器,如果你还没有这样做的话。
API 模块中的 hashtag 控制器
这个控制器中找到的所有方法都遵循相同的逻辑:
-
我们获取 hashtag 管理器的实例
-
我们从请求对象中获取参数
-
我们调用它的特定 API 方法并发送响应
让我们为这个控制器编写代码。我们将从listAction()开始。所有的方法都写在了try{}-catch(){}语句之间:
<?php
namespace App\Api\Controllers;
class HashtagsController extends BaseController{
public function listAction() {
try {
$manager = $this->getDI()->get('core_hashtag_manager');
$page = $this->request->getQuery('p', 'int', 0);
$st_output = $manager->restGet([], [], $page);
return $this->render($st_output);
} catch (\Exception $e) {
return $this->render([
'code' => $e->getCode(),
'message' => $e->getMessage(),
], $e->getCode());
}
}
这个方法调用 hashtag 管理器,并从请求中读取页码。接下来,我们输出$manager->restGet()方法的结果,它是一个包含分页的记录数组:
public function getAction($id) {
try {
$manager = $this->getDI()->get('core_hashtag_manager');
$st_output = $manager->restGet([
'id = :id:',
'bind' => [
'id' => $id,
],
]);
return $this->render($st_output);
} catch (\Exception $e) {
return $this->render([
'code' => $e->getCode(),
'message' => $e->getMessage(),
], $e->getCode());
}
}
这个方法与listAction()大部分相似,区别在于它只会返回一条记录。注意,我们将请求对象的 ID 绑定到$manager->restGet()方法:
public function updateAction($id) {
try {
$manager = $this->getDI()->get('core_hashtag_manager');
if ($this->request->getHeader('CONTENT_TYPE') == 'application/json') {
$data = $this->request->getJsonRawBody(true);
} else {
$data = [$this->request->getPut()];
}
if (count($data[0]) == 0) {
throw new \Exception('Please provide data', 400);
}
$result = $manager->restUpdate($id, $data);
return $this->render($result);
} catch (\Exception $e) {
return $this->render([
'code' => $e->getCode(),
'message' => $e->getMessage(),
], $e->getCode());
}
}
这个方法调用 hashtag 管理器,并从请求中读取页码和内容。如果我们发送的 body 是 JSON 格式,我们将使用getJsonRawBody()读取它。true参数用于将数据转换为数组。如果没有数据,我们抛出异常。接下来,我们输出$manager->restUpdate()方法的结果:
public function deleteAction($id) {
try {
$manager = $this->getDI()->get('core_hashtag_manager');
$st_output = $manager->restDelete($id);
return $this->render($st_output);
} catch (\Exception $e) {
return $this->render([
'code' => $e->getCode(),
'message' => $e->getMessage(),
], $e->getCode());
}
}
deleteAction()方法简单地调用$manager->restDelete(),并将对象的 ID 作为参数。如果对象被找到,它将被删除:
public function createAction() {
try {
$manager = $this->getDI()->get('core_hashtag_manager');
if ($this->request->getHeader('CONTENT_TYPE') == 'application/json') {
$data = $this->request->getJsonRawBody(true);
} else {
$data = $this->request->getPost();
}
if (count($data) == 0) {
throw new \Exception('Please provide data', 400);
}
$st_output = $manager->restCreate($data);
return $this->render($st_output);
} catch (\Exception $e) {
return $this->render([
'code' => $e->getCode(),
'message' => $e->getMessage(),
], $e->getCode());
}
}
}
这个方法与updateAction()类似,但不是更新一个对象,而是创建它。
路由缺失,所以我们将将其添加到modules/Api/Config.routing.php中。我们将为 hashtag 创建一个新的路由组:
$hashtags = new \Phalcon\Mvc\Router\Group([
'module' => 'api',
'controller' => 'hashtags',
]);
$hashtags->setPrefix($versions['v1'].'/hashtags');
$hashtags->addGet('', ['action' => 'list']);
$hashtags->addGet('/{id}', ['action' => 'get']);
$hashtags->addPut('/{id}', ['action' => 'update']);
$hashtags->addDelete('/{id}', ['action' => 'delete']);
$hashtags->addPost('', ['action' => 'create']);
$router->mount($hashtags);
如果一切正常,你现在可以插入一些记录到 hashtag 表中,并使用以下命令行调用 API 来获取记录:
$ curl -i -X GET -H "Content-Type:application/json" -H "APIKEY:6y825Oei113X3vbz78Ck7Fh7k3xF68Uc0lki41GKs2Z73032T4z8m1I81648JcrY" -H "TOKEN:mySecretToken" 'http://learning-phalcon.localhost/api/v1/hashtags'
命令行的输出应该类似于以下截图所示:

这个 cURL 命令向/api/v1/hashtags发送请求。-H选项用于发送头部信息;在我们的例子中,我们发送了 token 和 API 密钥。
减少代码重复的一个常用方法
让我们在modules/Core/Controllers/BaseController.php中创建一个方法,这个方法将帮助我们从我们的 API 获取数据。这个方法将在扩展它的控制器中使用:
public function apiGet($uri, $params = []) {
$config = $this->getDI()->get('config')->toArray();
$uri = $config['apiUrl'].$uri;
$curl = new \Phalcon\Http\Client\Provider\Curl();
$response = $curl->get($uri, $params, ["APIKEY:".$config['apiKeys'][0]]);
if ($response->header->statusCode != 200) {
throw new \Exception('API error: '.$response->header->status);
}
return json_decode($response->body, true);
}
获取数据
要获取数据,我们只需要提供 URL 和(如果需要)额外参数。借助 Phalcon 内置的 cURL 提供者,我们进行调用。接下来,我们在Backoffice模块中创建一个标签控制器。它看起来像这样:
<?php
namespace App\Backoffice\Controllers;
class HashtagController extends BaseController {
public function indexAction() {
return $this->dispatcher->forward(['action' => 'list']);
}
/**
* Hashtags list
*/
public function listAction() {
$page = $this->request->getQuery('p', 'int', 1);
try {
$hashtags = $this->apiGet('hashtags?p='.$page);
$this->view->hashtags = $hashtags;
} catch (\Exception $e) {
$this->flash->error($e->getMessage());
}
}
}
如你所见,我们在这里做的唯一一件事就是调用 API 的 URL,然后它返回一个分页项的数组。
布局结构
为此列表创建布局相当简单。转到modules/Backoffice/Views/Default并创建一个名为hashtag的新文件夹。在这个新文件夹中,创建一个名为list.volt的新文件,其内容如下:
{% extends 'layout.volt' %}
{% block body %}
<div class="pull-left">
<h1>Hashtags</h1>
</div>
<div class="pull-right">
<a class="btn btn-success" href="{{ url('hashtag/add') }}" aria-label="Left Align">
<span class="glyphicon glyphicon-plus" aria-hidden="true"></span> New
</a>
</div>
<div class="clearfix"></div>
<hr>
<div class="panel panel-default">
<div class="panel-body">
<table class="table table-striped">
<thead>
<tr>
<th>#</th>
<th>Hashtag</th>
<th>Created at</th>
<th>Options</th>
</tr>
</thead>
<tbody>
{% for hashtag in hashtags['items'] %}
<tr>
<th scope="row">{{ hashtag['id'] }}</th>
<td>{{ hashtag['hashtag_name'] }}</td>
<td>{{ hashtag['hashtag_created_at'] }}</td>
<td>
<a class="btn btn-default btn-xs" href="#" aria-label="Left Align">
<span class="glyphicon glyphicon-pencil" aria-hidden="true"></span>
</a>
<a class="btn btn-danger btn-xs" href="#" aria-label="Left Align">
<span class="glyphicon glyphicon-trash" aria-hidden="true"></span>
</a>
</td>
</tr>
{% else %}
<tr>
<td colspan="4">There are no hashtags in your database</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% if (hashtags['total_pages'] > 1) %}
{% include 'common/paginator' with {'page_url' : url('hashtag/list'), 'stack' : hashtags} %}
{% endif %}
{% endblock %}
在结束列表模板的代码块之前,我们包含另一个名为paginator的模板。这是将帮助我们导航记录的分页器。在modules/Backoffce/Views/Default/common/中创建一个名为paginator.volt的文件,并写入以下代码:
<nav>
<ul class="pager">
<li class="previous {% if (stack['current'] < 2) %}disabled{% endif %}"><a href="{{ page_url ~ '?p=' ~ stack['before'] }}"><span aria-hidden="true">←</span> Previous</a></li>
<li class="next {% if (stack['current'] == stack['total_pages']) %}disabled{% endif %}"><a href="{{ page_url ~ '?p=' ~ stack['next'] }}">Next <span aria-hidden="true">→</span></a></li>
</ul>
</nav>
小贴士
上述代码使用了已经可用的分页变量(见docs.phalconphp.com/en/latest/reference/pagination.html)。
现在,你可以在http://www.learning-phalcon.localhost/backoffice/hashtag/list处进行身份验证并访问标签列表。你应该能看到类似于下一张截图所示的内容:

让我们继续处理剩余的操作(创建、删除和更新)。实现这一点有几种方法,但我们不会使用我们的 API 进行创建和更新,主要是因为需要覆盖所有方面所需的时间,而且按照“老常规方式”来做更快。然而,你可以尝试这个想法,尝试将这些两个操作迁移到 API 驱动。
标签表单
对于创建和更新,我喜欢使用表单,因为它更容易维护代码,也更容易验证。我们将从编写创建表单的代码开始,创建操作(相同的表单将用于更新)。切换到modules/Core/Forms并创建一个名为HashtagForm.php的新文件,其代码如下:
<?php
namespace App\Core\Forms;
use Phalcon\Forms\Form;
use Phalcon\Forms\Element\Text;
use Phalcon\Forms\Element\Submit;
use Phalcon\Forms\Element\Hidden;
use Phalcon\Validation\Validator\PresenceOf;
use Phalcon\Validation\Validator\Identical;
class HashtagForm extends Form {
public function initialize() {
$hashtag_name = new Text('hashtag_name', array(
'placeholder' => 'Name',
));
$hashtag_name->addValidators(array(
new PresenceOf(array(
'message' => 'Name is required',
))
));
$this->add($hashtag_name);
//CSRF
$csrf = new Hidden('csrf');
$csrf->addValidator(
new Identical(array(
'value' => $this->security->getSessionToken(),
'message' => 'CSRF validation failed',
))
);
$this->add($csrf);
$this->add(new Submit('add', array(
'class' => 'btn btn-lg btn-primary btn-block',
)));
}
}
我们的新表单相当简单。我们有三个元素:标签的名称、一个csrf字段和一个提交按钮。我们使用两个验证器PresenceOf和Identical来验证名称和csrf字段。
标签控制器
我们继续编写create操作和模板的代码。打开modules/Backoffice/Controllers/HashtagController.php并添加这两个方法:
public function addAction() {
$manager = $this->getDI()->get('core_hashtag_manager');
$this->view->form = $manager->getForm();
}
public function createAction() {
if (!$this->request->isPost()) {
return $this->response->redirect('hashtag/list');
}
$manager = $this->getDI()->get('core_hashtag_manager');
$form = $manager->getForm();
if ($form->isValid($this->request->getPost())) {
try {
$manager = $this->getDI()->get('core_hashtag_manager');
$manager->create($this->request->getPost());
$this->flashSession->success('Object was created successfully');
return $this->response->redirect('hashtag/list');
} catch (\Exception $e) {
$this->flash->error($e->getMessage());
return $this->dispatcher->forward(['action' => 'add']);
}
} else {
foreach ($form->getMessages() as $message) {
$this->flash->error($message->getMessage());
}
return $this->dispatcher->forward(['action' => 'add', 'controller' => 'hashtag']);
}
}
addAction()方法简单地渲染我们刚刚创建的表单。创建和验证的过程发生在createAction()方法中。正如你可以在前两行看到的那样,这个方法只接受POST数据。当你在一个大项目上工作时,你可能想使用自定义路由,就像我们在 API 模块中所做的那样。
标签管理器
你可能会注意到标签管理器中有一个名为getForm()的新方法。这个方法返回一个HashtagForm实例,其外观如下:
use App\Core\Forms\HashtagForm;
class HashtagManager extends BaseManager{
...
public function getForm($entity = null, $options = null) {
return new HashtagForm($entity, $options);
}
...
}
如果您已经创建了标签管理器,您应该有一个类似于以下这样的 create() 方法:
public function create(array $st_inputData)
{
$st_defaultData = [
'hashtag_name' => new \Phalcon\Db\RawValue('NULL')
];
$st_data = array_merge($st_defaultData, $st_inputData);
$hashtag = new Hashtag();
$hashtag->setHashtagName($st_data['hashtag_name']);
return $this->save($hashtag, 'create');
}
add() 方法的视图模板
我们还需要编写模板的代码。在 modules/Backoffice/Views/Default/hashtag 目录下创建一个名为 add.volt 的新文件,并添加以下代码:
{% extends 'layout.volt' %}
{% block body %}
<h1>Add</h1>
<hr>
<div class="panel panel-default">
<div class="panel-body">
<form method="post" action="{{ url('hashtag/create') }}">
<div class="form-group">
<label for="hashtag_name">Name</label>
{{ form.render('hashtag_name', {'class':'form-control'}) }}
</div>
{{ form.render('add', {'value':'Add'}) }}
{{ form.render('csrf', {'value':security.getToken()}) }}
</form>
</div>
</div>
{% endblock %}
我们的模板扩展了 layout.volt 并渲染了标签表单元素。此时,您应该能够从您的 Backoffice 模块中添加一个新的标签。打开 http://www.learning-phalcon.localhost/backoffice/hashtag/add,填写标签的名称,然后点击 添加 按钮,如下所示:

如果标签保存正确,您将被重定向到标签列表页面,否则将显示错误消息。
改进数据库表结构并添加验证
现在我们能够添加标签了,我们将面临一个问题,因为我们能够添加重复的标签。为了解决这个问题,我们将对我们的标签表进行一些小的修改,并在标签模型中实现一个新的验证器。
我们将对这个表进行的更改是为了使 name 字段唯一。通过在您的数据库上执行以下 SQL 查询来实现:
ALTER TABLE hashtag ADD UNIQUE (hashtag_name);
如果您尝试添加一个新的标签,您将得到一个 完整性约束违反 错误。这足以避免数据库中的重复,但您需要通过使用 Phalcon\Mvc\Model\Validator\Uniqueness 来实现一个更人性化的错误。打开标签模型,并添加以下代码:
public function validation(){
$this->validate(new Uniqueness([
"field" => "hashtag_name",
"message" => "This hashtag already exists",
]));
return $this->validationHasFailed() != true;
}
这就是了!如果您尝试添加相同的标签,您将得到一个错误消息,说 此标签已存在。我们现在可以继续为标签的编辑/更新方法。
编辑标签
编辑遵循与创建相同的流程,除了我们需要搜索一个现有的对象来编辑。让我们首先在 modules/Core/Managers/HashtagManager.php 中创建 update() 方法:
public function update(array $st_inputData){
$st_defaultData = [
'hashtag_name' => new \Phalcon\Db\RawValue('NULL')
];
$st_data = array_merge($st_defaultData, $st_inputData);
$hashtag = Hashtag::findFirstById($st_data['id']);
if (!$hashtag) {
throw new \Exception('Object not found');
}
$hashtag->setHashtagName($st_data['hashtag_name']);
return $this->save($hashtag, 'update');
}
如您所见,update() 和 create() 方法之间只有两个区别。一个是根据其 ID 搜索了一个标签,第二个是我们将第二个参数从 $this->save() 改为了 update。模板与 create() 方法的模板相同。在 modules/Backoffice/Views/Default/hashtag 目录下创建一个名为 edit.volt 的新文件,并包含以下代码:
{% extends 'layout.volt' %}
{% block body %}
<h1>Edit</h1>
<hr>
<div class="panel panel-default">
<div class="panel-body">
<form method="post" action="{{ url('hashtag/update') }}">
<div class="form-group">
<label for="hashtag_name">Name</label>
{{ form.render('hashtag_name', {'class':'form-control'})}}
</div>
{{ form.render('save', {'value':'Save'}) }}
{{ form.render('csrf', {'value':security.getToken()})}}
</form>
</div>
</div>
{% endblock %}
我们在这里所做的唯一修改是将 <h1> 标题从 添加 改为 编辑。我们现在可以切换到 HashtagController 并创建两个新的方法,editAction() 和 updateAction():
public function editAction($id){
$manager = $this->getDI()->get('core_hashtag_manager');
$hashtag = $manager->findFirstById($id);
if (!$hashtag) {
$this->flashSession->error('Object not found');
return $this->response->redirect('hashtag/list');
}
$this->persistent->set('id', $id);
$this->view->form = $manager->getForm($hashtag);
}
在这个方法中,我们搜索一个对象,如果我们找不到它,就输出一个错误消息。如果找到了,我们将 ID 保存到一个持久包中(当使用持久包时,数据临时保存在会话中,并在第一次获取变量时删除),然后将对象分配给要渲染的表单。updateAction() 方法如下所示:
public function updateAction(){
if (!$this->request->isPost()) {
return $this->response->redirect('hashtag/list');
}
$manager = $this->getDI()->get('core_hashtag_manager');
$hashtag_id = $this->persistent->get('id');
$hashtag = $manager->findFirstById($hashtag_id);
$form = $manager->getForm($hashtag);
if ($form->isValid($this->request->getPost())) {
try {
$manager = $this->getDI()->get('core_hashtag_manager');
$manager->update([
'hashtag_name' => $this->request->getPost('hashtag_name',['string','trim']),
'id' => $hashtag_id
]);
$this->flashSession->success('Object was updated successfully');
return $this->response->redirect('hashtag/list');
} catch (\Exception $e) {
$this->flash->error($e->getMessage());
return $this->dispatcher->forward(['action' => 'edit']);
}
} else {
foreach ($form->getMessages() as $message) {
$this->flash->error($message->getMessage());
}
return $this->dispatcher->forward(['action' => 'edit', 'controller' => 'hashtag']);
}
}
与createAction()方法相比,这种方法的主要区别在于我们从持久化包中获取对象 ID 并搜索它。最后一步是从列表页面创建到编辑页面的链接。更新list.volt并替换当前的edit链接为以下代码:
<a class="btn btn-default btn-xs" href="{{ url('hashtag/edit/' ~ hashtag['id']) }}" aria-label="Left Align">
<span class="glyphicon glyphicon-pencil" aria-hidden="true"></span>
</a>
就这样!现在你可以访问http://www.learning-phalcon.localhost/backoffice/hashtag/list,点击现有记录的编辑按钮,并尝试编辑(更改名称)。
删除标签
我们可以继续并编写此过程最后一步的代码——删除。删除非常简单且快捷。我们将使用一个中间页面,以便用户在想要删除对象时可以确认。让我们首先编写模板的代码。在hashtag文件夹中创建一个名为delete.volt的新文件,并编写以下代码:
{% extends 'layout.volt' %}
{% block body %}
<h1>Confirm deletion</h1>
<h3>Are you sure you want to delete the selected element?</3>
<hr>
<div class="panel panel-default">
<div class="panel-body">
<form method="post" action="{{ url('hashtag/delete/' ~ id) }}" class="form-inline">
<input type="submit" value="Yes, delete" class="btn btn-sm btn-danger btn-block">
<a href="{{ url('hashtag/list') }}" class="btn btn-lg btn-default btn-block">Cancel</a>
</form>
</div>
</div>
{% endblock %}
此模板有一个简单的表单。当用户点击是,删除按钮时,实际删除操作发生。切换到HashtagController.php并创建一个名为deleteAction()的方法:
public function deleteAction($id){
if ($this->request->isPost()) {
try {
$manager = $this->getDI()->get('core_hashtag_manager');
$manager->delete($id);
$this->flashSession->success('Item has been deleted successfully');
} catch (\Exception $e) {
$this->flash->error($e->getMessage());
}
return $this->response->redirect('hashtag/list');
}
$this->view->id = $id;
}
默认情况下,deleteAction($id)方法仅渲染其模板(delete.volt)。当我们确认删除时,我们进行一个 POST 请求并删除记录。如果你还没有为管理器编写delete()方法的代码,以下是它应该的样子:
public function delete($id){
$object = Hashtag::findFirstById($id);
if (!$object) {
throw new \Exception('Hashtag not found');
}
if (false === $object->delete()) {
foreach ($object->getMessages() as $message) {
$error[] = (string) $message;
}
throw new \Exception(json_encode($error));
}
return true;
}
在最后一步,我们需要更新list.volt模板以创建到删除页面的链接。打开hashtag/list.volt并将delete链接替换为以下链接:
<a class="btn btn-danger btn-xs" href="{{ url('hashtag/delete/' ~ hashtag['id']) }}" aria-label="Left Align">
<span class="glyphicon glyphicon-trash" aria-hidden="true"></span>
</a>
关于删除,这就是所有需要知道的内容。你可以通过点击列表中的删除链接来测试它。你应该看到与下一张截图完全相同的输出:

如果你点击取消,你应该被重定向到列表页面。如果你点击是,删除并且没有错误,你将被重定向到列表页面,并显示以下成功消息:项目已成功删除。我们现在将继续进行分类的 CRUD 开发。
分类 CRUD
当我们为分类表创建架构时,我们添加了一个category_translation表。我们将修改这个表并为相同的国家代码和分类 ID 添加一个唯一索引以避免重复。执行以下查询:
ALTER TABLE `learning_phalcon`.`category_translation` ADD UNIQUE (
`category_translation_category_id` ,
`category_translation_lang`
) COMMENT '';
我们将在config/config.php全局配置文件中添加一个新的数组,该数组将包含有关i18n的信息:
'i18n' => [
'locales' => [ //ISO 639-1: two-letter codes, one per language
'en' => 'English'
]
]
分类表单
我们现在将创建添加/编辑分类的表单。在modules/Core/Forms/中创建一个新文件,命名为CategoryForm.php,并编写以下代码:
<?php
namespace App\Core\Forms;
use Phalcon\Forms\Form;
use Phalcon\Forms\Element\Text;
use Phalcon\Forms\Element\Select;
use Phalcon\Forms\Element\Submit;
use Phalcon\Forms\Element\Hidden;
use Phalcon\Validation\Validator\Identical;
class CategoryForm extends Form{
private $edit = false;
public function initialize($entity = null, $options = null) {
if (isset($options['edit']) && $options['edit'] === true) {
$this->edit = true;
}
$locales = $this->getDI()->get('config')->i18n->locales->toArray();
foreach ($locales as $locale => $name) {
if (true === $this->edit) {
$translations = $entity->getTranslations(["category_translation_lang = '$locale'"])->toArray();
}
$category_name[$locale] = new Text ("translations[$locale][category_translation_name]", [
'value' => $this->edit === true ? $translations[0]['category_translation_name'] : null
]);
$category_slug[$locale] = new Text ("translations[$locale][category_translation_slug]", [
'value' => $this->edit === true ? $translations[0]['category_translation_slug'] : null
]);
$category_lang[$locale] = new Hidden ( "translations[$locale][category_translation_lang]", [
'value' => $locale
]);
$this->add( $category_name[$locale] );
$this->add( $category_slug[$locale] );
$this->add( $category_lang[$locale] );
}
//CSRF
$csrf = new Hidden('csrf');
$csrf->addValidator(
new Identical(array(
'value' => $this->security->getSessionToken(),
'message' => 'CSRF validation failed',
))
);
$this->add($csrf);
$this->add(new Submit('save', array(
'class' => 'btn btn-lg btn-primary btn-block',
)));
}
}
在此表单中,我们根据可用的区域自动添加所需的字段。如果我们编辑一条记录,我们需要检索翻译并将正确的值分配给每个字段。我们使用数组命名风格以便于处理。这意味着生成的字段名称将看起来像这样:translations[en][category_translation_name]。
接下来,我们需要将可用的区域分配给视图。打开modules/Backoffice/Controllers/中的BaseController.php,并将以下行追加到afterExecuteRoute()方法中:
$this->view->locales = $this->getDI()->get('config')->i18n->locales->toArray();
创建类别模板
让我们看看我们的模板将是什么样子。在modules/Backoffice/Views/Default中创建一个新文件夹,命名为category。在这个新文件夹中,创建list.volt、add.volt、edit.volt和delete.volt模板文件。以下各节包含每个文件的代码。
list.volt
通常,任何部分的列表都是差不多的。以下是Category的list.volt模板文件:
{% extends 'layout.volt' %}
{% block body %}
<div class="pull-left">
<h1>Categories</h1>
</div>
<div class="pull-right">
<a class="btn btn-success" href="{{ url('category/add') }}" aria-label="Left Align">
<span class="glyphicon glyphicon-plus" aria-hidden="true"></span> New
</a>
</div>
<div class="clearfix"></div>
<hr>
<div class="panel panel-default">
<div class="panel-body">
<table class="table table-striped">
<thead>
<tr>
<th>#</th>
<th>Category</th>
<th>Slug</th>
<th>Created at</th>
<th>Options</th>
</tr>
</thead>
<tbody>
{% for record in records['items'] %}
<tr>
<th scope="row">{{ record['id'] }}</th>
<td>{{ record['category_translations'][0]['category_translation_name'] }}</td>
<td>{{ record['category_translations'][0]['category_translation_slug'] }}</td>
<td>{{ record['category_created_at'] }}</td>
<td>
<a class="btn btn-default btn-xs" href="{{ url('category/edit/' ~ record['id']) }}" aria-label="Left Align">
<span class="glyphicon glyphicon-pencil" aria-hidden="true"></span>
</a>
<a class="btn btn-danger btn-xs" href="{{ url('category/delete/' ~ record['id']) }}" aria-label="Left Align">
<span class="glyphicon glyphicon-trash" aria-hidden="true"></span>
</a>
</td>
</tr>
{% else %}
<tr>
<td colspan="4">There are no records in your database</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% if (records['total_pages'] > 1) %}
{% include 'common/paginator' with {'page_url' : url('category/list'), 'stack' : records} %}
{% endif %}
{% endblock %}
add.volt
在这个模板中,唯一需要注意的重要事情是我们如何渲染元素。我们遍历从BaseController.php分配的locales变量,并为每个区域渲染元素:
{% extends 'layout.volt' %}
{% block body %}
<h1>Add</h1>
<hr>
<div class="panel panel-default">
<div class="panel-body">
<form method="post" action="{{ url('category/create') }}">
{% for locale, name in locales %}
<h4>Category ({{ name }})</h4>
<div class="form-group">
<label for="category_name">Name</label>{{ form.render('translations['~locale~'][category_translation_name]', {'class':'form-control'}) }}
</div>
<div class="form-group">
<label for="category_slug">Slug</label>
{{ form.render('translations['~locale~'][category_translation_slug]', {'class':'form-control'}) }}
</div>
{{ form.render('translations['~locale~'][category_translation_lang]') }}
{% endfor %}
{{ form.render('save', {'value':'Save'}) }}
{{ form.render('csrf', {'value':security.getToken()}) }}
</form>
</div>
</div>
{% endblock %}
edit.volt
edit.volt文件基本上与add.volt相同。我们只需要更改表单操作为{{ url('category/update') }}。如果你知道你不会开发一个复杂的系统,你可以使用相同的文件进行添加/编辑。我个人更喜欢使用两个单独的文件,因为编辑的复杂性往往比添加要高得多。
delete.volt
这是一个最简单的模板,但出于与添加/编辑相同的原因,我更喜欢将这些文件分开:
{% extends 'layout.volt' %}
{% block body %}
<h1>Confirm deletion</h1>
<h3>Are you sure you want to delete the selected element?</3>
<hr>
<div class="panel panel-default">
<div class="panel-body">
<form method="post" action="{{ url('category/delete/' ~ id) }}" class="form-inline">
<input type="submit" value="Yes, delete" class="btn btn-sm btn-danger btn-block">
<a href="{{ url('category/list') }}" class="btn btn-lg btn-default btn-block">Cancel</a>
</form>
</div>
</div>
{% endblock %}
备注
如果你愿意,并且没有复杂的操作,你可以在common/文件夹中创建一个delete.volt文件,并从那里包含到所有部分。以下是如何做到这一点的示例:
{% extends 'layout.volt' %}
{% block body %}
{% include 'common/delete' with {'url':url('category/delete/' ~ id)} %}
{% endblock %}
创建类别控制器
现在我们有了模板的代码,让我们创建控制器。切换到modules/Backoffice/Controllers/,创建一个名为CategoryController.php的新文件,并包含以下代码:
<?php
namespace App\Backoffice\Controllers;
class CategoryController extends BaseController{
public function indexAction() {
return $this->dispatcher->forward(['action' => 'list']);
}
public function listAction() {
$page = $this->request->getQuery('p', 'int', 1);
try {
$records = $this->apiGet('categories?p='.$page);
$this->view->records = $records;
} catch (\Exception $e) {
$this->flash->error($e->getMessage());
}
}
public function addAction() {
$manager = $this->getDI()->get('core_category_manager');
$this->view->form = $manager->getForm();
}
public function editAction($id) {
$manager = $this->getDI()->get('core_category_manager');
$object = $manager->findFirstById($id);
if (!$object) {
$this->flashSession->error('Object not found');
return $this->response->redirect('category/list');
}
$this->persistent->set('id', $id);
$this->view->form = $manager->getForm($object,['edit' => true]);
}
public function createAction() {
if (!$this->request->isPost()) {
return $this->response->redirect('category/list');
}
$manager = $this->getDI()->get('core_category_manager');
$form = $manager->getForm();
if ($form->isValid($this->request->getPost())) {
try {
$manager = $this->getDI()->get('core_category_manager');
$post_data = $this->request->getPost();
$data = array_merge($post_data, ['category_is_active' => 1]);
$manager->create($data);
$this->flashSession->success('Object was created successfully');
return $this->response->redirect('category/list');
} catch (\Exception $e) {
$this->flash->error($e->getMessage());
return $this->dispatcher->forward(['action' => 'add']);
}
} else {
foreach ($form->getMessages() as $message) {
$this->flash->error($message->getMessage());
}
return $this->dispatcher->forward(['action' => 'add', 'controller' => 'category']);
}
}
public function updateAction() {
if (!$this->request->isPost()) {
return $this->response->redirect('category/list');
}
$manager = $this->getDI()->get('core_category_manager');
$object_id = $this->persistent->get('id');
$object = $manager->findFirstById($object_id);
$form = $manager->getForm($object);
if ($form->isValid($this->request->getPost())) {
try {
$manager = $this->getDI()->get('core_category_manager');
$manager->update(array_merge($this->request->getPost(), ['id' => $object_id]));
$this->flashSession->success('Object was updated successfully');
return $this->response->redirect('category/list');
} catch (\Exception $e) {
$this->flash->error($e->getMessage());
return $this->dispatcher->forward(['action' => 'edit']);
}
} else {
foreach ($form->getMessages() as $message) {
$this->flash->error($message->getMessage());
}
return $this->dispatcher->forward(['action' => 'edit', 'controller' => 'category']);
}
}
public function deleteAction($id) {
if ($this->request->isPost()) {
try {
$manager = $this->getDI()->get('core_category_manager');
$manager->delete($id);
$this->flashSession->success('Object has been deleted successfully');
} catch (\Exception $e) {
$this->flashSession->error($e->getMessage());
}
return $this->response->redirect('category/list');
}
$this->view->id = $id;
}
}
如果你检查updateAction()和createAction()方法,你会注意到我们直接使用$post_data。我们可以这样做,因为表单字段具有数组样式的名称,所以我们以管理器期望的格式发送数据。
editAction()方法渲染编辑记录的表单。注意$manager->getForm()的第二个参数。它是一个包含edit键和true值的数组,我们在CategoryForm.php中使用它。
创建类别管理器
我们缺少管理器。在modules/Core/Managers/CategoryManager.php中创建一个名为CategoryManager.php的新文件,并包含以下内容:
<?php
namespace App\Core\Managers;
use App\Core\Models\Category;
use App\Core\Models\CategoryTranslation;
use App\Core\Forms\CategoryForm;
class CategoryManager extends BaseManager{
public function getForm($entity = null, $options = null) {
return new CategoryForm($entity, $options);
}
public function find($parameters = null) {
return Category::find($parameters);
}
public function findFirst($parameters = null) {
return Category::findFirst($parameters);
}
public function findFirstById($id) {
return Category::findFirstById($id);
}
public function create(array $input_data) {
$default_data = array('translations' => array(
'en' => array(
'category_translation_name' => 'Category name',
'category_translation_slug' => '',
'category_translation_lang' => 'en',
),
),
'category_is_active' => 0,);
$data = array_merge($default_data, $input_data);
$category = new Category();
$category->setCategoryIsActive($data['category_is_active']);
$categoryTranslations = array();
foreach ($data['translations'] as $lang => $translation) {
$tmp = new CategoryTranslation();
$tmp->assign($translation);
array_push($categoryTranslations, $tmp);
}
$category->translations = $categoryTranslations;
return $this->save($category, 'create');
}
public function update(array $st_inputData) {
$st_defaultData = array('translations' => array(
'en' => array(
'category_translation_name' => 'Category name',
'category_translation_slug' => '',
'category_translation_lang' => 'en',
),
));
$st_data = array_merge($st_defaultData, $st_inputData);
$object = Category::findFirstById($st_data['id']);
if (!$object) {
throw new \Exception('Object not found');
}
foreach ($st_data['translations'] as $locale => $values) {
$translation = $object->getTranslations(["category_translation_lang = '$locale'"]);
$translation[0]->setCategoryTranslationName($values['category_translation_name']);
$translation[0]->setCategoryTranslationSlug($values['category_translation_slug']);
$translation[0]->setCategoryTranslationLang($values['category_translation_lang']);
$this->save($translation[0], 'update');
}
return $this->save($object, 'update');
}
public function delete($id) {
$object = Category::findFirstById($id);
if (!$object) {
throw new \Exception('Object not found');
}
if (false === $object->delete()) {
foreach ($object->getMessages() as $message) {
$error[] = (string) $message;
}
throw new \Exception(json_encode($error));
}
return true;
}
}
我们需要启用此管理器。将以下代码添加到config/managers.php文件中:
$di['core_category_manager'] = function () {
return new \App\Core\Managers\CategoryManager();
};
那就结束了!现在你可以访问http://www.learning-phalcon.localhost/backoffice/category/list,你应该会看到类似以下的内容:

如果你没有任何记录,点击+ 新建按钮创建一个新的类别。此操作将渲染add.volt模板,你将看到以下截图:

就这样!你可以查看本章的源代码,并使用不同类别来尝试 API。请注意,API 文档始终可在 docs/api/index.html 中找到。
摘要
在本章中,我们为标签和类别开发了一个功能性的 CRUD。你学习了如何进行 API 调用和动态渲染表单元素。
在下一章中,我们将专注于完成“后台”模块(为文章和用户开发 CRUD)。我们将通过编写用户和文章 CRUD 的代码来继续开发这个模块。
第八章。Backoffice 模块(第二部分)
在本章中,我们将开发 Backoffice 模块的剩余部分,以便我们可以获得一个完全功能化的管理区域。本章涵盖了以下主题:
-
用户 CRUD
-
文章 CRUD
用户 CRUD
我们已经开发出实现此功能所需的部分代码,但我们将重写其中一部分,因为在同时,我们对数据库进行了更改,这将影响我们应用程序的功能。接下来我们要开发的是类似于之前的 CRUD 部分。让我们从 API 控制器开始。
创建控制器(API)
正如我们在第七章中做的那样,Backoffice 模块(第一部分),带有哈希标签和分类,我们需要为用户创建一个控制器。在modules/Api/Controller/目录下创建一个新文件,命名为UsersController.php。然后,在文件中编写以下代码:
<?php
namespace App\Api\Controllers;
class UsersController extends BaseController{
public function updateAction($id) {
try {
$manager = $this->getDI()->get('core_user_manager');
if ($this->request->getHeader('CONTENT_TYPE') == 'application/json') {
$data = $this->request->getJsonRawBody(true);
} else {
$data = $this->request->getPut();
}
if (count($data) == 0) {
throw new \Exception('Please provide data', 400);
}
$st_data = array_merge($data, ['id' => $id]);
$result = $manager->restUpdate($st_data);
return $this->render($result);
} catch (\Exception $e) {
return $this->render([
'code' => $e->getCode(),
'message' => $e->getMessage(),
], $e->getCode());
}
}
public function createAction() {
try {
$manager = $this->getDI()->get('core_user_manager');
if ($this->request->getHeader('CONTENT_TYPE') == 'application/json') {
$data = $this->request->getJsonRawBody(true);
} else {
$data = $this->request->getPost();
}
if (count($data) == 0) {
throw new \Exception('Please provide data', 400);
}
$st_output = $manager->restCreate($data);
return $this->render($st_output);
} catch (\Exception $e) {
return $this->render([
'code' => $e->getCode(),
'message' => $e->getMessage(),
], 500);
}
}
}
如你所见,这个控制器和其他控制器之间没有太多区别,除了参数绑定。我们省略了list()、get()和delete()方法,但你可以在这个章节的源代码中找到它们。
现在我们将进入Backoffice中控制器创建的环节。
Backoffice 模块的用户控制器
在modules/Backoffice/Controller/目录下创建一个新文件,命名为UserController.php。然后,在文件中编写以下代码:
<?php
namespace App\Backoffice\Controllers;
class UserController extends BaseController{
public function createAction() {
if (!$this->request->isPost()) {
return $this->response->redirect('user/list');
}
$manager = $this->getDI()->get('core_user_manager');
$form = $manager->getForm();
if ($form->isValid($this->request->getPost())) {
try {
$manager = $this->getDI()->get('core_user_manager');
$post_data = $this->request->getPost();
$manager->create($post_data);
$this->flashSession->success('Object was created successfully');
return $this->response->redirect('user/list');
} catch (\Exception $e) {
$this->flash->error($e->getMessage());
return $this->dispatcher->forward(['action' => 'add']);
}
} else {
foreach ($form->getMessages() as $message) {
$this->flash->error($message->getMessage());
}
return $this->dispatcher->forward(['action' => 'add', 'controller' => 'user']);
}
}
public function updateAction() {
if (!$this->request->isPost()) {
return $this->response->redirect('user/list');
}
$manager = $this->getDI()->get('core_user_manager');
$object_id = $this->persistent->get('id');
$object = $manager->findFirstById($object_id);
$form = $manager->getForm($object);
if ($form->isValid($this->request->getPost())) {
try {
$manager = $this->getDI()->get('core_user_manager');
$manager->update(array_merge($this->request->getPost(), ['id' => $object_id]));
$this->flashSession->success('Object was updated successfully');
return $this->response->redirect('user/list');
} catch (\Exception $e) {
$this->flash->error($e->getMessage());
return $this->dispatcher->forward(['action' => 'edit']);
}
} else {
foreach ($form->getMessages() as $message) {
$this->flash->error($message->getMessage());
}
return $this->dispatcher->forward(['action' => 'edit', 'controller' => 'user']);
}
}
}
需要更多注意的方法是updateAction()和createAction(),在这些方法中,我们验证用户表单并将数据分配给管理器中的正确操作。
注意
故意省略了addAction()、deleteAction()和listAction()方法,但你可以在这个章节的源代码中找到它们。
用户表单
你已经学到了我们如何以及为什么使用表单。我们将创建一个表单,它将帮助我们渲染和验证用户创建所需的数据。在modules/Core/Forms/目录下创建一个新文件,命名为UserForm.php。然后,在文件中编写以下代码:
<?php
namespace App\Core\Forms;
use Phalcon\Forms\Form;
use Phalcon\Forms\Element\Text;
use Phalcon\Forms\Element\Password;
use Phalcon\Forms\Element\Submit;
use Phalcon\Forms\Element\Select;
use Phalcon\Forms\Element\Hidden;
use Phalcon\Validation\Validator\PresenceOf;
use Phalcon\Validation\Validator\Email;
use Phalcon\Validation\Validator\StringLength;
use Phalcon\Validation\Validator\Identical;
use App\Core\Models\AclRoles;
class UserForm extends Form {
private $edit;
public function initialize($entity = null, $options = null) {
if (isset($options['edit']) && $options['edit'] === true) {
$this->edit = true;
}
// First name
$user_first_name = new Text('user_first_name', array(
'placeholder' => 'First name',
));
$user_first_name->addValidators(array(
new PresenceOf(array(
'message' => 'First name is required',
))
));
$this->add($user_first_name);
// Last name
$user_last_name = new Text('user_last_name', array(
'placeholder' => 'Last name',
));
$user_last_name->addValidators(array(
new PresenceOf(array(
'message' => 'Last name is required',
))
));
$this->add($user_last_name);
// Email
$user_email = new Text('user_email', array(
'placeholder' => 'Email',
));
$user_email->addValidators(array(
new PresenceOf(array(
'message' => 'The e-mail is required',
)),
new Email(array(
'message' => 'The e-mail is not valid',
)),
));
$this->add($user_email);
//Password
$user_password = new Password('user_password', array(
'placeholder' => 'Password',
));
$user_password->addValidators(array(
new PresenceOf(array(
'message' => 'Password is required'
)),
new StringLength(array(
'min' => 8,
'messageMinimum' => 'Password is too short. Minimum 8 characters'
))
));
$this->add($user_password);
// User is active
$this->add(new Select('user_is_active', array(
1 => 'Yes',
0 => 'No'
)));
// User location
$user_profile_location = new Text('user_profile_location', array(
'placeholder' => 'Location',
));
if (true === $this->edit) {
$user_profile_location->setDefault($entity->profile->getUserProfileLocation());
}
$this->add($user_profile_location);
// User role
$user_acl_role = new Select('user_acl_role', AclRoles::find(), array(
'using' => array('name', 'name')
));
$this->add($user_acl_role);
//CSRF
$csrf = new Hidden('csrf');
$csrf->addValidator(
new Identical(array(
'value' => $this->security->getSessionToken(),
'message' => 'CSRF validation failed',
))
);
$this->add($csrf);
$this->add(new Submit('save', array(
'class' => 'btn btn-lg btn-primary btn-block',
)));
}
}
在这个表单中,你可能注意到一些新事物:
-
我们使用
Phalcon\Validation\Validator\StringLength来验证密码的长度。 -
我们使用一个新的表单元素
Phalcon\Forms\Element\Select来生成select表单元素。 -
我们使用
Phalcon\Validation\Validator\Email来验证电子邮件地址字段。 -
我们将
App\Core\Models\AclRoles的结果作为select元素user_acl_role的第二个参数。这个字段的第二个参数是一个数组,它指示Phalcon\Forms\Element\Select在生成 HTML 代码时使用字段名。通常,我们会使用字段的 ID 和名称,或者类似的东西。但在这个特定的情况下,acl_roles表没有 ID。
用户管理器
你可能已经有了用户管理器的一部分,或者可能已经完全创建。如果你还没有,现在就创建它。在modules/Core/Managers/中创建一个新文件,并将其命名为UserManager.php。然后,在它里面写入以下代码:
<?php
namespace App\Core\Managers;
use App\Core\Models\User;
use App\Core\Models\UserRole;
use App\Core\Models\AclRoles;
use App\Core\Models\UserProfile;
use App\Core\Forms\UserForm;
class UserManager extends BaseManager{
public function getForm($entity = null, $options = null) {
return new UserForm($entity, $options);
}
public function create($data, $user_role = 'Guest') {
$security = $this->getDI()->get('security');
if (isset($data['user_acl_role'])) {
$user_role = $data['user_acl_role'];
}
$user = new User();
$user->setUserFirstName($data['user_first_name']);
$user->setUserLastName($data['user_last_name']);
$user->setUserEmail($data['user_email']);
$user->setUserPassword($security->hash($data['user_password']));
$user->setUserIsActive($data['user_is_active']);
$o_acl_role = AclRoles::findFirstByName($user_role);
if (!$o_acl_role) {
throw new \Exception("Role $user_role does not exists");
};
$o_user_role[0] = new UserRole();
$o_user_role[0]->setUserRole($user_role);
$user->roles = $o_user_role;
$profile = new UserProfile();
$profile->setUserProfileLocation($data['user_profile_location']);
$user->profile = $profile;
return $this->save($user, 'create');
}
}
create()方法需要两个参数。第一个参数$data是一个包含创建我们新对象所需值的数组。第二个参数是$user_role,具有默认值。进一步来说,我们检查$data数组是否有一个名为user_acl_role的键。如果键存在,我们覆盖$user_role参数的默认值。最后,我们将值分配给每个$user对象并保存它们:
public function update(array $data) {
$object = User::findFirstById($data['id']);
if (!$object) {
throw new \Exception('Object not found');
}
$security = $this->getDI()->get('security');
$object->setUserFirstName($data['user_first_name']);
$object->setUserLastName($data['user_last_name']);
$object->setUserEmail($data['user_email']);
$object->setUserPassword($security->hash($data['user_password']));
$object->setUserIsActive($data['user_is_active']);
$o_acl_role = AclRoles::findFirstByName($data['user_acl_role']);
if (!$o_acl_role) {
throw new \Exception("Role $user_role does not exists");
};
$o_user_role[0] = new UserRole();
$o_user_role[0]->setUserRole($data['user_acl_role']);
$object->roles = $o_user_role;
$object->profile->setUserProfileLocation($data['user_profile_location']);
return $this->save($object, 'update');
}
update()方法与create()方法类似,但首先检查我们想要更新的对象是否存在。以下所示的delete()方法将简单地通过 ID 搜索对象;如果对象存在,则将其删除:
public function delete($id) {
$object = User::findFirstById($id);
if (!$object) {
throw new \Exception('Object not found');
}
if (false === $object->delete()) {
foreach ($object->getMessages() as $message) {
$error[] = (string) $message;
}
throw new \Exception(json_encode($error));
}
return true;
}
注意
再次强调,find()、findFirstById()和findFirst()方法已被故意省略,但你可以在本章的源代码中找到它们。
让我们关注create()和update()方法以及我们如何存储配置文件和角色的关系。因为用户和角色之间的关系是1 - N,为了正确存储值,我们使用数组表示法为$o_user_role变量。否则,保存将失败。对于密码,我们利用 Phalcon 内置的安全模块,并使用$security->hash()方法进行加密。
用户模板
最后一步是创建模板。切换到modules/Backoffice/Views/Default并创建一个名为user的新目录。在这个新目录中,创建所需的四个文件:add.volt、delete.volt、edit.volt和list.volt。关于这些模板没有新的说明,所以我们只需写出它们的代码。
add.volt的代码如下:
{% extends 'layout.volt' %}
{% block body %}
<h1>Add</h1>
<hr>
<div class="panel panel-default">
<div class="panel-body">
<form method="post" action="{{ url('user/create') }}">
<h4>User details</h4>
<hr>
<div class="form-group">
<label for="user_first_name">First name</label>
{{ form.render('user_first_name', {'class':'form-control'}) }}
</div>
<div class="form-group">
<label for="user_last_name">Last name</label>
{{ form.render('user_last_name', {'class':'form-control'}) }}
</div>
<div class="form-group">
<label for="user_email">Email</label>
{{ form.render('user_email', {'class':'form-control'}) }}
</div>
<div class="form-group">
<label for="user_password">Password</label>
{{ form.render('user_password', {'class':'form-control'}) }}
</div>
<div class="form-group">
<label for="user_is_active">Is active</label>
{{ form.render('user_is_active', {'class':'form-control'}) }}
</div>
<h4>User profile</h4>
<hr>
<div class="form-group">
<label for="user_profile_location">Location</label>
{{ form.render('user_profile_location', {'class':'form-control'}) }}
</div>
<h4>User role</h4>
<hr>
<div class="form-group">
<label for="user_acl_role">Role</label>
{{ form.render('user_acl_role', {'class':'form-control'}) }}
</div>
{{ form.render('save', {'value':'Save'}) }}
{{ form.render('csrf', {'value':security.getToken()}) }}
</form>
</div>
</div>
{% endblock %}
下面是delete.volt的代码:
{% extends 'layout.volt' %}
{% block body %}
<h1>Confirm deletion</h1>
<h3>Are you sure you want to delete the selected element?</3>
<hr>
<div class="panel panel-default">
<div class="panel-body">
<form method="post" action="{{ url('user/delete/' ~ id) }}" class="form-inline">
<input type="submit" value="Yes, delete" class="btn btn-sm btn-danger btn-block">
<a href="{{ url('user/list') }}" class="btn btn-lg btn-default btn-block">Cancel</a>
</form>
</div>
</div>
{% endblock %}
edit.volt文件几乎与add.volt相同。只需替换form动作并将其指向user/update:
<form method="post" action="{{ url('user/update') }}">
list.volt的代码如下:
{% extends 'layout.volt' %}
{% block body %}
<div class="pull-left">
<h1>Users</h1>
</div>
<div class="pull-right">
<a class="btn btn-success" href="{{ url('user/add') }}" aria-label="Left Align">
<span class="glyphicon glyphicon-plus" aria-hidden="true"></span> New
</a>
</div>
<div class="clearfix"></div>
<hr>
<div class="panel panel-default">
<div class="panel-body">
<table class="table table-striped">
<thead>
<tr>
<th>#</th>
<th>Name</th>
<th>Email</th>
<th>Location</th>
<th>Created at</th>
<th>Options</th>
</tr>
</thead>
<tbody>
{% for record in records['items'] %}
<tr>
<th scope="row">{{ record['id'] }}</th>
<td>{{ record['user_first_name'] }} {{ record['user_last_name'] }}</td>
<td>{{ record['user_email'] }}</td>
<td>{{ record['user_profile']['user_profile_location'] }}</td>
<td>{{ record['user_created_at'] }}</td>
<td>
<a class="btn btn-default btn-xs" href="{{ url('user/edit/' ~ record['id']) }}" aria-label="Left Align">
<span class="glyphicon glyphicon-pencil" aria-hidden="true">
</span>
</a>
<a class="btn btn-danger btn-xs" href="{{ url('user/delete/' ~ record['id']) }}" aria-label="Left Align">
<span class="glyphicon glyphicon-trash" aria-hidden="true"></span>
</a>
</td>
</tr>
{% else %}
<tr>
<td colspan="4">There are no records in your database</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% if (records['total_pages'] > 1) %}
{% include 'common/paginator' with {'page_url' : url('user/list'), 'stack' : records} %}
{% endif %}
{% endblock %}
我们已经完成了用户 CRUD!你应该能够访问 Backoffice 中的Users部分(http://www.learning-phalcon.localhost/backoffice/user/list)并查看现有用户列表。现在我们已经为添加文章所需的所有部分启用了 CRUD,我们将继续本章的最后一部分——文章 CRUD。
文章 CRUD
我们为这部分部分地编写了一些代码。它可能对你来说已经可以工作,但你将主要更改其中的大部分。API 控制器已经开发完成,因此我们可以直接进入ArticleManager进行重构。
控制器(API)
这个控制器的代码与其它控制器的代码类似。让我们看看它是什么样子。打开位于modules/Api/Controllers/ArticlesController.php的文件,清空其内容,并写入以下代码:
<?php
namespace App\Api\Controllers;
class ArticlesController extends BaseController{
public function updateAction($id) {
try {
$manager = $this->getDI()->get('core_article_manager');
if ($this->request->getHeader('CONTENT_TYPE') == ' application/json') {
$data = $this->request->getJsonRawBody(true);
} else {
$data = $this->request->getPut();
}
if (count($data) == 0) {
throw new \Exception('Please provide data', 400);
}
$st_inputData = array(
'article_user_id' => $data['article_user_id'],
'article_is_published' => $data['article_is_published'],
'translations' => [
$data['article_translation_lang'] => [
'article_translation_short_title' =>
$data['article_translation_short_title'],
'article_translation_long_title' =>
$data['article_translation_long_title'],
'article_translation_description' =>
$data['article_translation_description'],
'article_translation_slug' => $data[
'article_translation_slug'],
'article_translation_lang' => $data[
'article_translation_lang'],
],
],
'categories' => $data['categories'],
'hashtags' => $data['hashtags']
);
$result = $manager->restUpdate(array_merge(
$st_inputData, ['id' => $id]));
return $this->render($result);
} catch (\Exception $e) {
return $this->render([
'code' => $e->getCode(),
'message' => $e->getMessage(),
], $e->getCode());
}
}
public function createAction() {
try {
$manager = $this->getDI()->get('core_article_manager');
if ($this->request->getHeader('CONTENT_TYPE') ==
'application/json') {
$data = $this->request->getJsonRawBody(true);
} else {
$data = $this->request->getPost();
}
if (count($data) == 0) {
throw new \Exception('Please provide data', 400);
}
$st_inputData = array(
'article_user_id' => $data['article_user_id'],
'article_is_published' => $data['article_is_published'],
'translations' => [
$data['article_translation_lang'] => [
'article_translation_short_title' =>
$data['article_translation_short_title'],
'article_translation_long_title' =>
$data['article_translation_long_title'],
'article_translation_description' =>
$data['article_translation_description'],
'article_translation_slug' =>
$data['article_translation_slug'],
'article_translation_lang' =>
$data['article_translation_lang'],
],
],
'categories' => $data['categories'],
'hashtags' => $data['hashtags']
);
$st_output = $manager->restCreate($st_inputData);
return $this->render($st_output);
} catch (\Exception $e) {
return $this->render([
'code' => $e->getCode(),
'message' => $e->getMessage(),
], $e->getCode());
}
}
}
在此控制器中需要注意的唯一重要事项是我们期望 createAction() 和 updateAction() 的数据结构。让我们继续下一个控制器。
注意
addAction()、deleteAction() 和 listAction() 方法被有意地省略了,但您可以在本章的源代码中找到它们。
后台模块的文章控制器
切换到 modules/Backoffice/Controllers/ 文件夹,创建一个名为 ArticleController.php 的新文件,并写入以下代码:
<?php
namespace App\Backoffice\Controllers;
class ArticleController extends BaseController {
public function createAction() {
if (!$this - > request - > isPost()) {
return $this - > response - > redirect('article/list');
}
$manager = $this - > getDI() - > get('core_article_manager');
$form = $manager - > getForm();
if ($form - > isValid($this - > request - > getPost())) {
try {
$manager = $this - > getDI() - > get('core_article_manager');
$post_data = $this - > request - > getPost();
$data = array_merge($post_data,
['article_user_id ' => $this->auth->getUserId()]);
$manager - > create($data);
$this - > flashSession - > success('Object was created
successfully ');
return $this - > response - > redirect('article/list');
} catch (\Exception $e) {
$this - > flash - > error($e - > getMessage());
return $this - > dispatcher - > forward(['action' =>
'add'
]);
}
} else {
foreach($form - > getMessages() as $message) {
$this - > flash - > error($message - > getMessage());
}
return $this - > dispatcher - > forward(['action' => 'add',
'controller' => 'article'
]);
}
}
public function updateAction() {
if (!$this - > request - > isPost()) {
return $this - > response - > redirect('article/list');
}
$manager = $this - > getDI() - > get('core_article_manager');
$object_id = $this - > persistent - > get('id');
$object = $manager - > findFirstById($object_id);
$form = $manager - > getForm($object);
if ($form - > isValid($this - > request - > getPost())) {
try {
$manager = $this - > getDI() - > get('core_article_manager ');
$post_data = $this - > request - > getPost();
$data = array_merge(
$post_data, ['article_user_id' => $this - > auth - > getUserId(), 'id' => $object_id]);
$manager - > update($data);
$this - > flashSession - > success('Object was updated successfully ');
return $this - > response - > redirect('article/list');
} catch (\Exception $e) {
$this - > flash - > error($e - > getMessage());
return $this - > dispatcher - > forward(['action' =>
'edit'
]);
}
} else {
foreach($form - > getMessages() as $message) {
$this - > flash - > error($message - > getMessage());
}
return $this - > dispatcher - > forward(['action' => 'edit',
'controller' => 'category'
]);
}
}
}
查看一下 createAction() 和 updateAction()。在这里,当我们设置 article_user_id 字段的值时,我们使用认证用户的 ID。
注意
再次,addAction()、deleteAction() 和 listAction() 等方法被有意地省略了,但您可以在本章的源代码中找到它们。
文章表单
此表单与分类表单类似。让我们看看它的样子。在 modules/Core/Forms 目录中创建一个名为 ArticleForm.php 的新文件,并将此代码写入其中:
<?php
namespace App\Core\Forms;
use Phalcon\Forms\Form;
use Phalcon\Forms\Element\Text;
use Phalcon\Forms\Element\TextArea;
use Phalcon\Forms\Element\Select;
use Phalcon\Forms\Element\Submit;
use Phalcon\Forms\Element\Hidden;
use Phalcon\Validation\Validator\Identical;
use App\Core\Models\CategoryTranslation;
use App\Core\Models\Hashtag;
class ArticleForm extends Form {
private $edit = false;
public function initialize($entity = null, $options = null) {
if (isset($options['edit']) && $options['edit'] === true) {
$this->edit = true;
}
$locales = $this->getDI()->get('config')->i18n->locales->
toArray();
foreach ($locales as $locale => $name) {
if (true === $this->edit) {
$translations = $entity->getTranslations([
"article_translation_lang = '$locale'"])->toArray();
}
$article_translation_short_title[$locale] = new Text
("translations[$locale][article_translation_short_title]", [
'value' => $this->edit === true ? $translations[0]
['article_translation_short_title'] : null
]);
$article_translation_long_title[$locale] = new Text
("translations[$locale][article_translation_long_title]", [
'value' => $this->edit === true ? $translations[0]
['article_translation_long_title'] : null
]);
$article_translation_description[$locale] = new TextArea
("translations[$locale][article_translation_description]", [
'value' => $this->edit === true ? $translations[0]
['article_translation_description'] : null
]);
$article_translation_slug[$locale] = new Text (
"translations[$locale][article_translation_slug]", [
'value' => $this->edit === true ? $translations[0]
['article_translation_slug'] : null
]);
$article_translation_lang[$locale] = new Hidden (
"translations[$locale][article_translation_lang]", [
'value' => $locale
]);
$this->add( $article_translation_short_title[$locale] );
$this->add( $article_translation_long_title[$locale] );
$this->add( $article_translation_description[$locale] );
$this->add( $article_translation_slug[$locale] );
$this->add( $article_translation_lang[$locale] );
}
// Categories
$categories = new Select('categories[]',
CategoryTranslation::find([
"category_translation_lang = 'en'"]), [
'using' => [
'category_translation_category_id',
'category_translation_name'
],
'multiple' => true
]);
if ($this->edit === true) {
$categories_defaults = array();
foreach ($entity->getCategories(["columns" =>
["id"]]) as $category) {
$categories_defaults[] = $category->id;
}
$categories->setDefault($categories_defaults);
}
$this->add($categories);
// Hash tags
$hashtags = new Select('hashtags[]', Hashtag::find(), [
'using' => ['id', 'hashtag_name'],
'multiple' => true
]);
if ($this->edit === true) {
$hashtags_defaults = array();
foreach ($entity->getHashtags(["columns" =>
["id"]]) as $hashtag) {
$hashtags_defaults[] = $hashtag->id;
}
$hashtags->setDefault($hashtags_defaults);
}
$this->add($hashtags);
// Is published
$this->add(new Select('article_is_published', array(
1 => 'Yes',
0 => 'No'
)));
//CSRF
$csrf = new Hidden('csrf');
$csrf->addValidator(
new Identical(array(
'value' => $this->security->getSessionToken(),
'message' => 'CSRF validation failed',
))
);
$this->add($csrf);
$this->add(new Submit('save', array(
'class' => 'btn btn-lg btn-primary btn-block',
)));
}
}
我们以与分类相同的方式管理文章翻译。至于文章标签和文章分类,当我们编辑记录时,我们必须以某种方式检索现有的标签和分类,并将它们作为表单的默认值分配。
我们已经创建了控制器、管理器和表单。我们现在需要的是模板。切换到 modules/Backoffice/Views/Default/article/,并创建三个缺失的文件:add.volt、delete.volt 和 edit.volt。以下是每个文件的代码。
add.volt 的代码如下:
{% extends 'layout.volt' %}
{% block body %}
<h1>Add</h1>
<hr>
<div class="panel panel-default">
<div class="panel-body">
<form method="post" action="{{ url('article/create') }}">
{% for locale, name in locales %}
<h3>Article ({{ name }})</h3>
<hr>
<div class="form-group">
<label for="article_translation_short_title">Title
</label>
{{ form.render('translations['~locale~']
[article_translation_short_title]', {'class':'form-control'}) }}
</div>
<div class="form-group">
<label for="article_translation_long_title">
Long title</label>
{{ form.render('translations['~locale~']
[article_translation_long_title]',
{'class':'form-control'}) }}
</div>
<div class="form-group">
<label for="article_translation_description">Description
</label>
{{ form.render('translations['~locale~']
[article_translation_description]',
{'class':'form-control', 'rows': 8}) }}
</div>
<div class="form-group">
<label for="article_translation_slug">Slug
</label>
{{ form.render('translations['~locale~']
[article_translation_slug]',
{'class':'form-control'}) }}
</div>
{{ form.render('translations['~locale~']
[article_translation_lang]') }}
{% endfor %}
<div class="form-group">
<label for="article_is_published">Is published
</label>
{{form.render('article_is_published',
{'class':'formcontrol'}) }}
</div>
<h3>Categories</h3>
<hr>
<div class="form-group">
<label for="categories">Select one or more
categories</label>
{{ form.render('categories[]', {'class':'formcontrol'}) }}
</div>
<h3>Hash tags</h3>
<hr>
<div class="form-group">
<label for="hashtags">Select one or more hash tags
</label>
{{form.render('hashtags[]',
{'class':'form-control'})}}
</div>
{{form.render('save', {'value':'Save'}) }}
{{form.render('csrf', {'value':security.getToken()}) }}
</form>
</div>
</div>
{% endblock %}
创建此文件后,尝试访问 http://www.learning-phalcon.localhost/backoffice/article/add。您应该会看到表单。
edit.volt 中的代码与 add.volt 中的代码相同。复制它,并将其表单动作更改为 article/update 而不是 article/create。
delete.volt 文件与迄今为止我们创建的所有 delete.volt 文件内容相同。只需从其中任何一个复制内容,并将 links 动作更改为指向 article/delete。
我们已经创建了 list.volt 文件,但我们需要删除其内容,并在其中写入以下代码:
{% extends 'layout.volt' %} {% block body %}
<div class="pull-left">
<h1>Articles</h1>
</div>
<div class="pull-right">
<a class="btn btn-success" href="{{ url('article/add') }}" aria-label="Left Align">
<span class="glyphicon glyphicon-plus" aria-hidden="true"></span> New
</a>
</div>
<div class="clearfix"></div>
<hr>
<div class="table-responsive">
<table class="table table-striped">
<thead>
<tr>
<th>#</th>
<th>Title</th>
<th>Is published</th>
<th>Author</th>
<th>Created at</th>
<th>Options</th>
</tr>
</thead>
<tbody>
{% for record in records['items'] %}
<tr>
<td>{{record['id'] }}</td>
<td>{{record['article_translations'][0]
['article_translation_short_title'] }}</td>
<td>{{record['article_is_published'] }}</td>
<td>{{record['article_author']['user_first_name']}}
{{record['article_author']['user_last_name']}}
</td>
<td>{{ record['article_created_at'] }}</td>
<td>
<a class="btn btn-default btn-xs"
href="{{url('article/edit/' ~ record['id']) }}"
aria-label="Left Align">
<span class="glyphicon glyphicon-pencil"
ariahidden="true"></span>
</a>
<a class="btn btn-danger btn-xs"
href="{{url('article/delete/' ~ record['id']) }}"
aria-label="Left Align">
<span class="glyphicon glyphicon-trash"
ariahidden="true"></span>
</a>
</td>
</tr>
{% else %}
<tr>
<td colspan="4">There are no records in your
database</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% if (records['total_pages'] > 1) %}
{% include 'common/paginator' with {'page_url' : url('article/list'), 'stack' : records} %}
{% endif %}
{% endblock %}
到目前为止,您应该已经拥有了一个完全功能的管理区域。我们将在几分钟内结束本章,但在那之前,我们将稍微美化一下用户界面(UI)。让我们通过将认证用户的名称添加到页面顶部开始这个过程。
打开 modules/Backoffice/Controller/BaseControllers.php 文件,并将以下代码追加到 afterExecuteRoute() 方法中:
$this->view->identity = $this->getDI()->get('auth')->getIdentity();
这样,我们将认证用户的身份分配给视图。接下来,打开 modules/Backoffice/Views/Default/common/topbar.volt 模板文件,并在 "Sign out" <li> 标签之前追加以下代码:
<li class="disabled"><a href="#">Welcome, {{ identity['name'] }}</a></li>
您现在可以刷新页面,应该会看到认证用户的名称,如图所示:

接下来,我们不再有一个默认的空白页面,而是将其转换成一个简单的仪表板。打开 modules/Backoffice/Controller/IndexController.php 并修改 indexAction() 方法,如下所示:
public function indexAction() {
$total_articles = $this->getDI()->get('core_article_manager')->find()->count();
$total_users = $this->getDI()->get('core_user_manager')->find()->count();
$total_categories = $this->getDI()->get('core_category_manager')->find()->count();
$total_hashtags = $this->getDI()->get('core_hashtag_manager')->find()->count();
$this->view->setVar('dashboard', [
'total_articles' => $total_articles,
'total_users' => $total_users,
'total_categories' => $total_categories,
'total_hashtags' => $total_hashtags,
]);
}
如您所见,我们只是简单地计算文章、用户、标签和分类的总数。modules/Backoffice/Views/Default/index/index.volt 的模板代码可以看起来像这样:
{% extends 'layout.volt' %}
{% block body %}
<div class="row">
<div class="col-md-6 col-xs-6 text-center">
<h1>{{ dashboard['total_articles'] }}
<span class="glyphicon glyphicon-align-justify">
</span>
</h1>
<small>Articles</small>
</div>
<div class="col-md-6 col-xs-6 text-center">
<h1>{{ dashboard['total_categories'] }}
<span class="glyphicon glyphicon-th">
</span>
</h1>
<small>Categories</small>
</div>
</div>
<div class="row">
<div class="col-md-6 col-xs-6 text-center">
<h1>{{ dashboard['total_hashtags'] }}
<span class="glyphicon glyphicon-tag">
</span></h1>
<small>Tags</small>
</div>
<div class="col-md-6 col-xs-6 text-center">
<h1>{{ dashboard['total_users'] }}
<span class="glyphicon glyphicon-user">
</span>
</h1>
<small>Users</small>
</div>
</div>
{% endblock %}
如果你刷新页面,你应该能够看到这个简单仪表板的结果,如下所示:

文章管理器
打开位于 modules/Core/Manager/ArticleManager.php 的文件,清空其内容,并写入以下代码:
<?php
namespace App\Core\Managers;
use App\Core\Models\Article;
use App\Core\Models\ArticleTranslation;
use App\Core\Models\ArticleCategoryArticle;
use App\Core\Models\ArticleHashtagArticle;
use App\Core\Models\Category;
use App\Core\Models\Hashtag;
use App\Core\Models\User;
在这些第一行中,我们插入所有我们需要用于 CRUD 操作的文件:
class ArticleManager extends BaseManager
{
private $default_data = array(
'article_user_id' => 1,
'article_is_published' => 0,
'translations' => array(
'en' => array(
'article_translation_short_title' => 'Short title',
'article_translation_long_title' => 'Long title',
'article_translation_description' => 'Description',
'article_translation_slug' => '',
'article_translation_lang' => 'en',
),
),
'categories' => array(),
'hashtags' => array()
);
我们将 $default_data 添加为一个私有变量以避免代码重复。我们将为 create() 和 update() 方法使用它:
public function getForm($entity = null, $options = null)
{
return new ArticleForm($entity, $options);
}
public function create($input_data)
{
$data = $this->prepareData($input_data);
$article = new Article();
$article->setArticleIsPublished($data[
'article_is_published']);
$articleTranslations = array();
foreach ($data['translations'] as $lang => $translation) {
$tmp = new ArticleTranslation();
$tmp->assign($translation);
array_push($articleTranslations, $tmp);
}
if (count($data['categories']) > 0) {
$article->categories = Category::find([
"id IN (".implode(',', $data['categories']).")",
])->filter(function ($category) {
return $category;
});
}
if (count($data['hashtags']) > 0) {
$article->hashtags = Hashtag::find([
"id IN (".implode(',', $data['hashtags']).")",
])->filter(function ($hashtag) {
return $hashtag;
});
}
$user = User::findFirstById((int) $data['article_user_id']);
if (!$user) {
throw new \Exception('User not found', 404);
}
$article->setArticleUserId($data['article_user_id']);
$article->translations = $articleTranslations;
return $this->save($article, 'create');
}
让我们尝试理解 create() 方法。首先,我们调用 prepareData() 方法。这是一个辅助方法,我们也在 update() 中使用它。接下来,我们初始化一个新的文章对象,并设置 article_is_published 字段的标志。文章需要翻译和标签,我们必须为它分配一个用户。我们通过为每个翻译和标签初始化一个新的对象来完成这项工作。在用户的情况下,我们需要检查用户是否存在于我们的数据库中:
public function update($input_data)
{
$article = Article::findFirstById($input_data['id']);
if (!$article) {
throw new \Exception('Article not found', 404);
}
$data = $this->prepareData($input_data);
$article->setArticleIsPublished($data['article_is_published']);
$article->setArticleUpdatedAt(new \Phalcon\Db\RawValue('NOW()'));
foreach ($data['translations'] as $lang => $translation) {
$article->getTranslations()->filter(function($t) use($lang, $translation){
if ($t->getArticleTranslationLang() == $lang) {
$t->assign($translation);
$t->update();
}
});
}
$results = ArticleCategoryArticle::findByArticleId($input_data['id']);
if ($results) {
$results->delete();
}
if (count($data['categories']) > 0) {
$article->categories = Category::find([
"id IN (".implode(',', $data['categories']).")",])->filter(function ($category) {
return $category;
});
}
$results = ArticleHashtagArticle::findByArticleId(
$input_data['id']);
if ($results) {
$results->delete();
}
if (count($data['hashtags']) > 0) {
$article->hashtags = Hashtag::find([
"id IN (".implode(',', $data['hashtags']).")",
])->filter(function ($hashtag) {
return $hashtag;
});
}
$user = User::findFirstById((int) $data['article_user_id']);
if (!$user) {
throw new \Exception('User not found', 404);
}
$article->setArticleUserId($data['article_user_id']);
return $this->save($article, 'update');
}
在前面的代码中,update() 方法遵循与 create() 方法相同的逻辑。但在接下来的代码中,我们首先需要删除现有的标签和分类的关系,并创建新的关系。此方法还会检查文章是否存在于我们的数据库中:
public function delete($id)
{
$article = Article::findFirstById($id);
if (!$article) {
throw new \Exception('Article not found', 404);
}
if (false === $article->delete()) {
foreach ($article->getMessages() as $message) {
$error[] = (string) $message;
}
throw new \Exception(json_encode($error));
}
return true;
}
private function prepareData($input_data)
{
$data = array_merge($this->default_data, $input_data);
if (!is_array($data['categories'])) {
$data['categories'] = $data['categories'] != '' ?
array_map('trim', explode(',', $data['categories'])) : null;
} else {
$data['categories'] = implode(',', $data['categories']);
}
if (!is_array($data['hashtags'])) {
$data['hashtags'] = $data['hashtags'] != '' ?
array_map('trim', explode(',', $data['hashtags'])) : null;
} else {
$data['hashtags'] = implode(',', $data['hashtags']);
}
return $data;
}
}
prepareData() 方法是一个辅助方法,它将帮助我们避免在 update() 和 create() 方法中的代码重复。
看一下 create() 和 update() 方法。我们期望分类和标签是 ID 的逗号分隔值。如果这些字段包含值,我们使用 array_map() 方法并对每个 ID 应用修剪操作。在 update() 的情况下,我们总是删除现有的标签和分类,然后再次添加(或添加新的)。我之所以使用这种方法,是因为 Phalcon 的 ORM 不会自动执行此操作。
注意
重要提示
在官方文档中,它说你可以用这种方式删除相关记录:
$robots->getParts()->delete();
当使用多对多关系时,就像我们的情况一样,如果你为分类或标签执行前面的代码,你最终只会删除标签和分类。这不会从中间模型中删除关系。此外,还有一个用于更新相关记录的方法,但由于某些奇怪的功能,它不再被支持,但它仍然可以在官方文档中找到。不要使用它:
$robots->getParts()->update($data, function($part) {
if ($part->type == Part::TYPE_BASIC) {
return false;
}
return true;
});
摘要
我们终于完成了这个模块。一般来说,编写代码的方法是无限的。在本章中,我使用了一种我认为容易理解的方法。请随意不同,用你喜欢的方式编码。这本书的目的不是教你编程,而是教你 Phalcon。你可能已经注意到,对于 API,我们没有使用任何验证。你可以稍微练习一下,并将你的表单连接到 API。
在下一章中,我们将切换到前端模块,在那里我们将对 API 进行一些小的修改。我们还将尝试基于 Elasticsearch(www.elastic.co/products/elasticsearch)实现一个搜索引擎。
第九章. 前端模块
开发前端可能是一项艰巨的工作。您必须考虑各种方面,例如用户体验(UX)、搜索引擎优化(SEO)、浏览器兼容性、移动响应性等。我们将专注于创建最小布局并实现 Elasticsearch。我们还将使用 MongoDB 为文章创建一些日志。我们将逐步在本章中涵盖以下主题:
-
前端布局和基本功能
-
实现 Elasticsearch
-
实现 MongoDB
前端布局和基本功能
我们将使用一个简单的布局来构建前端模块。切换到modules/Frontend/Views/Default/common文件夹,并创建footer.volt、paginator.volt和navbar.volt文件,内容如下。
footer.volt
footer.volt文件没有包含太多信息,但将来您肯定希望添加更多信息,例如链接、合作伙伴、分析脚本等:
<footer class="lp-footer">
<p>Learning Phalcon</p>
<p>
<a href="#">Back to top</a>
</p>
</footer>
paginator.volt
paginator.volt文件包含两个简单的链接:上一页和下一页。您可以修改这些链接并创建一个更复杂的分页器,如果需要的话:
<nav>
<ul class="pager">
<li><a href="?p={{ records['before'] }}">Previous</a></li>
<li><a href="?p={{ records['next'] }}">Next</a></li>
</ul>
</nav>
navbar.volt
navbar.volt文件包含指向我们主页和所有可用类别的链接。我们将在本章的后面将类别分配给视图。
代码如下:
<div class="lp-masthead">
<div class="container">
<nav class="lp-nav">
<a class="lp-nav-item active" href="{{ url('') }}">Home</a>
{% for category in categories['items'] %}
<a class="lp-nav-item" href="{{ url('categories/' ~ category['category_translations'][0]['category_translation_slug']) }}">{{ category['category_translations'][0]['category_translation_name']}}</a>
{% endfor %}
</nav>
</div>
</div>
layout.volt
让我们继续到layout.volt。在modules/Frontend/Views/Default/文件夹中已经有一个文件。我们在第二章中创建了它,设置项目 MVC 结构和环境。清空其内容并添加以下内容:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block pageTitle %}Learning Phalcon{% endblock %}</title>
{{ stylesheetLink('../assets/default/bower_components/bootstrap/dist/css/bootstrap.min.css') }}
{{ stylesheetLink('../assets/default/css/lp.css') }}
<!--[if lt IE 9]>
<script src="img/html5shiv.min.js">
</script>
<script src="img/respond.min.js">
</script>
<![endif]-->
</head>
<body>
{% block navbar %}
{% include 'common/navbar.volt' %}
{% endblock %}
<div class="container">
<div class="lp-header">
<h1 class="lp-title">Learning Phalcon</h1>
<p class="lead lp-description">The fastest PHP Framework</p>
</div>
<div class="row">
<div class="col-sm-12 lp-main">
{% block body %}
{% endblock %}
</div>
</div>
</div>
{% block footer %}
{% include 'common/footer.volt' %}
{% endblock %}
{{ javascriptInclude("../assets/default/bower_components/jquery/dist/jquery.min.js") }}
{{ javascriptInclude("../assets/default/bower_components/bootstrap/dist/js/bootstrap.min.js") }}
{{ javascriptInclude("../assets/default/js/lp.js") }}
{% block javascripts %} {% endblock %}
</body>
</html>
注意,在这里我们使用了javascriptInclude()和stylesheetLink()方法,这些方法在 Volt 中默认可用。如果您愿意,可以使用我们为 Backoffice 模块所用的资产管理器。我们还需要一个简单的 CSS 文件。您应该在public/assets/default/css/文件夹中已经有一个名为lp.css的文件。清空其内容并添加以下内容:
@import url(http://fonts.googleapis.com/css?family=News+Cycle:400,700);
body { font-family: "News Cycle"; color: #555; }
h1, .h1, h2, .h2, h3, .h3, h4, .h4, h5, .h5, h6, .h6 {
margin-top: 0; font-family: "News Cycle"; font-weight: normal; color: #333;
}
.container {
width: 720px;
}
.lp-masthead { background-color: #356aa0; -webkit-box-shadow: inset 0 -2px 5px rgba(0, 0, 0, .1); box-shadow: inset 0 -2px 5px rgba(0, 0, 0, .1); }
.lp-nav-item { position: relative; display: inline-block; padding: 10px; font-weight: 500; color: #cdddeb; }
.lp-nav-item:hover,
.lp-nav-item:focus {
color: #fff; text-decoration: none;
}
.lp-nav .active { color: #fff; }
.lp-nav .active:after { position: absolute; bottom: 0; left: 50%; width: 0; height: 0; margin-left: -5px; vertical-align: middle; content: " "; border-right: 5px solid transparent; border-bottom: 5px solid; border-left: 5px solid transparent; }
.lp-header { padding-top: 20px; padding-bottom: 20px; }
.lp-title { margin-top: 30px; margin-bottom: 0; font-size: 30px; font-weight: normal; }
.lp-description { font-size: 16px; color: #999; }
.lp-main { font-size: 13px; line-height: 1.5; }
.pager { margin-bottom: 60px; text-align: left; }
.pager>li>a { width: 140px; padding: 10px 20px; text-align: center; border-radius: 30px; }
.lp-post { margin-bottom: 60px; }
.lp-post-title { margin-bottom: 5px; font-size: 40px; }
.lp-post-meta { margin-bottom: 20px; color: #999; }
.lp-footer { padding: 40px 0; color: #999; text-align: center; background-color: #f9f9f9; border-top: 1px solid #e5e5e5; }
.lp-footer p:last-child { margin-bottom: 0; }
修改 BaseController.php
现在,我们应该修改Frontend模块中的BaseController.php,以便扩展核心模块,并在每次请求时将类别全局分配给我们的视图。打开modules/Frontend/Controllers/BaseController.php,清空其内容,并附加以下代码:
<?php
namespace App\Frontend\Controllers;
class BaseController extends \App\Core\Controllers\BaseController
{
public function afterExecuteRoute()
{
$this->view->categories = $this->apiGet('categories');
}
}
我们实际上没有主页(但我们可以在任何时候添加一个),所以我们将请求转发到ArticlesController。打开modules/Frontend/Controllers/IndexController.php,删除indexAction(),并附加以下代码:
public function indexAction()
{
return $this->dispatcher->forward([
'controller' => 'article',
'action' => 'list'
]);
}
最后一步是创建文章的listAction()方法和视图。首先,在modules/Frontend/Views/Default/article/common/文件夹中创建一个名为list.item.volt的新文件,并添加以下内容:
{% for record in records['items'] %}
{% if (record['article_is_published'] == 1) %}
<div class="lp-post">
<h2 class="lp-post-title">{{ record['article_translations'][0]
['article_translation_short_title'] }}</h2>
<p class="lp-post-meta">
{{ record['article_created_at']|date("d M Y") }} by
<a href="#">
{{ record['article_author']['user_first_name']}}
{{ record['article_author']['user_last_name']}}
</a></p>
<p>
{{ record['article_translations'][0]
['article_translation_long_title'] }}
<a href="{{ url('article/' ~ record['article_translations'][0]
['article_translation_slug']) }}">Read more</a>
</p>
</div>
{% endif %}
{% endfor %}
我们还需要修改布局 modules/Frontend/Views/Default/article/list.volt。打开此文件,清除其内容,并追加以下代码:
{% extends 'layout.volt' %}
{% block body %}
{% include 'article/common/list.item' with {'records':records} %}
{% if records['total_items'] > 2 %}
{% include 'common/paginator' with {'records':records} %}
{% endif %}
{% endblock %}
你可以看到,只有当我们有超过两条记录时,我们才会显示 paginator(你可以随时更改此设置)。这与 ArticleController 的 listAction() 方法的 $limit 参数相关。打开 modules/Frontend/Controllers/ArticleController.php,并向其中追加以下代码:
<?php
namespace App\Frontend\Controllers;
class ArticleController extends BaseController{
public function listAction() {
$page = $this->request->getQuery('p', 'int', 1);
try {
$records = $this->apiGet('articles',['p' => $page, 'limit' => 2]);
$this->view->records = $records;
} catch (\Exception $e) {
$this->flash->error($e->getMessage());
}
}
}
基本上,这部分工作已经完成。你现在可以打开 http://www.learning-phalcon.localhost/,你应该会看到类似于以下截图的内容:

接下来,我们将对文章控制器进行一些修改,以便通过别名获取文章。我个人喜欢尽可能地将事物分开,以防将来需要实现复杂的逻辑。我们将在 API (ArticlesController) 中创建一个名为 getBySlugAction() 的新方法。别名是一个用于 SEO(搜索引擎优化)目的的友好 URL。打开 modules/Api/Controllers/ArticlesController.php 并追加以下代码:
public function getBySlugAction($slug) {
try {
$manager = $this->getDI()->get('core_article_manager');
$st_output = $manager->restGet([
'article_translation_slug = :article_translation_slug:',
'bind' => [
'article_translation_slug' => $slug,
],
]);
return $this->render($st_output);
} catch (\Exception $e) {
return $this->render([
'code' => $e->getCode(),
'message' => $e->getMessage(),
], $e->getCode());
}
}
此方法与 getAction() 类似。我们正在通过别名进行搜索,因此我们需要修改 ArticleManager.php 中的 find() 方法。我们新的 find() 方法将如下所示:
public function find($parameters = null) {
if (isset($parameters['bind']['article_translation_slug'])) {
$translation = ArticleTranslation::findFirst($parameters);
if ($translation->count() !== 1) {
return [$translation->getArticle()->toArray()];
} else {
throw new \Exception('Article not found', 404);
}
} elseif (isset($parameters['bind']['category_translation_slug'])) {
$category_translation = CategoryTranslation::findFirst($parameters);
if ($category_translation->count() !== 1) {
return $category_translation->getCategory()->getArticles();
} else {
throw new \Exception('Article not found', 404);
}
} else {
return Article::find($parameters);
}
}
我们检查 article_translation_slug 参数是否已设置。如果已设置,则不是调用 Article::find() 方法,而是调用 ArticleTranslation::findFirst()。如果得到结果,我们将对象作为数组返回。当我们需要从某个类别检索文章时,我们应用相同的逻辑。除非我们也修改 BaseManager.php 中的 restGet() 方法,否则此代码将无法工作。我们当前的 restGet() 方法包含以下行:
$result = $objects->filter(function ($object) {
return $object->toArray();
});
将此行替换为以下代码:
if (is_array($objects)) {
$result = $objects;
} else {
$result = $objects->filter(function ($object) {
return $object->toArray();
});
}
此修改后的代码检查 $this->find() 的结果是否为数组。如果是,我们不需要过滤任何内容。现在,我们切换到 modules/Frontend/Controllers/ArticleController.php 并添加一个新方法。它将通过其别名获取文章:
public function readAction($slug) {
try {
$records = $this->apiGet("articles/slug/$slug");
$this->view->records = $records;
} catch (\Exception $e) {
$this->flash->error($e->getMessage());
}
}
我们缺少路由信息。我们需要为 Api 和 Frontend 模块添加路由。在 modules/Api/Config/routing.php(文章组)中添加以下行:
$articles->addGet('/slug/{slug}', ['action' => 'getBySlug']);
然后在 modules/Frontend/Config/routing.php 文件中,将最后一行路由代码替换为以下代码:
$router->add('#^/articles/([a-zA-Z0-9\-]+)[/]{0,1}$#', array(
'module' => 'frontend',
'controller' => 'article',
'action' => 'read',
'slug' => 1,
));
文章项目模板
我们还需要一个用于阅读文章的模板。切换到 modules/Frontend/Views/Default/article/common/,创建一个新文件,命名为 item.volt,并添加以下代码:
{% for record in records['items'] %}
{% if (record['article_is_published'] == 1) %}
<div class="lp-post">
<h2 class="lp-post-title">{{ record['article_translations'][0]
['article_translation_short_title'] }}</h2>
<p class="lp-post-meta">{{ record['article_created_at']|date(
"d M Y") }} by <a href="#">
{{ record['article_author']['user_first_name']}}
{{ record['article_author']['user_last_name'] }}</a></p>
<p>
<strong>{{ record['article_translations'][0]
['article_translation_long_title'] }}</strong>
</p>
<p>
{{ record['article_translations'][0]
['article_translation_description'] }}
</p>
</div>
{% endif %}
{% endfor %}
readAction() 的模板(modules/Frontend/Views/Default/article/read.volt)应该包含以下代码:
{% extends 'layout.volt' %}
{% block body %}
{% include 'article/common/item' with {'records':records} %}
{% endblock %}
就这样!你现在可以访问 http://www.learning-phalcon.localhost/。点击 阅读更多 链接,你应该会看到类似于以下的结果:

从类别检索文章
我们缺少从类别(顶部导航栏)检索文章的实现。我们需要按照以下步骤进行:
-
将
Api模块的路由信息添加到modules/Api/Config/routing.php中:$articles->addGet('/category/{slug}', ['action' => 'getByCategorySlug']); -
在
modules/Api/Controllers/ArticlesController.php中创建一个名为getByCategorySlugAction()的新方法:public function getByCategorySlugAction($slug) { try { $manager = $this->getDI()->get('core_article_manager'); $st_output = $manager->restGet([ 'category_translation_slug = :category_translation_slug:', 'bind' => [ 'category_translation_slug' => $slug, ], ]); return $this->render($st_output); } catch (\Exception $e) { return $this->render([ 'code' => $e->getCode(), 'message' => $e->getMessage(), ], $e->getCode()); } } -
将前端模块的路由信息添加到
modules/Frontend/Config/routing.php中:$router->add('#^/categories/([a-zA-Z0-9\-]+)[/]{0,1}$#', array( 'module' => 'frontend', 'controller' => 'article', 'action' => 'categories', 'slug' => 1, )); -
在
modules/Frontend/Controllers/ArticleController.php中创建一个名为categoriesAction()的新方法:public function categoriesAction($slug) { $this->view->pick('article/list'); try { $records = $this->apiGet("articles/category/$slug"); $this->view->records = $records; } catch (\Exception $e) { $this->flash->error($e->getMessage()); } }
小贴士
注意,我们在categoriesAction()中选择了文章和列表视图,因为没有必要重复代码;它与列出文章的代码相同。
现在我们有一个最小化、功能性的前端。我们可以浏览文章,从类别中获取文章,并阅读文章。我们不会进一步深入,因为事情可能会变得过于复杂。在本章中,我们只会添加一个功能,并通过在 Elasticsearch 中索引文章来提高速度。
如果您想练习更多,您可以实现一个简单的搜索表单来通过标题搜索文章,或者实现一个作者的个人页面。
实现 ElasticSearch
Elasticsearch(ES)是什么?简短的回答是:它是一个搜索服务器。根据维基百科,这是完整的定义:
Elasticsearch 是基于 Lucene 的搜索服务器。它提供了一个具有 RESTful Web 接口和无需模式 JSON 文档的分布式、多租户全文搜索引擎。Elasticsearch 是用 Java 开发的,并作为开源软件在 Apache 许可证下发布。Elasticsearch 是企业搜索引擎中第二受欢迎的搜索引擎。
如果您需要全文搜索、结构化数据的实时分析,或者两者的组合,Elasticsearch 是一个非常强大的工具,非常适合您。所有的大公司都在使用它。我们将使用 ES 在 MySQL 之前存储和搜索文章。这样我们将减少对 MySQL 的流量,并避免频繁查询它。我们不会详细讨论 ES,所以请花几分钟时间阅读有关其基本操作的www.elastic.co/guide/。
安装 ElasticSearch
有一个可下载的 APT 仓库。我们将执行以下步骤:
-
打开终端并输入以下命令:
$ wget -qO - https://packages.elasticsearch.org/GPG-KEY-elasticsearch | sudo apt-key add - $ sudo add-apt-repository "deb http://packages.elasticsearch.org/elasticsearch/1.4/debian stable main" $ sudo apt-get update && sudo apt-get install elasticsearch -
安装完成后,您可以通过执行以下命令来配置仓库以在启动时启动:
$ sudo update-rc.d elasticsearch defaults 95 10启动服务的命令如下:
$ sudo service elasticsearch start测试其是否正在运行的命令如下:
$ curl -X GET http://localhost:9200/您应该得到一个类似于以下的 JSON 响应:
{ "status" : 200, "name" : "Lord Pumpkin", "cluster_name" : "elasticsearch", "version" : { "number" : "1.4.4", "build_hash" : "c88f77ffc81301dfa9dfd81ca2232f09588bd512", "build_timestamp" : "2015-02-19T13:05:36Z", "build_snapshot" : false, "lucene_version" : "4.10.3" }, "tagline" : "You Know, for Search" } -
我们将需要一个客户端库来与之交互。幸运的是,有一个可用的。在终端中,我们切换到我们项目的根目录,并输入以下命令:
$ php composer.phar require "elasticsearch/elasticsearch":"1.3.3"
这将安装 PHP 客户端以及许多依赖项。这可能需要一些时间,所以请不要担心。接下来,我们将在项目中设置这个客户端。如果你没有 ES 的经验,请花 10 分钟阅读 PHP 客户端的文档,网址为www.elastic.co/guide/en/elasticsearch/client/php-api/current/index.html。
在 DI 中启用客户端
在使用 ES 客户端之前,我们需要在 DI 中启用它。打开config/services.php并添加以下代码:
$di['elastic'] = function() {
return new \Elasticsearch\Client();
};
索引(存储)文档
如果我们要索引文档,我们需要向我们的管理器添加一些方法。同时,我们还需要对数据类型进行一些修改。首先,我们将创建一个通用的方法来分页数组结果。打开modules/Core/Managers/ArticleManager.php并追加以下代码:
protected function paginate($data, $limit, $page)
{
$paginator = new \Phalcon\Paginator\Adapter\NativeArray(
array(
"data" => $data,
"limit"=> $show,
"page" => $page
)
);
$items = $paginator->getPaginate();
if ($items->total_items > 0) {
return $items;
}
return false;
}
我们创建一个方法,在将数据发送到 ES 索引之前应该规范化数据:
protected function esNormalize($article) {
$body = json_decode(json_encode($article->toArray(),
JSON_NUMERIC_CHECK), true);
$body['article_created_at'] = str_replace(' ', 'T',
$body['article_created_at']);
if ($body['article_updated_at'] != '') {
$body['article_updated_at'] = str_replace(' ', 'T',
$body['article_updated_at']);
} else {
$body['article_updated_at'] = $body['article_created_at'];
}
return $body;
}
json_encode和json_decode方法用于强制将只包含数字的字符串值转换为数值/整数值。我们还用T替换了 MySQL 中的日期和时间之间的空格。这种 ISO 格式被 ES 自动识别为日期,然后它会相应地设置字段类型。我们还强制article_updated_at字段获取有效的日期值。如果我们不这样做,我们将无法在特定时间间隔内搜索文章。接下来,我们将在同一管理器中创建一个方法,该方法将文章索引到 ES 中。在管理器中追加此代码:
public function esIndex($article) {
$elastic_manager = $this->getDI()->get('elastic');
$params = array();
$params['index'] = 'learningphalcon';
$params['type'] = 'article';
$params['id'] = 'article-' . $article->getId();
$params['body'] = $this->esNormalize($article);
$elastic_manager->index($params);
return true;
}
每次我们索引数据时,ES 都期望一个特定的格式。这个格式在esIndex()方法中表示。要比较参数与 MySQL 结构,你可以考虑以下内容:
-
index:数据库名 -
type:表名 -
id:ID(主键) -
body:一个名为 body 的表字段,其中包含一个 JSON 编码的数据库
esIndex()方法始终返回 true,但我们必须小心,并且始终使用try {},catch() {},因为esindex()可能会抛出异常。如果文章已经在 ES 索引中存在,它将被更新。让我们创建一个简单的任务,从 MySQL 检索所有文章并将它们索引到 ES 中。打开modules/Task/ArticlesTask.php并追加此代码:
public function esindexAction() {
$article_manager = $this->getDI()->get('core_article_manager');
foreach ($article_manager->find() as $article) {
try {
$article_manager->esindex($article);
$this->consoleLog("Article {$article->getId()} has been indexed");
} catch (\Exception $e) {
$this->consoleLog("Article {$article->getId()} has not been indexed. Reason: {$e->getMessage()}", "red");
}
}
}
确保数据库中有些文章。如果没有,请导航到后台办公室并添加一些。然后打开终端,切换到项目的根目录,并执行以下命令:
$ php modules/cli.php article esindex
你应该看到类似以下输出:

到目前为止,我们在 ES 中已索引了文章。每次我们从 MySQL 更新、添加或删除文章时,我们都必须在 ES 中反映这一动作。当我们添加文章时,我们已经在做这件事了。让我们为更新和删除实现它。
我们不需要为 ES 中的文章更新创建特殊的方法。只需提交相同文章的索引即可。ES 会通过 ID 找到它并自动更新。我们所需做的只是像为createAction()实现的功能一样实现这个功能。
让我们按照以下步骤进行:
-
打开
modules/Backoffice/Controllers/ArticleController.php。 -
前往
updateAction()方法:$this->persistent->set('es_add_to_index_id', $object_id); -
在以下行之后添加前面的代码:
$this->flashSession->success('Object was updated successfully'); -
我们需要修改
editAction()方法。移除当前的方法,并用这个方法替换它:public function editAction($id) { $manager = $this->getDI()-> get('core_article_manager'); $object = $manager->findFirstById($id); if (!$object) { $this->flashSession->error('Object not found'); return $this->response->redirect('article/list'); } if ($es_add_to_index_id = $this->persistent-> get('es_add_to_index_id')) { $article = $manager->findFirstByid( $es_add_to_index_id); try { $manager->esindex($article); } catch (\Exception $e) { $this->flash->error("Article was not added to ES index"); } } $this->persistent->set('id', $id); $this->view->form = $manager->getForm( $object,['edit' => true]); }
更新文章时,我们只需做这些。从现在起,无论你做出什么更改,这些更改都会在 ES 中反映出来。当我们从 MySQL 中删除文章时,我们也必须从 ES 中删除它。让我们在ArticleManager.php中创建一个简单的删除方法:
public function esdelete($article_id)
{
$elastic_manager = $this->getDI()->get('elastic');
$params['index'] = 'learningphalcon';
$params['type'] = 'article';
$params['id'] = 'article-'.(int)$article_id;
try {
$elastic_manager->delete($params);
} catch (\Exception $e) {
}
}
正如你所见,我们只需要提供三个键:index、type和id。然后我们调用delete()方法,如果找到了,文章就会被移除。最后一步是在删除文章时调用esdelete()。再次打开modules/Backoffice/Controllers/ArticleController.php,进入deleteAction(),在$manager->delete($id);之后添加$manager->esdelete($id);这一行。现在,当我们从 MySQL 中删除文章时,它们也会从 ES 中删除。
我们不会进一步深入 ES。你应该花些时间实现一个搜索表单来从 ES 检索文章。作为一个提示,这里有一个简单的通过分类 slug 搜索 ES 文章的方法:
public function elasticSearchByCategorySlug($categorySlug, $show, $page, $limit)
{
$elastic_manager = $this->getDI()->get('elastic');
$params['index'] = 'learningphalcon';
$params['type'] = 'article';
$params['body']['from'] = 0;
$params['body']['size'] = $limit;
$params['body']['query']['bool']['must'] = array(
array('match' => array('category_translation_slug' => $categorySlug))
);
$params['body']['sort'] = [
'post_id' => ['order' => 'desc']
];
$queryResponse = $elastic_manager->search($params);
foreach ($queryResponse['hits']['hits'] as $hit) {
$tmp['items'][] = $hit['_source'];
}
return $this->paginate($tmp['items'], $show, $page);
}
实现 MongoDB
在本节中,我们将实现一个简单的文章日志。当然,你可以让你的整个网站运行在 Mongo 上。它非常快,但就我个人而言,我不喜欢在大项目中使用它,因为 Mongo 可能会非常占用空间。为了获得一个整体的概念,过去我不得不为近 5000 个房产(公寓、别墅和房屋)的价格索引了 4 年,所需的大小大约是 50GB。在我目前的工作场所,我们已经将短信日志迁移到 Mongo,我们有近 300 万条短信日志,大约占用 20GB 的空间。对于一个相对较小的网站,MongoDB 是完美的,或者如果你知道空间不会成为问题,那就去试试吧。
我们在本节中不会介绍 Mongo,但会有一个示例展示如何使用 Phalcon 实现它。如果你对 Mongo 一无所知,请抽出一些时间阅读其基础知识,请参阅docs.mongodb.org/manual/。
话虽如此,让我们开始实现日志记录。我们要记录什么?文章 ID、用户 IP 地址、用户代理和时间戳。从这个基础上,你将能够显示文章被阅读的次数,并生成简单的报告。
Mongo 模型
切换到modules/Core/Models并创建一个名为 Mongo 的新文件夹。在这个新文件夹中,创建两个新的文件,代码如下。
modules/Core/Models/Mongo/BaseCollection.php
modules/Core/Models/Mongo/BaseCollection.php 文件是一个简单的基类,它扩展了 \Phalcon\Mvc\Collection。您可以在未来使用它来添加以下常见逻辑:
<?php
namespace App\Core\Models\Mongo;
class BaseCollection extends \Phalcon\Mvc\Collection
{
}
modules/Core/Models/Mongo/ArticleLog.php
这个类是我们 article_log 集合的模型,并且有两个重要的方法:log() 和 countVisits()。我们将使用它们来记录文章访问次数并计数:
<?php
namespace App\Core\Models\Mongo;
class ArticleLog extends BaseCollection
{
public $article_id;
public $client_ip;
public $user_agent;
public $timestamp;
public function getSource()
{
return 'article_log';
}
public function log($article_id, \Phalcon\Http\Request $request)
{
$log = new self();
$log->article_id = (int) $article_id;
$log->client_ip = $request->getClientAddress();
$log->user_agent = $request->getUserAgent();
$log->timestamp = time();
$log->save();
}
public function countVisits($article_id, $unique = false)
{
if (false === $unique) {
return $this->count(array(
array(
"article_id" => $article_id
)
));
} else {
$result = $this->getConnection()->command(
array(
'distinct' => 'article_log',
'key' => 'client_ip',
'query' => ['article_id' => $article_id],
)
);
return count($result['values']);
}
}
public function columnMap()
{
return [
'article_id' => 'article_id',
'client_ip' => 'client_ip',
'user_agent' => 'user_agent',
'timestamp' => 'timestamp',
];
}
}
log() 方法相当直接。我们给变量赋值,并将信息保存到 article_log 集合中。countVisits() 方法期望两个参数:$article_id 和 $unique。如果我们不想显示唯一访问次数,则此参数必须设置为 false(默认值),然后我们可以简单地使用内置的 count() 方法查询集合。如果我们需要只显示唯一访问次数(按 IP 地址唯一),则执行 MongoClient 中的 command() 动作,Phalcon 没有实现此方法。
让我们从核心模块的 ArticleManager.php 切换,并添加这两个方法,以便我们可以从 DI 中调用它们:
public function mongoLog($article_id, \Phalcon\Http\Request $request)
{
$log = new ArticleLog();
$log->log($article_id, $request);
}
public function countVisits($article_id, $unique = false)
{
$alog = new ArticleLog();
return $alog->countVisits($article_id, $unique);
}
现在,我们将修改 ArticleController.php 中的 readAction() 方法(前端模块)。删除当前的方法,并添加以下代码:
public function readAction($slug){
try {
$records = $this->apiGet("articles/slug/$slug");
$manager = $this->getDI()->get(
'core_article_manager');
$total_views = $manager->countVisits(
$records['items'][0]['id']);
$manager->mongoLog($records['items'][0]['id'],
$this->request);
$this->view->records = $records;
$this->view->total_views = $total_views;
} catch (\Exception $e) {
$this->flash->error($e->getMessage());
}
}
注意包含 $total_views = $manager->countVisits($records['items'][0]['id']); 的行——我们没有提供 $unique 参数。这意味着默认情况下,我们不会显示唯一访问量。如果您想显示它们,请像这样添加 true:
$total_views = $manager->countVisits($records['items'][0]['id'], true);
最后一步是对我们的模板进行一些小的修改。打开 modules/Frontend/Views/Default/article/read.volt 并将 total_views 参数添加到 include:
{% extends 'layout.volt' %}
{% block body %}
{% include 'article/common/item' with {'records':records, 'total_views' : total_views} %}
{% endblock %}
然后,打开 modules/Frontend/Views/Default/article/common/item.volt,清空其内容,并添加以下代码:
{% for record in records['items'] %}
{% if (record['article_is_published'] == 1) %}
<div class="lp-post">
<h2 class="lp-post-title">
{{ record['article_translations'][0]
['article_translation_short_title'] }}</h2>
<p class="lp-post-meta">
{{ record['article_created_at']|date("d M Y") }}
by <a href="#">
{{record['article_author']['user_first_name'] }}
{{ record['article_author']['user_last_name'] }}</a>
{% if dispatcher.getActionName() == 'read') %}
<span class="pull-right glyphicon glyphicon-eye-open">
{{ total_views }}
</span>
{% endif %}
</p>
<p>
<strong>{{ record['article_translations'][0]
['article_translation_long_title'] }}</strong>
</p>
<p>
{{ record['article_translations'][0]
['article_translation_description'] }}
</p>
</div>
{% endif %}
{% endfor %}
旧 item.volt 文件和新文件之间的区别在于 {% if dispatcher.getActionName() == 'read') %} 下的代码。我们只在 readAction() 中显示访问次数。
关于 MongoDB 和 Phalcon 的内容就这么多。Phalcon 的 ODM 功能与 ORM 功能类似,但并不那么高级。您可能会发现自己处于必须使用 MongoClient 从 PHP 中强制使用的情况。您可以在 docs.phalconphp.com/en/latest/reference/odm.html 上了解更多关于 ODM 的信息。
摘要
在本章中,您学习了关于 ElasticSearch 和 MongoDB 的一些新知识。我们创建了一个简单的前端模块,现在我们有一个简单、功能齐全的网站。
在下一章和最后一章中,我们将讨论之前章节中没有涉及的内容,例如上传图片和注释路由器。
第十章. 进一步探索
在本章中,我们将尝试涵盖一些在这本书中没有使用的内容。2015 年 4 月,Phalcon 发布了 2.0 版本。你不必担心,因为它与之前学到的内容完全兼容。
与之前版本相比,2.0 版本完全用 Zephir 语言(www.zephir-lang.com/)重写。如果你想升级到 2.0.*版本。
本章我们将涵盖以下主题:
-
使用 Phalcon 上传文件
-
使用注解路由器
使用 Phalcon 上传文件
使用 Phalcon 上传文件非常简单。我们只需要检查请求对象是否有文件,并将其移动到我们的上传目录。让我们在Backoffice模块中创建以下控制器:
<?php
namespace App\Backoffice\Controllers;
use App\Core\Forms\MediaForm;
class MediaController extends BaseController {
public function addAction() {
$this->view->form = new MediaForm();
}
public function uploadAction() {
if (true == $this->request->hasFiles() && $this->request->isPost()) {
$upload_dir = __DIR__ . '/../../../public/uploads/';
if (!is_dir($upload_dir)) {
mkdir($upload_dir, 0755);
}
foreach ($this->request->getUploadedFiles() as $file) {
$file->moveTo($upload_dir . $file->getName());
$this->flashSession->success($file->getName().' has been successfully uploaded.');
}
$this->response->redirect('media/add');
}
}
}
uploadAction()方法首先检查请求对象是否有文件,并且请求方法是POST。我们将上传目录的路径分配给$upload_dir变量。然后我们检查这个目录是否在 public 中存在,如果不存在,则创建它。接下来,我们将每个上传的文件移动到public/uploads/。你可以在这个章节的源代码中找到这个示例的表单和视图。file对象有一些内置方法,非常有帮助:
-
$file->getSize(); -
$file->getRealType(); -
$file->getName()
使用这些方法,我们可以为图像实现一个简单的验证器。假设我们只接受不超过 1MB 的 JPEG 文件。下面是uploadAction()方法改进版本的样子:
<?php
class MediaController extends BaseController {
private $valid_mime = [
'image/jpeg'
];
private $max_size = 125000;
public function uploadAction() {
if (true == $this->request->hasFiles() && $this->request->isPost()) {
$upload_dir = __DIR__ . '/../../../public/uploads/';
if (!is_dir($upload_dir)) {
mkdir($upload_dir, 0755);
}
foreach ($this->request->getUploadedFiles() as $file) {
if (!in_array($file->getRealType(), $this->valid_mime)) {
$this->flashSession->error($file->getName().' is invalid');
continue;
}
if ($file->getSize() > $this->max_size) {
$this->flashSession->error($file->getName().' is too big');
continue;
}
$file->moveTo($upload_dir . $file->getName());
$this->flashSession->success($file->getName().' has been successfully uploaded.');
}
$this->response->redirect('media/add');
}
}
}
Phalcon 还支持图像处理。不幸的是,这部分内容没有文档说明,但你可以查看官方仓库github.com/phalcon/cphalcon/tree/master/ext/phalcon/image以了解可用方法,或者查看 IDE 存根的源代码github.com/phalcon/phalcon-devtools/tree/master/ide/1.3.4/Phalcon/Image。
图像处理的简单示例可以如下所示:
$image = new Phalcon\Image\Adapter\GD($file);
$image->resize(200, 200)
if ($image->save()) {
$this->flashSession->success('Image has been successfully resized');
}
我们还可以使用外部库,例如github.com/avalanche123/Imagine,你可以在imagine.readthedocs.org/en/latest/usage/introduction.html找到它,那里有很好的文档说明。
使用注解路由器
在这本书中,我们使用了配置文件来设置路由器。如果你来自 Symfony,例如,你可能想使用注解。为此,你需要更改 DI 中的路由器信息:
<?php
use Phalcon\Mvc\Router\Annotations;
$di['router'] = function() {
$router = new Annotations(false);
$router->addResource('Articles', '/api/v1/articles');
return $router;
};
然后,你必须修改ArticlesController使其看起来像这样:
<?php
namespace App\Api\Controllers;
/**
* @RoutePrefix("/api/v1/articles")
*/
class ArticlesController extends BaseController {
/**
* @Get("/")
*/
public function listAction() {
}
}
你可以在docs.phalconphp.com/en/latest/reference/routing.html#annotations-router了解更多关于注解路由器的信息。如果你需要/想要,你也可以开发自己的路由器,实现Phalcon\Mvc\RouterInterface。
摘要
在本章中,我们看到了如何使用 Phalcon 上传文件,也看到了如何使用注解路由器。
Phalcon 是一个完全解耦的框架。没有真正的“最佳实践”,因此作为开发者,您可以构建自己的管道。我还建议您查看 Phalcon 的 Vegas CMF 项目github.com/vegas-cmf,尤其是如果您打算与大型团队合作。
感谢您阅读这本书,我真心希望它对您有所帮助。现在您可以开始开发自己的应用程序了。




浙公网安备 33010602011771号