CakePHP-1-3-应用开发秘籍-全-

CakePHP 1.3 应用开发秘籍(全)

原文:zh.annas-archive.org/md5/4d2e075c183293401fc7987613851063

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

CakePHP 是一个用于 PHP 的快速开发框架,它为开发、维护和部署 Web 应用程序提供了一个可扩展的架构。虽然该框架为初学者提供了大量的文档和参考指南,但开发更复杂和可扩展的应用程序需要深入了解 CakePHP 的功能,这对即使是经验丰富的开发者来说也是一个挑战。

本食谱集中的食谱将为您提供即时结果,并帮助您开发 Web 应用程序,利用 CakePHP 的功能,让您能够快速理解和使用这些功能。遵循本书中的食谱(展示了如何使用 AJAX、数据源、GEO 定位、路由、性能优化等),您将能够迅速理解和使用这些功能。

本书涵盖的内容

第一章认证:本章解释了如何在 CakePHP 应用程序上设置认证,从最基本的设置开始,到使用框架核心中内置的工具实现高级授权机制结束。这是通过使用工具来实现的,这些工具允许我们快速设置安全区域,同时不会失去构建更复杂解决方案的灵活性。

前两个食谱展示了如何设置一个基本但完全工作的认证系统。接下来的三个食谱允许我们的用户使用不同的信息登录,在成功登录后将用户详细信息保存下来,并展示如何获取这些用户信息。第六个食谱展示了一种更复杂的基于路由前缀的授权技术。第七个食谱通过使用 CakePHP 的访问控制层设置了一个复杂的认证系统。最后,最后一个食谱展示了如何将我们的应用程序与 OpenID 集成。

第二章模型绑定:本章讨论了 CakePHP 应用程序最重要的方面之一:模型之间的关系,也称为模型绑定或关联。作为任何应用程序逻辑的组成部分,掌握如何操纵模型绑定以获取所需数据,在需要时获取数据,这一点至关重要。

为了做到这一点,我们将通过一系列食谱展示如何更改获取绑定的方式,哪些绑定以及从绑定中返回哪些信息,如何创建新的绑定,以及如何构建层次化数据结构。

第三章,推动搜索:使用模型获取数据是任何 CakePHP 应用程序最重要的方面之一。因此,合理使用框架提供的查找函数可以确保我们应用程序的成功,并且同样重要的是确保我们的代码可读且易于维护。

在本章中,我们提供了一些食谱,当需要时可以手动执行基于 SQL 的查询。

CakePHP 还允许我们定义自己的自定义查找类型,这将扩展基本类型,使我们的代码更具可读性。本章的最后几个食谱展示了如何为我们的查找类型添加分页支持。

第四章验证和行为:本章讨论了 CakePHP 模型中的两个基本方面,这对于大多数应用程序都是至关重要的:验证和行为。

当我们将信息保存到数据源(例如数据库)时,CakePHP 会自动确保数据被引号包围,以防止攻击,其中 SQL 注入是最常见的一种。如果我们还需要确保数据遵循某种格式(例如,电话号码有效),我们则使用验证规则。

有时候,我们需要的不仅仅是验证我们正在处理的数据。在某些情况下,我们需要为最终用户无法指定的字段设置值,但这些字段却是我们应用程序逻辑的一部分。CakePHP 的行为允许我们通过回调在数据保存之前或之后对其进行操作,从而扩展模型提供的功能。

第三个食谱展示了如何在行为中使用模型回调(如beforeFindafterFind),而第四个食谱展示了如何在使用save操作时,通过行为添加额外的字段值。

本章的最后两个食谱给出了如何使用Sluggable行为(用于创建 SEO 友好的 URL)和Geocodable行为(为Address模型添加地理编码支持)的示例。

第五章数据源:数据源几乎是所有模型操作的基础。它们在模型逻辑和底层数据层之间提供了一个抽象层,允许更灵活的数据操作方法。通过这种抽象,CakePHP 应用程序能够在不知道数据存储或检索的具体细节的情况下操作数据。

本章展示了如何从现有数据源获取信息,使用预构建的数据源处理非关系型数据,并教我们如何创建一个功能齐全的 Twitter 数据源。

第六章路由魔法:几乎每个基于 Web 的应用程序最终都必须开发一种成功的策略,通过一种称为搜索引擎优化的技术来获得更好的搜索引擎排名。

本章首先通过使用路由参数介绍了一些基本的路由概念,然后继续构建优化路由以利用我们的搜索引擎排名。

本章的最后部分展示了如何为我们的用户资料创建高度优化的 URL,以及如何构建自定义的Route类以获得更大的灵活性。

第七章, 创建和消费 Web 服务:当展望将应用程序功能暴露给第三方应用程序或展望将外部服务集成到我们自己的应用程序中时,Web 服务是必不可少的。它们提供了一套广泛的技术和定义,使得用不同编程语言编写的系统可以相互通信。

本章介绍了一系列食谱,用于消费 Web 服务并将我们应用程序的部分暴露为 Web 服务。

第八章, 使用 Shell:CakePHP 最强大且最不为人知的特性之一是其壳框架。它为应用程序提供了构建命令行工具所需的一切,这些工具可以用于执行密集型任务和其他任何类型的非交互式处理。

本章通过介绍构建基本壳的过程开始,引导读者了解 CakePHP 壳,然后继续介绍更高级的功能,例如发送电子邮件和从壳中运行控制器操作。最后,本章介绍了机器人插件,它提供了一个功能齐全的解决方案,用于调度和运行任务。

第九章, 国际化应用程序:本章包含一系列食谱,允许读者国际化他们 CakePHP 应用程序的所有方面,包括静态内容(如视图中的内容)和动态内容(如数据库记录)。

前两个食谱展示了如何使任何 CakePHP 视图或模型验证消息中的文本准备好翻译。第三个食谱展示了如何翻译更复杂的表达式。第四个食谱展示了如何运行 CakePHP 内置工具以提取所有需要翻译的静态内容,然后将该内容翻译成不同的语言。第五个食谱展示了如何翻译数据库记录。最后,最后一个食谱展示了如何允许用户更改当前应用程序的语言。

第十章, 测试:本章涵盖了应用程序编程中最有趣的一个领域:通过 CakePHP 内置工具进行单元测试,它提供了一个完整且强大的单元测试框架。

第一个食谱展示了如何设置测试框架,以便我们可以创建自己的测试用例。第二个食谱展示了如何创建测试数据(固定数据)并使用该数据来测试模型方法。第三和第四个食谱展示了如何测试控制器操作,以及如何测试我们的视图是否显示了我们期望的内容。最后一个食谱展示了如何以非普通方式运行测试。

第十一章, 工具类和工具:本章介绍了一系列工具类和有用的技术,这些技术可以提高 CakePHP 应用的架构。

第一道食谱展示了如何使用优化数组操作的 CakePHP 类。第二道食谱展示了如何使用Email组件发送电子邮件。第三道食谱展示了如何使用MagicDb类检测文件类型,最后一道食谱展示了如何创建应用程序异常,并在抛出时正确处理它们。

你需要这本书的什么

我们需要以下软件来完成这本书:

  • 支持 CakePHP 的 Web 服务器(例如 Apache)

  • 支持 CakePHP 的数据库引擎(例如 MySQL)

  • CakePHP 已安装、配置并正常运行

这本书面向谁

如果你是一名希望发现快速简便的方法来改进 Web 应用程序、利用框架所有方面的 CakePHP 开发者,这本书就是为你准备的。本书假设你已经具备 CakePHP 和一般的 PHP 开发技能。

惯例

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

文本中的代码词汇如下所示:“创建一个名为query_log.php的文件,并将其放置在你的app/controllers/components文件夹中,内容如下:”

代码块设置如下:

CREATE TABLE `accounts`(
`id` INT UNSIGNED AUTO_INCREMENT NOT NULL,
`email` VARCHAR(255) NOT NULL,
PRIMARY KEY(`id`)
);

新术语重要词汇以粗体显示。你在屏幕上看到的,例如在菜单或对话框中的文字,在文本中会这样显示:“在那个屏幕上,确保抓取显示为消费者密钥消费者密钥的内容,因为我们在执行这个食谱时会用到它。”

注意

警告或重要注意事项以如下框中的方式显示。

小贴士

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

读者反馈

我们读者的反馈总是受欢迎的。告诉我们你对这本书的看法——你喜欢什么或可能不喜欢什么。读者反馈对我们开发你真正能从中获得最大价值的标题非常重要。

要发送给我们一般性的反馈,只需发送电子邮件至<feedback@packtpub.com>,并在邮件主题中提及书名。

如果你需要一本书,并希望我们出版,请通过www.packtpub.com上的建议书名表单或发送电子邮件至<suggest@packtpub.com>给我们留言。

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

客户支持

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

小贴士

下载本书的示例代码

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

勘误

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

盗版

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

如果您在本书的任何方面遇到问题,请通过发送电子邮件到<copyright@packtpub.com>并附上疑似盗版材料的链接与我们联系。

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

问题

如果您在本书的任何方面遇到问题,请通过发送电子邮件到<questions@packtpub.com>与我们联系,我们将尽力解决。

第一章. 身份验证

本章将涵盖以下主题:

  • 设置基本身份验证系统

  • 使用和配置 Auth 组件

  • 允许使用电子邮件或用户名登录

  • 登录后保存用户详情

  • 获取当前用户的信息

  • 使用前缀进行基于角色的访问控制

  • 基于访问控制层的身份验证设置

  • 与 OpenID 集成

简介

本章解释了如何在 CakePHP 应用程序上设置身份验证,从最基本的设置开始,到高级授权机制结束。这是通过使用框架核心中内置的工具来实现的,这些工具允许我们快速设置安全区域,同时不失构建更复杂解决方案的灵活性。

前两个菜谱展示了如何设置一个基本但完全工作的身份验证系统。接下来的三个菜谱允许我们的用户使用不同的信息登录,在成功登录后保存用户详情,并展示如何获取这些用户信息。第六个菜谱展示了一种更复杂的基于路由前缀的授权技术。第七个菜谱通过使用 CakePHP 的访问控制层设置了一个复杂的身份验证系统。最后,最后一个菜谱展示了如何将我们的应用程序与 OpenID 集成。

设置基本身份验证系统

当我们在向应用程序添加身份验证的过程中时,首先要完成的第一项任务是确定哪些控制器需要用户访问。通常,我们会默认保护每个控制器和操作,然后我们会指定我们应用程序的哪些区域允许公开访问。

准备工作

我们必须有一个包含至少两个字段的 users 表:username(用于存储用户名)和 password(用于存储由用户密码生成的散列)。

如果您没有为此目的创建表,可以使用以下 SQL 语句来创建它:

CREATE TABLE `users`(
`id` INT UNSIGNED AUTO_INCREMENT NOT NULL,
`username` VARCHAR(255) NOT NULL,
`password` CHAR(40) NOT NULL,
PRIMARY KEY(`id`)
);

如何操作...

  1. 创建一个名为 users_controller.php 的文件,并将其放置在您的 app/controllers 文件夹中,内容如下:

    <?php
    class UsersController extends AppController {
    public function login() {
    }
    public function logout() {
    $this->redirect($this->Auth->logout());
    }
    }
    ?>
    
    
  2. 在您的 app/views/users 文件夹中创建一个名为 login.ctp 的文件(如果您还没有创建该文件夹,请先创建),并添加以下内容:

    <?php
    echo $this->Form->create(array('action'=>'login'));
    echo $this->Form->inputs(array(
    'legend' => 'Login',
    'username',
    'password'
    ));
    echo $this->Form->end('Login');
    ?>
    
    
  3. 在您的 app/ 文件夹中创建一个名为 app_controller.php 的文件,内容如下:

    <?php
    class AppController extends Controller {
    public $components = array(
    'Auth' => array(
    'authorize' => 'controller'
    ),
    'Session'
    );
    public function isAuthorized() {
    return true;
    }
    }
    ?>
    
    
  4. 修改 UsersController,并在 login 方法之前添加以下代码:

    public function beforeFilter() {
    parent::beforeFilter();
    $this->Auth->allow('add');
    }
    public function add() {
    if (!empty($this->data)) {
    $this->User->create();
    if ($this->User->save($this->data)) {
    $this->Session->setFlash('User created!');
    $this->redirect(array('action'=>'login'));
    } else {
    $this->Session->setFlash('Please correct the errors');
    }
    }
    }
    
    
  5. 创建一个名为 add.ctp 的文件,并将其放置在您的 app/views/users 文件夹中,内容如下:

    <?php
    echo $this->Form->create();
    echo $this->Form->inputs(array(
    'legend' => 'Signup',
    'username',
    'password'
    ));
    echo $this->Form->end('Submit');
    ?>
    
    

    现在我们已经有一个完全工作的身份验证系统。我们可以通过浏览到 http://localhost/users/add 来添加新用户,通过浏览到 http://localhost/users/login 来登录,最后通过浏览到 http://localhost/users/logout 来注销。

    创建用户后,你应该会看到一个带有成功信息的登录表单,如下面的截图所示:

    如何操作...

它是如何工作的...

我们首先在UsersController类中创建两个动作:login(),用于显示和处理登录表单的提交,以及logout(),用于处理用户登出。

你可能会惊讶,login()方法没有任何逻辑。要显示表单,我们只需要显示动作的视图。表单提交由Auth组件处理,因此我们不需要实现任何控制器逻辑。因此,我们唯一需要实现的是为这个动作创建一个视图,该视图包括一个简单的表单,包含两个字段:usernamepassword

注意

CakePHP 的FormHelperinputs方法是一个旨在避免多次调用input方法的快捷方式。通过使用它,我们可以创建一个包含元素的完整表单,而无需多次调用FormHelper::input()

logout()控制器动作简单地调用Auth组件的logout()方法。此方法从会话中删除已登录用户数据,并返回用户登出后应重定向到的地址,该地址来自组件先前配置的logoutRedirect设置(如果未配置,则默认为应用程序的主页。)

接下来,我们在控制器中添加两个组件:SessionAuthSession组件是必需的,用于创建消息(通过使用其setflash()方法),告知用户登录尝试是否失败,或者是否创建了用户。

Auth组件通过beforeFilter回调方法在控制器动作和传入请求之间操作。它使用其authorize设置来检查将要使用哪种身份验证方案。

注意

要获取有关authorize设置的更多信息,请参阅配方使用和配置 Auth 组件

一旦将Auth组件添加到控制器中,该控制器中的所有动作在没有有效用户登录的情况下均不可访问。这意味着,如果我们有任何应该公开的动作(例如我们控制器中的login()add()动作),我们就必须告诉Auth组件这些动作。

如果有人想使某些动作公开,可以将这些动作的名称添加到Auth组件的allowedActions设置中,或者通过调用其allow()方法。我们使用后者来告诉Auth组件add()动作可以在不登录用户的情况下访问。login()动作由Auth组件自动添加到公共动作列表中。

当用户尝试访问不在公共操作范围内的操作时,Auth组件会检查会话以查看是否有用户已经登录。如果没有找到有效的用户,它将浏览器重定向到login操作。如果已登录用户,它将使用控制器的isAuthorized方法检查用户是否有权限。如果其返回值为true,则允许访问,否则拒绝访问。在我们的案例中,我们在AppController,我们的基本控制器类中实现了此方法。如果尝试的操作需要已登录的用户,则执行login()操作。在用户使用登录表单提交数据后,组件将首先对密码字段进行哈希处理,然后对User模型执行查找操作以找到有效的账户,使用提交的用户名和密码。如果找到有效记录,则将其保存到会话中,标记用户已登录。

对密码确认字段进行哈希处理

Auth组件在控制器上启用,并且用户提交了一个名为password的字段(无论它是否在登录表单中渲染)的表单时,组件将在执行控制器操作之前自动对password字段进行哈希处理。

注意

Auth组件使用配置设置中定义的盐(在您的app/config/core.php文件中的Security.salt)来计算哈希。即使使用相同的密码,不同的盐值也会产生不同的哈希值。因此,请确保您更改所有 CakePHP 应用程序中的盐,从而增强您的认证系统的安全性。

这意味着该操作永远不会保留明文密码值,在利用机制进行密码验证确认时,这一点应特别注意。当你实施此类验证时,请确保使用正确的方法对确认字段进行哈希处理:

if (!empty($this->data)) {
$this->data['User']['confirm_password'] = $this->Auth->password($this->data['User']['confirm_password']);
// Continue with processing
}

参见

  • 使用和配置 Auth 组件

  • 获取当前用户信息

使用和配置 Auth 组件

如果有什么定义了Auth组件,那就是它的灵活性,它负责不同的认证模式,每种模式都满足不同的需求。在本食谱中,您将学习如何修改组件的默认行为,以及如何在不同认证模式之间进行选择。

准备工作

我们应该有一个完全工作的认证系统,所以按照整个食谱设置基本认证系统

我们还将添加对禁用用户账户的支持。使用以下 SQL 语句向您的用户表添加一个名为 active 的字段:

ALTER TABLE `users`
ADD COLUMN `active` TINYINT UNSIGNED NOT NULL default 1;

如何操作...

  1. 修改AppController类中Auth组件的定义,使其看起来如下:

    public $components = array(
    'Auth' => array(
    'authorize' => 'controller',
    'loginRedirect' => array(
    'admin' => false,
    'controller' => 'users',
    'action' => 'dashboard'
    ),
    'loginError' => 'Invalid account specified',
    'authError' => 'You don\'t have the right permission'
    ),
    'Session'
    );
    
    
  2. 现在,当您仍在编辑app/app_controller.php文件时,在AppController类中的beforeFilter方法声明下方,放置以下代码:

    public function beforeFilter() {
    if ($this->Auth->getModel()->hasField('active'))
    {$this->Auth->userScope = array('active' => 1);
    }
    }
    
    
  3. 将默认布局从 cake/libs/view/layouts/default.ctp 复制到你的 app/views/layouts 目录,并确保你在布局中放置以下行,以便显示认证消息:

    <?php echo $this->Session->flash('auth'); ?>
    
    
  4. 编辑你的 app/controllers/users_controller.php 文件,并在 logout() 方法下方放置以下方法:

    public function dashboard() {
    }
    
    
  5. 最后,在名为 dashboard.ctp 的文件中创建此新添加的动作视图,并将其放置在你的 app/views/users 文件夹中,内容如下:

    <p>Welcome!</p>
    
    

    如果你现在浏览到 http://localhost/users/login 并输入错误的凭据(错误的用户名和/或密码),你应该会看到以下截图中的错误消息:

    如何做...

它是如何工作的...

由于 Auth 组件在其执行控制器动作之前执行其魔法,我们要么需要在 beforeFilter 回调中指定其设置,要么在将组件添加到 components 属性时传递它们。一个常见的地方是在 AppController 类的 beforeFilter() 方法中这样做,因为这样我们可以在所有控制器中共享相同的认证设置。

此配方更改了一些 Auth 设置,以便每当有效用户登录时,他们都会自动被带到 UsersController 中的 dashboard 动作(通过 loginRedirect 设置完成。)它还通过组件的相应设置添加了一些默认错误消息:当提供的账户无效时为 loginError,当有有效账户但操作未授权时为 authError(这可以通过在 AppController 中实现的 isAuthorized() 方法返回 false 来实现。)

它还在 AppController::beforeFilter() 中设置了组件的 userScope 设置。此设置允许我们定义 User 查找操作需要匹配哪些条件才能允许用户账户登录。通过添加 userScope 设置,我们确保只有将 active 字段设置为 1 的用户记录才能访问。

更改默认用户模型

正如你可能已经注意到的,User 模型的角色至关重要,不仅是为了获取正确的用户账户,还要检查某些认证方案上的权限。默认情况下,Auth 组件会寻找一个 User 模型,但你可以通过设置 userModel 属性或设置数组中的 userModel 键来更改要使用的模型。

例如,如果你的用户模型是 Account,你会在将 Auth 组件添加到控制器时添加以下设置:

'userModel' => 'Account'

或者等价地,你可以在 AppController 类的 beforeFilter 方法中添加以下内容到 beforeFilter 方法:

$this->Auth->userModel = 'Account';

还有更多...

Auth 组件的 $authorize 属性(或 Auth 组件设置数组中的 authorize 键)定义了应该使用哪种认证方案。可能的值有:

  • controller:它使组件使用控制器的isAuthorized方法,该方法返回true以允许访问,或返回false以拒绝访问。此方法在获取登录用户时特别有用(请参阅获取当前用户信息配方)。

  • model:它与controller类似;而不是使用控制器来调用方法,它会在User模型中查找isAuthorized方法。首先,它会尝试将控制器的操作映射到 CRUD 操作('create''read''update''delete'之一),然后使用三个参数调用该方法:用户记录、被访问的控制器以及要执行的操作(或实际的控制器操作)。

  • object:它与model类似;而不是使用模型来调用方法,它会在给定的类中查找isAuthorized方法。为了指定哪个类,将AuthComponent::$object属性设置为该类的实例。它使用三个参数调用该方法:用户记录、被访问的控制器以及要执行的操作。

  • actions:它使用Acl组件来检查访问权限,这允许更细粒度的访问控制。

  • crud:它与actions类似;区别在于它首先尝试将控制器的操作映射到 CRUD 操作('create''read''update''delete'之一)。

相关内容

  • 获取当前用户信息

  • 设置基于访问控制层身份验证

允许使用用户名或电子邮件登录

默认情况下,Auth组件将使用登录表单中提交的给定用户名来检查有效用户账户。然而,某些应用程序有两个单独的字段:一个用于定义用户名,另一个用于定义用户的电子邮件。本配方展示了如何允许使用用户名或电子邮件进行登录。

准备工作

我们应该有一个完全工作的身份验证系统,所以按照整个配方,设置基本身份验证系统

我们还需要字段来存储用户的电子邮件地址。使用以下 SQL 语句向您的users表添加一个名为email的字段:

ALTER TABLE `users`
ADD COLUMN `email` VARCHAR(255) NOT NULL;

我们需要修改注册页面,以便用户可以指定他们的电子邮件地址。编辑您的app/views/users/add.ctp文件并做出以下更改:

<?php
echo $this->Form->create();
echo $this->Form->inputs(array(
'legend' => 'Signup',
'email',
'username',
'password'
));
echo $this->Form->end('Submit');
?>

如何做到这一点...

  1. 编辑您的app/views/users/login.ctp文件并对其做出以下更改:

    <?php
    echo $this->Form->create(array('action'=>'login'));
    echo $this->Form->inputs(array(
    'legend' => 'Login',
    'username' => array('label'=>'Username / Email'),
    'password'
    ));
    echo $this->Form->end('Login');
    ?>
    
    
  2. 编辑您的UsersController类,并确保login操作看起来如下:

    public function login() {
    if (
    !empty($this->data) &&
    !empty($this->Auth->data['User']['username']) &&
    !empty($this->Auth->data['User']['password'])
    ) {
    $user = $this->User->find('first', array(
    'conditions' => array(
    'User.email' => $this->Auth->data['User']['username'],
    'User.password' => $this->Auth->data['User']['password']
    ),
    'recursive' => -1
    ));
    if (!empty($user) && $this->Auth->login($user)) {
    if ($this->Auth->autoRedirect) {
    $this->redirect($this->Auth->redirect());
    }
    } else {
    $this->Session->setFlash($this->Auth->loginError, $this->Auth->flashElement, array(), 'auth');
    }
    }
    }
    
    

    如果您现在浏览到http://localhost/users/login,您就可以输入用户的电子邮件和密码进行登录,如下面的截图所示:

    如何做到这一点...

它是如何工作的...

Auth 组件无法使用用户名和密码字段找到有效的用户账户时,它将控制权交回 login 动作。因此,在 login 动作中,我们可以检查是否有任何提交的数据。如果是这样,我们知道 Auth 组件无法找到有效的账户。

考虑到这一点,我们可以尝试找到与给定用户名匹配的电子邮件地址的用户账户。如果存在,我们登录用户并将浏览器重定向到默认动作,类似于组件在成功尝试时所做的操作。

如果我们找不到有效的用户账户,我们只需将闪存消息设置为 Auth 组件中指定的默认错误消息。

还有更多...

你可能已经注意到,在查找用户记录时,我们使用了 $this->Auth->data 而不是 $this->data 来使用实际提交的值。这样做的原因是因为 Auth 组件不仅会自动哈希密码字段,还会将其值从控制器中的 data 属性中移除,所以如果你需要再次显示登录表单,密码字段将不会为用户预先填充。

参见

  • 获取当前用户信息

登录后保存用户详情

具有身份验证功能的网站提供的最典型的功能之一是允许用户选择(通过点击复选框)他们是否希望在登录后让系统记住他们的账户。

准备工作

我们应该有一个工作的身份验证系统,所以按照整个配方,设置基本身份验证系统

如何操作...

  1. 编辑你的 app/app_controller.php 文件,并将以下 Auth 组件设置添加到 Auth 组件中。同时,通过以下更改 components 属性来添加 Cookie 组件:AppController(在 $components 属性中)必须包含以下强制性设置(如果尚未存在,请将其添加到组件设置数组的内部):

    public $components = array(
    'Auth' => array(
    'authorize' => 'controller',
    'autoRedirect' => false
    ),
    'Cookie',
    'Session'
    );
    
    
  2. 编辑你的 app/views/users/login.ctp 视图文件,并做出以下更改:

    <?php
    echo $this->Form->create(array('action'=>'login'));
    echo $this->Form->inputs(array(
    'legend' => 'Login',
    'username',
    'password',
    'remember' => array('type' => 'checkbox', 'label' => 'Remember me')
    ));
    echo $this->Form->end('Login');
    ?>
    
    
  3. 现在,将以下代码添加到你的 UsersController 类的 login 动作末尾:

    if (!empty($this->data)) {
    $userId = $this->Auth->user('id');
    if (!empty($userId)) {
    if (!empty($this->data['User']['remember'])) {
    $user = $this->User->find('first', array(
    'conditions' => array('id' => $userId),
    'recursive' => -1,
    'fields' => array('username', 'password')
    ));
    $this->Cookie->write('User', array_intersect_key(
    $user[$this->Auth->userModel],
    array('username'=>null, 'password'=>null)
    ));
    } elseif ($this->Cookie->read('User') != null) {
    $this->Cookie->delete('User');
    }
    $this->redirect($this->Auth->redirect());
    }
    }
    
    
  4. 接下来,将以下代码添加到你的 UsersController 类的 logout() 方法开头:

    if ($this->Cookie->read('User') != null) {
    $this->Cookie->delete('User');
    }
    
    
  5. 最后,将以下方法添加到你的 AppController 类中,紧接在 components 属性声明下方:

    public function beforeFilter() {
    if ($this->Auth->user() == null) {
    $user = $this->Cookie->read('User');
    if (!empty($user)) {
    $user = $this->Auth->getModel()->find('first', array(
    'conditions' => array(
    $this->Auth->fields['username'] => $user[$this->Auth->fields['username']],
    $this->Auth->fields['password'] => $user[$this->Auth->fields['password']]
    ),
    'recursive' => -1
    ));
    if (!empty($user) && $this->Auth->login($user)) {
    $this->redirect($this->Auth->redirect());
    }
    }
    }
    }
    
    

它是如何工作的...

我们需要完成的第一项任务是禁用Auth组件中的自动重定向。通过这样做,我们能够捕捉到成功和失败的登录尝试,这允许我们检查是否选中了记住我复选框。如果复选框确实被选中,我们创建一个名为User的 cookie,其中包含usernamepassword字段的值,其值等于登录的用户 ID。记住,password值会自动由Auth组件加密,因此存储是安全的。Cookie组件通过自动加密和解密给定值,增加了另一层安全性。

AppController::beforeFilter()中,如果没有登录用户,我们会检查 cookie 是否已设置。如果是,我们使用 cookie 中存储的usernamepassword字段的值来登录用户,然后将浏览器重定向到login操作。

最后,我们在适当的时候删除 cookie(当用户未选中复选框登录或用户手动注销时)。

参见

  • 获取当前用户信息

获取当前用户信息

CakePHP 的认证系统将为我们提供构建强大、灵活的基于Auth的应用程序所需的工具。然后我们可以使用它来获取当前用户信息,并在整个应用程序中使其可用。

在这个菜谱中,我们将看到如何保存当前登录用户的信息,使其可以从我们的 CakePHP 应用程序的任何地方访问,包括布局,同时向User模型添加一个有用的方法以简化工作。

准备工作

我们应该有一个工作的认证系统,所以按照以下步骤,设置基本认证系统

如何做...

  1. 将以下方法添加到您的AppController类中:

    public function beforeFilter() {
    $user = $this->Auth->user();
    if (!empty($user)) {
    Configure::write('User', $user[$this->Auth->getModel()->alias]);
    }
    }
    
    
  2. 在您的AppController类中,在类定义内添加以下方法:

    public function beforeRender() {
    $user = $this->Auth->user();
    if (!empty($user)) {
    $user = $user[$this->Auth->getModel()->alias];
    }
    $this->set(compact('user'));
    }
    
    
  3. 将默认的 CakePHP 布局文件default.ctp从您的cake/libs/view/layouts文件夹复制到您的应用程序的app/views/layouts文件夹。在app/views/layouts/default.ctp文件夹中放置以下代码。在编辑此布局时,在您想要登录/注销链接出现的地方添加以下代码:

    <?php if (!empty($user)) { ?>
    Welcome back <?php echo $user['username']; ?>!
    <?php
    echo $this->Html->link('Log out', array('plugin'=>null, 'admin'=>false, 'controller'=>'users', 'action'=>'logout'));
    } else {
    echo $this->Html->link('Log in', array('plugin'=>null, 'admin'=>false, 'controller'=>'users', 'action'=>'login'));
    }
    ?>
    
    
  4. 将以下方法添加到User模型中。如果您还没有为users表创建模型,请继续创建一个名为user.php的文件并将其放置在您的app/models目录中。如果您已经有了,请确保将其get方法添加进去:

    <?php
    class User extends AppModel {
    public static function get($field = null) {
    $user = Configure::read('User');
    if (empty($user) || (!empty($field) && !array_key_exists($field, $user))) {
    return false;
    }
    return !empty($field) ? $user[$field] : $user;
    }
    }
    ?>
    
    

它是如何工作的...

通过将用户记录存储在应用程序的全局配置变量中,我们能够在应用程序的任何地方获取当前用户信息,无论是控制器、组件、模型等。这使得我们能够知道在任何时候是否有用户登录。

我们还需要确保视图能够了解是否有已登录的用户。虽然从技术上讲,视图仍然可以访问配置变量,但通常更优雅的做法是设置一个视图变量,以避免视图与 PHP 类之间的任何交互(除了视图助手之外)。

注意

当你在 AppController 中为视图设置变量时,确保没有任何控制器操作会覆盖这个变量非常重要。明智地选择一个独特的名称,并确保你不在你的控制器中设置具有相同名称的视图变量。

最后,我们在 User 模型中添加了一个方便的方法,这样我们就可以从我们的控制器中获取当前用户,而无需处理 Configure 变量。我们还可以使用 get 方法收集特定的用户信息。例如,要从控制器中获取当前用户的用户名,我们可以做如下操作:

$userName = User::get('username');

你不需要自己加载 User 模型类,因为 Auth 组件会为你做这件事。

参见

  • 允许使用电子邮件或用户名登录

使用前缀进行基于角色的访问控制

尽管 CakePHP 提供了一个非常强大的访问控制层,但有时我们只需要实现用户角色,而不必深入了解指定哪个角色可以访问哪个操作。

这个菜谱展示了如何通过使用路由前缀来限制基于角色的特定操作访问,这构成了一个简单的基于角色的身份验证的完美解决方案。为了完成这个菜谱,我们将假设需要在我们的应用程序中添加三个用户角色:管理员、经理和用户。

准备工作

我们应该有一个工作的身份验证系统,所以按照菜谱,设置基本身份验证系统users 表也应该包含一个字段来存储用户的角色(命名为 role。)使用以下 SQL 语句添加此字段:

ALTER TABLE `users`
ADD COLUMN `role` VARCHAR(255) DEFAULT NULL AFTER `password`;

如何做...

  1. 编辑你的 app/config/core.php 文件,查找定义 Routing.prefixes 设置的行。如果该行被注释掉了,取消注释它。然后将其更改为:

    Configure::write('Routing.prefixes', array('admin', 'manager'));
    
    
  2. 在你的 UsersController 类定义的末尾添加以下代码:

    public function dashboard() {
    $role = $this->Auth->user('role');
    if (!empty($role)) {
    $this->redirect(array($role => true, 'action' => 'dashboard'));
    }
    }
    public function admin_dashboard() {
    }
    public function manager_dashboard() {
    }
    
    
  3. 为这些操作中的每一个创建一个视图,并在其中放入内容以反映正在渲染的视图。因此,你需要创建三个文件:

    • app/views/users/admin_dashboard.ctp

    • app/views/users/manager_dashboard.ctp

    • app/views/users/dashboard.ctp

    例如,dashboard.ctp 的内容可以简单地是:

    <h1>Dashboard (User)</h1>
    
    
  4. 编辑你的 app/controllers/app_controller.php 文件,并将 components 属性声明更改为包括以下设置,用于 Auth 组件:

    public $components = array(
    'Auth' => array(
    'authorize' => 'controller',
    'loginRedirect' => array(
    'admin' => false,
    'controller' => 'users',
    'action' => 'dashboard'
    )
    ),
    'Session'
    );
    
    
  5. 在编辑你的 AppController 类的同时,更改 isAuthorized 方法,并将其完全替换为以下内容:

    public function isAuthorized() {
    $role = $this->Auth->user('role');
    $neededRole = null;
    $prefix = !empty($this->params['prefix']) ?
    $this->params['prefix'] :
    null;
    if (
    !empty($prefix) &&
    in_array($prefix, Configure::read('Routing.prefixes'))
    ) {
    $neededRole = $prefix;
    }
    return (
    empty($neededRole) ||
    strcasecmp($role, 'admin') == 0 ||
    strcasecmp($role, $neededRole) == 0
    );
    }
    
    
  6. 将默认的 CakePHP 布局文件 default.ctp 从您的 cake/libs/view/layouts 文件夹复制到您的应用程序的 app/views/layouts 文件夹。在编辑此布局时,将以下代码放置在 app/views/layouts/default.ctp 布局文件中,您希望链接到仪表板的位置。

    <?php
    $dashboardUrl = array('controller'=>'users', 'action'=>'dashboard');
    if (!empty($user['role'])) {
    $dashboardUrl[$user['role']] = true;
    }
    echo $this->Html->link('My Dashboard', $dashboardUrl);
    ?>
    
    

它是如何工作的...

当它们位于正常路由之前时,CakePHP 会识别 Routing.prefixes 设置中定义的前缀作为 URL 的一部分。例如,如果 admin 是一个已定义的前缀,则路由 /admin/articles/index 将转换为 ArticlesController 中的 admin_index 操作。

由于我们正在利用 Auth 配置中的控制器认证方案,我们知道每次用户尝试访问非公开操作时,都会执行 AppController::isAuthorized(),在方法内部,我们根据用户是否有权访问设置 truefalse

了解这一点后,我们可以在控制器操作即将执行时检查是否使用了前缀。如果当前访问的路由包含前缀,我们可以将该前缀与用户的角色匹配,以确保他们有权访问请求的资源。

我们可以通过在路由中添加适当的前缀来链接仅对角色可用的资源。例如,要链接到管理员的仪表板,URL 将是:

array(
'manager' => true,
'controller' => 'users',
'action' => 'dashboard'
);

相关内容

  • 基于访问控制层设置认证

基于访问控制层设置认证

应用程序拥有的角色越多,其访问控制层就越复杂。幸运的是,Auth 组件提供的认证方案之一允许我们通过命令行工具轻松定义哪些操作可以被某些角色(称为组)访问。在本教程中,您将学习如何在您的应用程序上设置访问控制列表(ACL)。

准备工作

我们应该有一个名为 groups 的表来存储角色。

如果您还没有,请使用以下语句创建它:

CREATE TABLE `groups`(
`id` INT NOT NULL AUTO_INCREMENT,
`name` VARCHAR(255) NOT NULL,
PRIMARY KEY(`id`)
);

如果您的 groups 表中没有记录,请运行以下 SQL 语句创建一些:

INSERT INTO `groups`(`id`, `name`) VALUES
(1, 'Administrator'),
(2, 'Manager'),
(3, 'User');

我们还必须有一个 users 表来存储用户,该表应包含一个字段(命名为 group_id),用于包含用户所属组的引用。如果您没有这样的表,请使用以下语句创建它:

CREATE TABLE `users`(
`id` INT NOT NULL AUTO_INCREMENT,
`group_id` INT NOT NULL,
`username` VARCHAR(255) NOT NULL,
`password` CHAR(40) NOT NULL,
PRIMARY KEY(`id`),
KEY `group_id`(`group_id`),
CONSTRAINT `users__groups` FOREIGN KEY(`group_id`) REFERENCES `groups`(`id`)
);

我们还需要初始化 ARO / ACO 表。使用您的操作系统控制台,切换到您的应用程序目录,并运行:

  • 如果您使用的是 GNU Linux / Mac / Unix 系统:

    ../cake/console/cake schema create DbAcl
    
    
  • 如果您使用的是 Microsoft Windows:

    ..\cake\console\cake.bat schema create DbAcl
    
    

如何操作...

注意

以下初始步骤与 设置基本认证系统 中显示的步骤非常相似。然而,两者之间有一些关键的区别,所以请确保仔细阅读这些说明。

  1. User 模型创建一个控制器(在 app/controllers 文件夹内名为 users_controller.php 的文件中),它应包含以下内容:

    <?php
    class UsersController extends AppController {
    public function login() {
    }
    public function logout() {
    $this->redirect($this->Auth->logout());
    }
    }
    ?>
    
    
  2. 在您的app/views/users文件夹中创建一个名为login.ctp的文件(如果您还没有创建该文件夹,请先创建),内容如下:

    <?php
    echo $this->Form->create(array('action'=>'login'));
    echo $this->Form->inputs(array(
    'legend' => 'Login',
    'username',
    'password'
    ));
    echo $this->Form->end('Login');
    ?>
    
    
  3. 在您的app/文件夹中创建一个名为app_controller.php的文件。确保它包含以下内容:

    <?php
    class AppController extends Controller {
    public $components = array(
    'Acl',
    'Auth' => array(
    'authorize' => 'actions',
    'loginRedirect' => array(
    'admin' => false,
    'controller' => 'users',
    'action' => 'dashboard'
    )
    ),
    'Session'
    );
    }
    ?>
    
    
  4. 修改UsersController类,并在其login()方法之前添加以下代码:

    public function beforeFilter() {
    parent::beforeFilter();
    $this->Auth->allow('add');
    }
    public function add() {
    if (!empty($this->data)) {
    $this->User->create();
    if ($this->User->save($this->data)) {
    $this->Session->setFlash('User created!');
    $this->redirect(array('action'=>'login'));
    } else {
    $this->Session->setFlash('Please correct the errors');
    }
    }
    $this->set('groups', $this->User->Group->find('list'));
    }
    
    
  5. app/views/users文件夹中添加动作视图,通过创建一个名为add.ctp的文件并包含以下内容:

    <?php
    echo $this->Form->create();
    echo $this->Form->inputs(array(
    'legend' => 'Signup',
    'username',
    'password',
    'group_id'
    ));
    echo $this->Form->end('Submit');
    ?>
    
    
  6. 创建一个名为group.php的文件,并将其放置在您的app/models文件夹中,内容如下:

    <?php
    class Group extends AppModel {
    public $actsAs = array('Acl' => 'requester');
    public function parentNode() {
    if (empty($this->id) && empty($this->data)) {
    return null;
    }
    $data = $this->data;
    if (empty($data)) {
    $data = $this->find('first', array(
    'conditions' => array('id' => $this->id),
    'fields' => array('parent_id'),
    'recursive' => -1
    ));
    }
    if (!empty($data[$this->alias]['parent_id'])) {
    return $data[$this->alias]['parent_id'];
    }
    return null;
    }
    }
    ?>
    
    
  7. 创建一个名为user.php的文件,并将其放置在您的app/models文件夹中,内容如下:

    <?php
    class User extends AppModel {
    public $belongsTo = array('Group');
    public $actsAs = array('Acl' => 'requester');
    public function parentNode() {
    }
    public function bindNode($object) {
    if (!empty($object[$this->alias]['group_id'])) {
    return array(
    'model' => 'Group',
    'foreign_key' => $object[$this->alias]['group_id']
    );
    }
    }
    }
    ?>
    
    

    注意

    请注意您groups表中所有记录的 ID,因为它们需要将每个组链接到一个ARO记录。

  8. 在您的控制台中运行以下命令(如果您的组 ID 不同,请将引用的 1、2、3 更改为您的组 ID)。

    • 如果您使用的是 GNU Linux / Mac / Unix 系统,命令如下:

      ../cake/console/cake acl create aro root Groups
      ../cake/console/cake acl create aro Groups Group.1
      ../cake/console/cake acl create aro Groups Group.2
      ../cake/console/cake acl create aro Groups Group.3
      
      
    • 如果您使用的是 Microsoft Windows,命令如下:

      ..\cake\console\cake.bat acl create aro root Groups
      ..\cake\console\cake.bat acl create aro Groups Group.1
      ..\cake\console\cake.bat acl create aro Groups Group.2
      ..\cake\console\cake.bat acl create aro Groups Group.3
      
      
  9. 在您的UsersController类定义的末尾添加以下代码:

    public function dashboard() {
    $groupName = $this->User->Group->field('name',
    array('Group.id'=>$this->Auth->user('group_id'))
    );
    $this->redirect(array('action'=>strtolower($groupName)));
    }
    public function user() {
    }
    public function manager() {
    }
    public function administrator() {
    }
    
    
  10. 为这些动作中的每一个创建一个视图,并在每个视图中放置一些独特的内容,以反映正在渲染的视图。因此,您必须创建三个文件:

    • app/views/users/user.ctp

    • app/views/users/manager.ctp

    • app/views/users/administrator.ctp

    例如,user.ctp的内容可以简单地是:

    <h1>Dashboard (User)</h1>
    
    
  11. 我们必须告诉 ACL 关于这些受限动作的信息。请在您的控制台中运行以下命令。

    • 如果您使用的是 GNU Linux / Mac / Unix 系统,命令如下:

      ../cake/console/cake acl create aco root controllers
      ../cake/console/cake acl create aco controllers Users
      ../cake/console/cake acl create aco controllers/Users logout
      ../cake/console/cake acl create aco controllers/Users user
      ../cake/console/cake acl create aco controllers/Users manager
      ../cake/console/cake acl create aco controllers/Users administrator
      
      
    • 如果您使用的是 Microsoft Windows,命令如下:

      ..\cake\console\cake.bat acl create aco root controllers
      ..\cake\console\cake.bat acl create aco controllers Users
      ..\cake\console\cake.bat acl create aco controllers/Users logout
      ..\cake\console\cake.bat acl create aco controllers/Users user
      ..\cake\console\cake.bat acl create aco controllers/Users manager
      ..\cake\console\cake.bat acl create aco controllers/Users administrator
      
      
  12. 最后,我们必须通过将每个 ARO(组)链接到每个 ACO(控制器动作)来授予权限。请在您的控制台中运行以下命令。

    • 如果您使用的是 GNU Linux / Mac / Unix 系统,命令如下:

      ../cake/console/cake acl grant Group.1 controllers/Users all
      ../cake/console/cake acl grant Group.2 controllers/Users/logout all
      ../cake/console/cake acl grant Group.2 controllers/Users/manager all
      ../cake/console/cake acl grant Group.3 controllers/Users/logout all
      ../cake/console/cake acl grant Group.3 controllers/Users/user all
      
      
    • 如果您使用的是 Microsoft Windows,命令如下:

      ..\cake\console\cake.bat acl grant Group.1 controllers/Users all
      ..\cake\console\cake.bat acl grant Group.2 controllers/Users/logout all
      ..\cake\console\cake.bat acl grant Group.2 controllers/Users/manager all
      ..\cake\console\cake.bat acl grant Group.3 controllers/Users/logout all
      ..\cake\console\cake.bat acl grant Group.3 controllers/Users/user all
      
      

    现在我们已经有一个完全工作的基于 ACL 的认证系统。我们可以通过浏览到http://localhost/users/add,使用 http://localhost/users/login 登录,并最终使用 http://localhost/users/logout 登出来添加新用户。

用户应只能访问http://localhost/users/user,经理可以访问http://localhost/users/manager,而管理员应该能够访问所有这些动作,包括http://localhost/users/administrator

它是如何工作的...

当将Auth组件的authorize配置选项设置为actions,并在控制器组件列表中添加Acl之后,CakePHP 将检查当前访问的动作是否是公开动作。如果不是这种情况,它将检查是否有匹配 ACO 记录的已登录用户。如果没有这样的记录,它将拒绝访问。

一旦为控制器操作找到了匹配的 ACO,它将使用User模型中的bindNode方法来查看用户记录是如何与 ARO 匹配的。我们添加的方法实现指定用户记录应该通过用户所属的组在aros表中查找。

在拥有匹配的 ACO 和 ARO 之后,它最后会检查是否为给定的 ARO 和 ACO 记录设置了有效的权限集(在aros_acos表中)。如果找到,则允许访问,否则将拒绝授权。

在组表中的每个记录都必须有一个匹配的 ARO 记录,这是至关重要的。我们通过发出aro create命令来设置这种关联,将每个组 ID 链接到形式为Group.ID的 ARO 记录,其中 ID 是实际的 ID。

类似地,所有不在定义的公共操作中的控制器操作都应该有一个匹配的 ACO 记录。就像 ARO 一样,我们通过发出aco create命令来创建控制器操作和 ACO 之间的关联,将 ACO 名称设置为操作名称,并使它们成为名称为控制器名称的 ACO 的子项。

最后,为了授予 ARO(组)对 ACO(控制器操作)的权限,我们发出acl grant命令,将 ARO(Group.ID)指定为第一个参数,将第二个参数指定为整个控制器(例如controllers/Users)或特定的控制器操作(例如controllers/Users/logout)。grant 命令的最后一个参数(all)简单地提供了对访问类型的进一步控制,并在使用 ACL 控制对自定义对象的访问或使用crud认证方案时更有意义。

还有更多...

在开发应用程序时,将每个控制器操作与 ACO 匹配的任务可能有些麻烦。幸运的是,CakePHP 社区中的几个人感觉到了对更简单解决方案的需求。我推荐的一个解决方案是采用由 CakePHP 1.3 版本的首席开发者 Mark Story 开发的插件acl_extras。通过使用此插件,您将能够持续同步您的控制器与acos表。更多关于它的信息,包括其安装说明,可以在github.com/markstory/acl_extras找到。

参见

  • 使用前缀进行基于角色的访问控制

集成 OpenID

OpenID (openid.net) 是一种允许用户无需在您的应用程序中实际拥有用户名即可登录的绝佳方式。这是一个被广泛采用且在许多知名网站上(如 Google、Yahoo、MySpace 和 AOL)证明了自己的解决方案。

这个配方展示了如何以透明的方式添加对 OpenID 登录的支持,同时仍然与有效的Auth实现一起工作。

准备工作

我们应该有一个工作的认证系统,所以按照配方,设置基本认证系统

我们还需要 PHP OpenID 库。从 github.com/openid/php-openid/downloads 下载最新版本,并将下载文件中的 Auth 文件夹提取到你的 app/vendors 文件夹中。你现在应该在 vendors 文件夹内有一个名为 Auth 的目录。

最后,我们需要下载 CakePHP 的 OpenID 插件。访问 github.com/mariano/openid/downloads 并下载最新版本。将下载的文件解压缩到你的 app/plugins 文件夹中。你现在应该在 app/plugins 文件夹内有一个名为 openid 的目录。

如何操作...

  1. 编辑你的 AppController 类,将 Auth 组件的引用从 Auth 更改为 Openid.OpenAuthcomponents 属性现在应该看起来像这样:

    public $components = array(
    'Openid.OpenAuth' => array(
    'authorize' => 'controller'
    ),
    'Session'
    );
    
    
  2. 接下来,编辑登录视图(在 app/views/users/login.ctp 中)并添加一个字段,允许用户指定他们的 OpenID URL。视图现在应该看起来像这样:

    <?php
    echo $this->Form->create(array('action'=>'login'));
    echo $this->Form->inputs(array(
    'legend' => 'Login',
    'openid' => array('label' => 'OpenID URL'),
    'username',
    'password'
    ));
    echo $this->Form->end('Login');
    ?>
    
    

    现在,你应该能够使用有效的用户名和密码组合,或者 OpenID URL 登录,如下面的截图所示:

    如何操作...

它是如何工作的...

由于 OpenAuth 组件(openid 插件的一部分)扩展了 CakePHP 内置的 Auth 组件,它以类似的方式工作。当组件似乎找不到使用用户名和密码登录用户的方法时,它将检查是否指定了 OpenID URL。

如果是这样,它将尝试将 URL 与 OpenID 服务器进行认证。当它这样做时,用户将被带到 OpenID 服务器,以便应用程序可以授予访问 OpenID 凭证的权限。当权限被授予时,用户将被带回到应用程序,此时 OpenAuth 组件能够标记用户为已登录,并继续正常的应用程序工作流程。

还有更多...

openid 插件有进一步选项来自定义其行为;包括指定应返回哪些用户信息的能力。请查看 github.com/mariano/openid 中的文档。

作为标准的 Auth 实现,这种集成可以与本章中我们看到的其他任何配方结合使用,从而允许灵活的开放认证解决方案。如果你这样做,请确保注意 OpenAuth 组件返回的用户不包含有效的用户记录,因此你应该在登录时创建一个。

即使你在使用名为 OpenAuth 的组件,其名称与 Auth 明显不同,你仍然可以使用 $this->Auth 来设置属性或调用,例如,allow 方法。这是可能的,因为该组件创建了一个别名。

参见

  • 获取当前用户信息

第二章. 模型绑定

在本章中,我们将涵盖:

  • Containable 添加到所有模型中

  • 限制查找中返回的绑定

  • 修改查找的绑定参数

  • 修改查找的绑定条件

  • 改变一对一关联的 JOIN 类型

  • 定义对同一模型的多个关联

  • 动态添加绑定

简介

本章讨论了 CakePHP 应用程序最重要的一个方面:模型之间的关系,也称为 模型绑定关联

作为任何应用程序逻辑的组成部分,掌握如何操纵模型绑定以获取我们所需数据、所需时刻的所有方面至关重要。

为了做到这一点,我们将通过一系列食谱来展示如何改变绑定获取的方式,哪些绑定和哪些绑定信息被返回,如何创建新的绑定,以及如何构建层次数据结构。

将 Containable 添加到所有模型中

Containable 行为是 CakePHP 核心的一部分,可能是我们用来处理模型绑定的最重要的行为之一。

几乎所有 CakePHP 应用程序都将受益于其功能,因此在这个食谱中,我们展示了如何为所有模型启用它。

如何操作...

创建一个名为 app_model.php 的文件,并将其放置在您的 app/ 文件夹中,内容如下。如果您已经有了这样的文件,请确保您添加了以下所示的 actsAs 属性,或者您的 actsAs 属性包含了 Containable

<?php
class AppModel extends Model {
public $actsAs = array('Containable');
}
?>

它是如何工作的...

Containable 行为不过是 bindModel()unbindModel() 方法的包装,这些方法定义在 CakePHP 的 Model 类中。它的目的是帮助我们处理关联管理,而无需在调用这些方法时重新定义所有关联的繁琐过程,从而使我们的代码更加可读和可维护。

这是一个非常重要的观点,因为 CakePHP 用户常犯的一个错误是认为 Containable 参与了查询制作过程,即在 CakePHP 创建实际 SQL 查询以获取数据的过程中。

Containable 为我们节省了一些不必要的查询,并优化了为每个相关模型获取的信息,但它不会作为改变 CakePHP 中查询构建方式的方法。

参见

  • 限制查找中返回的绑定

  • 修改查找的绑定参数

  • 修改查找的绑定条件

限制查找中返回的绑定

这个食谱展示了如何使用 Containable 来指定 find 操作的结果中返回哪些相关模型。它还展示了如何限制每个关联获取的字段。

准备工作

为了完成这个食谱,我们需要一些样本表来操作。

  1. 使用以下 SQL 语句创建一个名为 families 的表:

    CREATE TABLE `families`(
    `id` INT UNSIGNED AUTO_INCREMENT NOT NULL,
    `name` VARCHAR(255) NOT NULL,
    PRIMARY KEY(`id`)
    );
    
    
  2. 使用以下 SQL 语句创建一个名为 people 的表:

    CREATE TABLE `people`(
    `id` INT UNSIGNED AUTO_INCREMENT NOT NULL,
    `family_id` INT UNSIGNED NOT NULL,
    `name` VARCHAR(255) NOT NULL,
    `email` VARCHAR(255) NOT NULL,
    PRIMARY KEY(`id`),
    KEY `family_id`(`family_id`),
    CONSTRAINT `people__families` FOREIGN KEY(`family_id`) REFERENCES `families`(`id`)
    );
    
    
  3. 使用以下 SQL 语句创建一个名为profiles的表:

    CREATE TABLE `profiles`(
    `id` INT UNSIGNED AUTO_INCREMENT NOT NULL,
    `person_id` INT UNSIGNED NOT NULL,
    `website` VARCHAR(255) default NULL,
    `birthdate` DATE default NULL,
    PRIMARY KEY(`id`),
    KEY `person_id`(`person_id`),
    CONSTRAINT `profiles__people` FOREIGN KEY(`person_id`) REFERENCES `people`(`id`)
    );
    
    
  4. 使用以下 SQL 语句创建一个名为posts的表:

    CREATE TABLE `posts`(
    `id` INT UNSIGNED AUTO_INCREMENT NOT NULL,
    `person_id` INT UNSIGNED NOT NULL,
    `title` VARCHAR(255) NOT NULL,
    `body` TEXT NOT NULL,
    `created` DATETIME NOT NULL,
    `modified` DATETIME NOT NULL,
    PRIMARY KEY(`id`),
    KEY `person_id`(`person_id`),
    CONSTRAINT `posts__people` FOREIGN KEY(`person_id`) REFERENCES `people`(`id`)
    );
    
    

    注意

    即使您不想为表添加外键约束,也请确保为每个引用另一个表中的记录的字段使用 KEY。通过这样做,当引用的表被连接时,您将显著提高 SQL 查询的速度。

  5. 添加一些样本数据,使用以下 SQL 语句:

    INSERT INTO `families`(`id`, `name`) VALUES
    (1, 'The Does');
    INSERT INTO `people`(`id`, `family_id`, `name`, `email`) VALUES
    (1, 1, 'John Doe', 'john.doe@example.com'),
    (2, 1, 'Jane Doe', 'jane.doe@example.com');
    INSERT INTO `profiles`(`person_id`, `website`, `birthdate`) VALUES
    (1, 'http://john.example.com', '1978-07-13'),
    (2, NULL, '1981-09-18');
    INSERT INTO `posts`(`person_id`, `title`, `body`, `created`, `modified`) VALUES
    (1, 'John\'s Post 1', 'Body for John\'s Post 1', NOW(), NOW()),
    (1, 'John\'s Post 2', 'Body for John\'s Post 2', NOW(), NOW());
    
    
  6. 我们需要将Containable添加到所有我们的模型中,因此请遵循以下步骤将 Containable 添加到所有模型

  7. 我们现在继续创建主模型。创建一个名为person.php的文件,并将其放置在您的app/models文件夹中,以下是其内容:

    <?php
    class Person extends AppModel {
    public $belongsTo = array('Family');
    public $hasOne = array('Profile');
    public $hasMany = array('Post');
    }
    ?>
    
    
  8. 在名为family.php的文件中创建模型Family,并将其放置在您的app/models文件夹中,以下是其内容:

    <?php
    class Family extends AppModel {
    public $hasMany = array('Person');
    }
    ?>
    
    

如何操作...

Containable对我们的模型可用时,我们可以在find操作中添加一个名为contain的设置。在这个设置中,我们指定一个基于数组的层次结构,返回我们想要的相关数据。contain可以接收的特殊值是false或一个空数组,这告诉Containable不要返回任何相关数据。

例如,要获取不带相关数据的第一个Person记录,我们只需这样做:

$person = $this->Person->find('first', array(
'contain' => false
));

注意

另一种让 CakePHP 不获取相关数据的方法是通过使用recursive查找设置。将recursive设置为-1将产生与将contain设置为false完全相同的效果。

如果我们想获取第一个Person记录及其所属的Family,我们这样做:

$person = $this->Person->find('first', array(
'contain' => array('Family')
));

使用我们的样本数据,上述查询将产生以下数组结构:

array(
'Person' => array(
'id' => '1',
'family_id' => '1',
'name' => 'John Doe',
'email' => 'john.doe@example.com'
),
'Family' => array(
'id' => '1',
'name' => 'The Does'
)
)

假设现在我们还想获取属于Person的所有Post记录以及该Person所属家庭的成员的所有Post记录。那么我们就必须这样做:

$person = $this->Person->find('first', array(
'contain' => array(
'Family.Person'
'Post'
)
));

上述操作将产生以下数组结构(为了可读性,已移除createdmodified字段):

array(
'Person' => array(
'id' => '1',
'family_id' => '1',
'name' => 'John Doe',
'email' => 'john.doe@example.com'
),
'Family' => array(
'id' => '1',
'name' => 'The Does',
'Person' => array(
array(
'id' => '1',
'family_id' => '1',
'name' => 'John Doe',
'email' => 'john.doe@example.com'
),
array(
'id' => '2',
'family_id' => '1',
'name' => 'Jane Doe',
'email' => 'jane.doe@example.com'
)
)
),
'Post' => array(
array(
'id' => '1',
'person_id' => '1',
'title' => 'John\'s Post 1',
'body' => 'Body for John\'s Post 1'
),
array(
'id' => '2',
'person_id' => '1',
'title' => 'John\'s Post 2',
'body' => 'Body for John\'s Post 2'
)
)
)

我们还可以使用Containable来指定从相关模型中获取哪些字段。使用前面的示例,让我们限制Post字段,以便我们只返回titlePerson记录的Familyname字段。我们通过将字段名添加到关联模型层次结构中来实现这一点:

$person = $this->Person->find('first', array(
'contain' => array(
'Family.Person.name',
'Post.title'
)
));

返回的数据结构将如下所示:

array(
'Person' => array(
'id' => '1',
'family_id' => '1',
'name' => 'John Doe',
'email' => 'john.doe@example.com'
),
'Family' => array(
'id' => '1',
'name' => 'The Does',
'Person' => array(
array(
'name' => 'John Doe',
'family_id' => '1',
'id' => '1'
),
array(
'name' => 'Jane Doe',
'family_id' => '1',
'id' => '2'
)
)
),
'Post' => array(
array(
'title' => 'John\'s Post 1',
'id' => '1',
'person_id' => '1'
),
array(
'title' => 'John\'s Post 2',
'id' => '2',
'person_id' => '1'
)
)
)

您可能会注意到,即使我们为Family => Person绑定和Post绑定指明了特定的字段,仍然会返回一些额外的字段。这些字段(如family_id)是 CakePHP 需要的,被称为外键字段,用于获取相关数据,因此Containable足够智能,会将它们包含在查询中。

假设我们还想获取一个人的电子邮件。由于需要多个字段,我们需要使用数组表示法,使用fields设置来指定字段列表:

$person = $this->Person->find('first', array(
'contain' => array(
'Family' => array(
'Person' => array(
'fields' => array('email', 'name')
)
),
'Post.title'
)
));

它是如何工作的...

我们使用contain查找设置来指定我们想要在查找操作中使用哪种包含类型。这种包含类型以数组的形式给出,其中数组层次结构模仿模型关系。由于层次结构可能足够深,使得数组表示法难以处理,因此本食谱中使用的点表示法提供了一个有用且更易读的替代方案。

如果我们要引用属于Family模型的Person模型,该关系的正确contain语法是Person => Family(我们也可以使用Person.Family,这更简洁。)

我们还使用fields设置来指定我们想要为绑定获取哪些字段。我们通过指定作为绑定Containable设置一部分的字段名数组来实现这一点。

Containable在我们对一个模型执行查找操作之前寻找contain查找设置。如果找到,它通过在适当的模型上发出unbindModel()调用来更改模型绑定,以解除contain查找设置中未指定的那些关系的绑定。然后,它将recursive查找设置设置为获取关联数据所需的最小值。

让我们用一个实际例子来进一步理解这个包装过程。使用我们的Person模型(它具有与Family模型的belongsTo关系、与Profile模型的hasOne关系以及与Post模型的hasMany关系),以下基于Containable的查询:

$person = $this->Person->find('first', array( 'contain' => array('Family.Person') ));

或者使用数组表示法执行相同的查询:

$person = $this->Person->find('first', array( 'contain' => array('Family' => 'Person') ));

等价于以下不使用Containable但使用 CakePHP 的Model类中内置的unbindModel()方法的指令集:

$this->Person->unbindModel(array( 'hasOne' => array('Profile'), 'hasMany' => array('Post') )); $person = $this->Person->find('first', array( 'recursive' => 2 ));

不使用Containable不仅更加复杂,而且如果我们决定更改一些关系,也可能引发问题。在前面的例子中,如果我们决定删除Profile绑定或更改其关系类型,我们就必须修改unbindModel()调用。然而,如果我们使用Containable,相同的代码适用,我们无需担心此类更改。

包含查找参数的格式

我们已经看到了如何使用contain查找参数来限制find操作之后返回的绑定。即使其格式看似不言自明,让我们再通过另一个例子来深入理解Containable的数组表示法。假设我们有以下图中显示的模型和关系:

包含查找参数的格式

将该图转换为Containable行为理解的内容,就像使用数组结构编写它一样简单。例如,如果我们正在对User模型执行find操作,并且想要引用Profile关系,一个简单的array('Profile')表达式就足够了,因为Profile模型直接与User模型相关。

如果我们想要引用Article记录的Comment关系,该记录的User是所有者,并且属于一个属于我们User模型的Article,那么我们就在结构中添加另一个维度,现在它表示为array('Article' => 'Comment')

我们可以预先推断出下一个示例将如何呈现。假设我们想要获取每个Article中评论的UserProfileComment。结构将如下所示:array('Article' => array('Comment' => array('User' => 'Profile')))

有时我们想要简化可读性,幸运的是,Containable行为允许将上述表达式重写为array('Article.Comment.User.Profile'),这被称为点表示法。然而,如果你想要更改绑定的其他参数,那么这个语法将必须更改为基于完整数组的表达式(参见本配方中的参见部分)。

绑定更改的重置

当你发出一个使用Containable行为来改变一些其绑定的查找操作时,一旦查找完成,CakePHP 将重置所有绑定的更改到它们原始状态。这在大多数情况下是通常想要的,但也有一些场景,你希望保留你的更改直到你手动重置它们,例如当你需要发出多个查找操作并且所有这些查找都使用修改后的绑定时。

为了强制我们的绑定更改被保留,我们在contain查找参数中使用reset选项,将其设置为false。当我们准备好重置它们时,我们发出对Containable行为为我们模型添加的resetBindings()方法的调用。以下示例代码显示了此过程:

$person = $this->Person->find('first', array(
'contain' => array(
'reset' => false,
'Family'
)
));
// ...
$this->Person->resetBindings();

实现相同结果的另一种方式是通过调用contain()方法(将其第一个参数设置为包含的绑定,并将其第二个参数设置为false以指示我们希望保留这些包含),这对于所有使用Containable的模型都是可用的,发出查找(无需使用contain设置),然后重置绑定:

$this->Person->contain(array('Family'), false);
$person = $this->Person->find('first');
// ...
$this->Person->resetBindings();

参见

  • 修改查找的绑定参数

  • 修改查找的绑定条件

修改查找的绑定参数

这个配方展示了如何使用Containable来改变影响模型绑定的一些参数。

准备工作

为了完成这个配方,我们需要一些示例表来工作。

  1. 使用以下 SQL 语句创建一个名为users的表:

    CREATE TABLE `users`(
    `id` INT UNSIGNED AUTO_INCREMENT NOT NULL,
    `name` VARCHAR(255) NOT NULL,
    `email` VARCHAR(255) NOT NULL,
    PRIMARY KEY(`id`)
    );
    
    
  2. 使用以下 SQL 语句创建一个名为profiles的表:

    CREATE TABLE `profiles`(
    `id` INT UNSIGNED AUTO_INCREMENT NOT NULL,
    `user_id` INT UNSIGNED NOT NULL,
    `website` VARCHAR(255) default NULL,
    `birthdate` DATE default NULL,
    PRIMARY KEY(`id`),
    KEY `user_id`(`user_id`),
    CONSTRAINT `profiles__users` FOREIGN KEY(`user_id`) REFERENCES `users`(`id`)
    );
    
    
  3. 使用以下 SQL 语句创建一个名为articles的表:

    CREATE TABLE `articles`(
    `id` INT UNSIGNED AUTO_INCREMENT NOT NULL,
    `user_id` INT UNSIGNED NOT NULL,
    `title` VARCHAR(255) NOT NULL,
    `body` TEXT NOT NULL,
    `published` TINYINT NOT NULL default 1,
    `created` DATETIME NOT NULL,
    `modified` DATETIME NOT NULL,
    PRIMARY KEY(`id`),
    KEY `user_id`(`user_id`),
    CONSTRAINT `articles__users` FOREIGN KEY(`user_id`) REFERENCES `users`(`id`)
    );
    
    
  4. 使用以下 SQL 语句添加一些示例数据:

    INSERT INTO `users`(`id`, `name`, `email`) VALUES
    (1, 'John Doe', 'john.doe@example.com'),
    (2, 'Jane Doe', 'jane.doe@example.com');
    INSERT INTO `profiles`(`user_id`, `website`, `birthdate`) VALUES
    (1, 'http://john.example.com', '1978-07-13'),
    (2, NULL, '1981-09-18');
    INSERT INTO `articles`(`user_id`, `title`, `body`, `published`, `created`, `modified`) VALUES
    (1, 'John\'s Post 1', 'Body for John\'s Post 1', 1, NOW(), NOW()),
    (1, 'John\'s Post 2', 'Body for John\'s Post 2', 1, NOW(), NOW()),
    (1, 'John\'s Post 3', 'Body for John\'s Post 3', 0, NOW(), NOW()),
    (1, 'John\'s Post 4', 'Body for John\'s Post 4', 1, NOW(), NOW()),
    (2, 'Jane\'s Post 1', 'Body for Jane\'s Post 1', 1, NOW(), NOW());
    
    
  5. 通过遵循配方将 Containable 添加到所有模型,将Containable行为添加到所有模型中。

  6. 现在我们需要创建主模型。创建一个名为user.php的文件,并将其放置在app/models文件夹中,内容如下:

    <?php
    class User extends AppModel {
    public $hasOne = array('Profile');
    public $hasMany = array('Article');
    }
    ?>
    
    

如何做到这一点...

如果我们想要获取第一个User记录以及该User拥有的Article记录,但首先按最新文章排序,我们使用order绑定设置(我们还将使用fields设置来限制每个Article返回的字段):

$user = $this->User->find('first', array(
'contain' => array(
'Article' => array(
'fields' => array('Article.title'),
'order' => array(
'Article.created' => 'desc',
'Article.id' => 'desc'
)
)
)
));

使用我们的示例数据,上述查询将导致以下数组结构:

array(
'User' => array(
'id' => '1',
'name' => 'John Doe',
'email' => 'john.doe@example.com',
),
'Article' => array(
array(
'title' => 'John\'s Post 4',
'user_id' => '1'
),
array(
'title' => 'John\'s Post 3',
'user_id' => '1'
),
array(
'title' => 'John\'s Post 2',
'user_id' => '1'
),
array(
'title' => 'John\'s Post 1',
'user_id' => '1'
)
)
)

如果我们想要获取相同的数据,但确保我们只获取User最新写的一篇Article,我们使用limit绑定设置:

$user = $this->User->find('first', array(
'contain' => array(
'Article' => array(
'fields' => array('Article.title'),
'order' => array(
'Article.created' => 'desc',
'Article.id' => 'desc'
),
'limit' => 1
)
)
));

使用我们的示例数据,上述查询将导致以下数组结构:

array(
'User' => array(
'id' => '1',
'name' => 'John Doe',
'email' => 'john.doe@example.com',
),
'Article' => array(
array(
'title' => 'John\'s Post 4',
'user_id' => '1'
)
)
)

在某些场景下,另一个有用的选项是offset,适用于hasManyhasAndBelongsToMany绑定。使用上面的示例,我们现在想要获取在最新Article之后的两个最新User创建的文章。

$user = $this->User->find('first', array(
'contain' => array(
'Article' => array(
'fields' => array('Article.title'),
'order' => array(
'Article.created' => 'desc',
'Article.id' => 'desc'
),
'limit' => 2,
'offset' => 1
)
)
));

返回的数据结构现在看起来像这样:

array(
'User' => array(
'id' => '1',
'name' => 'John Doe',
'email' => 'john.doe@example.com',
),
'Article' => array(
array(
'title' => 'John\'s Post 3',
'user_id' => '1'
),
array(
'title' => 'John\'s Post 2',
'user_id' => '1'
)
)
)

它是如何工作的...

Containable行为使用在 CakePHP 的Model类中定义的内置bindModel()方法来更改在contain查找设置中定义的绑定设置。

它会遍历定义的绑定,并检查是否存在定义的绑定设置。如果有,它将它们传递给每个指定绑定的bindModel()方法。

一些绑定设置仅在某些关系类型上有意义。例如,之前使用的limit设置在belongsTohasOne关系上可能没有用。

以下列表包括可以为每种关系类型指定的设置:

  • belongsTo: className, conditions, foreignKey, order

  • hasOne: className, conditions, foreignKey, order

  • hasMany: className, conditions, finderQuery, foreignKey, limit, offset, order

  • hasAndBelongsToMany: associationForeignKey, className, conditions, deleteQuery, finderQuery, foreignKey, insertQuery, joinTable, limit, offset, order, unique, with

相关内容

  • 修改查找的绑定条件

修改查找的绑定条件

这个配方展示了如何使用Containable来更改通过绑定获取与模型相关数据时使用的条件。

准备工作

我们需要将Containable添加到我们的模型中,我们还需要一些示例模型和数据来工作。遵循配方,将 Containable 添加到所有模型中,以及配方中的准备工作部分,修改查找的绑定参数

如何做...

如果我们想要获取第一个User记录以及该用户拥有的已发布的Article记录,但首先按最新文章排序,并限制返回的字段,我们使用conditions绑定设置:

$user = $this->User->find('first', array(
'contain' => array(
'Article' => array(
'fields' => array('Article.title'),
'conditions' => array(
'Article.published' => 1
)
)
)
));

使用我们的示例数据,前面的查询将导致以下数组结构:

array(
'User' => array(
'id' => '1',
'name' => 'John Doe',
'email' => 'john.doe@example.com',
),
'Article' => array(
array(
'title' => 'John\'s Post 1',
'user_id' => '1'
),
array(
'title' => 'John\'s Post 2',
'user_id' => '1'
),
array(
'title' => 'John\'s Post 4',
'user_id' => '1'
)
)
)

它是如何工作的...

条件绑定设置是另一个绑定参数,如食谱中所示,修改查找的绑定参数。因此,Containable 行为使用 CakePHP 的 Model 类中定义的内置 bindModel() 方法来更改 contain 查找操作中定义的绑定条件。

更改一对一关联的 JOIN 类型

当我们查询具有其他关联模型的模型时,CakePHP 将发出新的查询以获取关联数据,或者如果关联模型与主模型(通过使用 belongsTohasOne 定义的绑定)具有一对一关系,则使用 LEFT JOIN SQL 语句。

然而,有时我们需要更改一对一关联的 JOIN 类型,以使用 RIGHT JOININNER JOIN。这个食谱展示了如何更改 belongsTohasOne 关联的 JOIN 类型。

准备工作

按照食谱中的 准备工作 部分,限制查找中返回的绑定

如何操作...

  1. 编辑 Person 模型,并更改 belongsTohasOne 关联的绑定定义,如下所示:

    <?php
    class Person extends AppModel {
    public $belongsTo = array('Family' => array('type' => 'INNER'));
    public $hasOne = array('Profile' => array('type' => 'RIGHT'));
    public $hasMany = array('Post');
    }
    ?>
    
    

它是如何工作的...

当我们向模型添加绑定时,我们可以传递一个设置数组到绑定定义中,以配置绑定的不同方面。其中一个设置是 type,仅适用于 belongsTohasOne 绑定。

type 设置允许我们定义 CakePHP 在获取关联模型时将使用哪种类型的 JOIN(仅在查询主模型时适用)。可用的 JOIN 类型有:

  • INNER JOIN:连接并仅返回与默认连接条件匹配的关联模型的记录。当绑定设置为使用此连接类型时,只有具有绑定记录的记录将被返回。在上面的例子中,只有属于 FamilyPerson 记录将被返回。

  • LEFT JOIN:这是 CakePHP 使用的默认 JOIN 类型。即使没有绑定记录,也会返回所有记录。在上面的例子中,如果 Family 绑定类型设置为 LEFT,则即使 Person 记录不属于 Family,也会返回 Person 记录。

  • RIGHT JOINLEFT JOIN 的对立面,即使它们与主模型没有关联,也会显示相关模型的全部记录,并且只显示与相关模型链接的主模型的记录。

定义多个关联到同一模型

这个食谱展示了如何从一个模型设置多个关联到同一模型,这种需求通常在大多数应用程序中都会出现。

准备工作

为了完成这个食谱,我们需要一些样本表来操作。

  1. 使用以下 SQL 语句创建一个名为 addresses 的表:

    CREATE TABLE `addresses`(
    `id` INT UNSIGNED AUTO_INCREMENT NOT NULL,
    `address` TEXT NOT NULL,
    `city` VARCHAR(255) default NULL,
    `state` VARCHAR(255) NOT NULL,
    `zip` VARCHAR(10) NOT NULL,
    `country` CHAR(3) NOT NULL,
    PRIMARY KEY(`id`)
    );
    
    
  2. 使用以下 SQL 语句创建一个名为 users 的表:

    CREATE TABLE `users`(
    `id` INT UNSIGNED AUTO_INCREMENT NOT NULL,
    `billing_address_id` INT UNSIGNED default NULL,
    `home_address_id` INT UNSIGNED default NULL,
    `name` VARCHAR(255) NOT NULL,
    `email` VARCHAR(255) NOT NULL,
    PRIMARY KEY(`id`),
    KEY `billing_address_id`(`billing_address_id`),
    KEY `home_address_id`(`home_address_id`),
    CONSTRAINT `addresses__billing_address_id` FOREIGN KEY(`billing_address_id`) REFERENCES `addresses`(`id`),
    CONSTRAINT `addresses__home_address_id` FOREIGN KEY(`home_address_id`) REFERENCES `addresses`(`id`)
    );
    
    
  3. 使用以下 SQL 语句添加一些样本数据:

    INSERT INTO `addresses`(`id`, `address`, `city`, `state`, `zip`, `country`) VALUES
    (1, '123 Street', 'Palo Alto', 'CA', '94310', 'USA'),
    (2, '123 Street', 'London', 'London', 'SE10AA', 'GBR');
    INSERT INTO `users`(`billing_address_id`, `home_address_id`, `name`, `email`) VALUES
    (1, 2, 'John Doe', 'john.doe@example.com');
    
    
  4. 现在我们需要创建主模型。创建一个名为 user.php 的文件,并将其放置在您的 app/models 文件夹中,内容如下:

    <?php
    class User extends AppModel {
    }
    ?>
    
    

如何操作...

编辑User模型,并添加绑定定义以包括对Address模型的引用:

<?php
class User extends AppModel {
public $belongsTo = array(
'BillingAddress' => array(
'className' => 'Address'
),
'HomeAddress' => array(
'className' => 'Address'
)
);
}
?>

如果我们发出一个查找操作来获取User,我们会得到以下数据结构:

array(
'User' => array(
'id' => '1',
'billing_address_id' => '1',
'home_address_id' => '2',
'name' => 'John Doe',
'email' => 'john.doe@example.com',
),
'BillingAddress' => array(
'id' => '1',
'address' => '123 Street',
'city' => 'Palo Alto',
'state' => 'CA',
'zip' => '94310',
'country' => 'USA'
),
'HomeAddress' => array(
'id' => '2',
'address' => '123 Street',
'city' => 'London',
'state' => 'London',
'zip' => 'SE10AA',
'country' => 'GBR'
)
)

还有更多...

在这个例子中,我们用于绑定的命名约定是 CakePHP 用于字段名称的标准约定,其中每个大写字母都由一个下划线符号作为前缀,所有内容都转换为小写,并添加后缀_id。因此,绑定的标准字段名称BillingAddressbilling_address_id

然而,有时我们需要使用不符合此标准的字段名称。在这种情况下,我们可以使用foreignKey绑定设置来指定字段名称。例如,我们可以更改User模型定义,使HomeAddress的名称变为Address,这将使User模型看起来像这样:

<?php
class User extends AppModel {
public $belongsTo = array(
'BillingAddress' => array(
'className' => 'Address'
),
'Address' => array(
'className' => 'Address',
'foreignKey' => 'home_address_id'
)
);
}
?>

注意

当我们使用不同的别名来引用同一个模型时,某些模型回调实现,如beforeSave,需要更改以避免直接使用模型名称,而是使用所有模型都有的属性alias。更多关于这方面的信息可以从 Nick Baker 的文章中获得,该文章可在www.webtechnick.com/blogs/view/230/The_Power_of_CakePHP_aliases找到。

动态添加绑定

这个食谱展示了如何在查找操作之前设置新的绑定,包括在操作执行后自动删除的绑定,以及永久添加的绑定。

准备就绪

我们需要一些样本模型和数据来工作。按照食谱中的准备就绪部分,修改查找操作的绑定参数

如何操作...

如果在我们获取User时想要获取最新发布的Article,我们可以在User模型中添加一个永久的绑定。然而,如果我们需要根据需要添加绑定,那么在需要它的查找操作之前添加绑定会更智能,这样可以避免为其他操作带来不必要的开销。

我们可以添加所需的绑定,然后发出find操作:

$this->User->bindModel(array(
'hasOne' => array(
'LastArticle' => array(
'className' => 'Article',
'conditions' => array(
'LastArticle.published' => 1
),
'order' => array(
'LastArticle.created' => 'desc',
'LastArticle.id' => 'desc'
)
)
)
));
$user = $this->User->find('first', array(
'conditions' => array(
'User.id' => 1
),
'contain' => array(
'LastArticle' => array('fields' => array('title'))
)
));

上述代码会给我们以下数据结构:

array(
'User' => array(
'id' => '1',
'name' => 'John Doe',
'email' => 'john.doe@example.com',
),
'LastArticle' => array(
'title' => 'John\'s Post 4'
)
)

如果我们想要在请求结束时使绑定永久,但又不将其添加到User模型中,我们只需在bindModel()调用中将值false作为第二个参数添加(如果操作是paginate()调用,则需要这样做,因为这个调用将发出两个find操作):

$this->User->bindModel(array(
'hasOne' => array(
'LastArticle' => array(
'className' => 'Article',
'conditions' => array(
'LastArticle.published' => 1
),
'order' => array(
'LastArticle.created' => 'desc',
'LastArticle.id' => 'desc'
)
)
)
), false);

它是如何工作的...

当你发出bindModel()调用时,CakePHP 会像你指定在模型本身上一样添加绑定。如果你没有将方法第二个参数设置为false,那么该绑定将在find操作完成后自动删除。如果你设置了它以避免重置,那么它将保留到你的应用程序的脚本实例完成。

通过 bindModel() 指定绑定的格式是一个数组,通过绑定类型(belongsTo, hasOne, hasManyhasAndBelongsToMany 之一)进行索引,每个绑定类型的值是一个关联数组。

你可以在模型中定义每个关联(就像你通常做的那样),通过关联名称(如果它不同于指向的模型名称或如果你有要定义的绑定参数)进行索引,或者,可选地,简单地引用相关模型。

第三章。推动搜索

在本章中,我们将涵盖:

  • 执行 GROUP 和 COUNT 查询

  • 使用虚拟字段

  • 使用临时 JOIN 构建查询

  • 搜索所有匹配搜索词的项目

  • 实现自定义查找类型

  • 分页自定义查找类型

  • 实现基于 AJAX 的分页

简介

使用模型获取数据是任何 CakePHP 应用程序最重要的方面之一。因此,合理使用框架提供的查找函数可以确保我们应用程序的成功,并且同样重要的是确保我们的代码可读性和可维护性。

CakePHP 提供了以下基本查找类型:

  • all:用于查找所有匹配给定查找选项的记录。

  • count:用于计算匹配给定选项的记录数量。

  • first:用于查找匹配给定查找选项的第一个记录。

  • list:用于查找所有匹配给定查找选项的记录,并使用提供的格式格式化。

  • neighbors:根据特定字段的值查找匹配记录的前一个和后一个记录。

  • threaded:查找一组结果,并根据名为parent_id的字段值返回它们作为层次结构。

掌握这些类型就像理解所有类型都处理的可用查找选项一样简单。在本章中,我们有几个食谱来充分利用这些选项,并在需要时手动执行基于 SQL 的查询。

CakePHP 还允许我们定义自己的自定义查找类型,这将扩展三个基本类型,使我们的代码更加易于阅读。本章的最后几个食谱展示了如何创建自己的查找类型,并支持分页。

执行 GROUP 和 COUNT 查询

这个食谱展示了如何使用 CakePHP 的内置查找类型来执行相对复杂的GROUPCOUNT查询,包括两者的组合。

准备工作

为了完成这个食谱,我们需要一些示例表来操作。

  1. 使用以下 SQL 语句创建一个名为users的表:

    CREATE TABLE `users`(
    `id` INT UNSIGNED AUTO_INCREMENT NOT NULL,
    `name` VARCHAR(255) NOT NULL,
    `email` VARCHAR(255) NOT NULL,
    PRIMARY KEY(`id`)
    );
    
    
  2. 使用以下 SQL 语句创建一个名为blogs的表:

    CREATE TABLE `blogs`(
    `id` INT UNSIGNED AUTO_INCREMENT NOT NULL,
    `user_id` INT UNSIGNED NOT NULL,
    `name` VARCHAR(255) NOT NULL,
    PRIMARY KEY(`id`),
    KEY `user_id`(`user_id`),
    CONSTRAINT `blogs__users` FOREIGN KEY(`user_id`) REFERENCES `users`(`id`)
    );
    
    
  3. 使用以下 SQL 语句创建一个名为posts的表:

    CREATE TABLE `posts`(
    `id` INT UNSIGNED AUTO_INCREMENT NOT NULL,
    `blog_id` INT UNSIGNED NOT NULL,
    `title` VARCHAR(255) NOT NULL,
    `body` TEXT NOT NULL,
    `created` DATETIME NOT NULL,
    `modified` DATETIME NOT NULL,
    PRIMARY KEY(`id`),
    KEY `blog_id`(`blog_id`),
    CONSTRAINT `posts__blogs` FOREIGN KEY(`blog_id`) REFERENCES `blogs`(`id`)
    );
    
    
  4. 使用以下 SQL 语句添加一些示例数据:

    INSERT INTO `users`(`id`, `name`, `email`) VALUES
    (1, 'John Doe', 'john.doe@example.com'),
    (2, 'Jane Doe', 'jane.doe@example.com');
    INSERT INTO `blogs`(`user_id`, `name`) VALUES
    (1, 'John Doe\'s Blog'),
    (2, 'Jane Doe\'s Blog');
    INSERT INTO `posts`(`blog_id`, `title`, `body`, `created`, `modified`) VALUES
    (1, 'John\'s Post 1', 'Body for John\'s Post 1', '2010-04-19 14:00:00', '2010-04-19 14:00:00'),
    (1, 'John\'s Post 2', 'Body for John\'s Post 2', '2010-04-19 14:30:00', '2010-04-19 14:30:00'),
    (1, 'John\'s Post 3', 'Body for John\'s Post 3', '2010-04-20 14:00:00', '2010-04-20 14:00:00'),
    (1, 'John\'s Post 4', 'Body for John\'s Post 4', '2010-05-03 14:00:00', '2010-05-03 14:00:00'),
    (2, 'Jane\'s Post 1', 'Body for Jane\'s Post 1', '2010-04-19 15:00:00', '2010-04-19 15:00:00'),
    (2, 'Jane\'s Post 2', 'Body for Jane\'s Post 2', '2010-06-18 15:00:00', '2010-06-18 15:00:00'),
    (2, 'Jane\'s Post 3', 'Body for Jane\'s Post 3', '2010-10-06 15:00:00', '2010-10-06 15:00:00');
    
    
  5. 我们现在继续创建所需的模型。在名为post.php的文件中创建模型Post,并将其放置在您的app/models文件夹中,内容如下:

    <?php
    class Post extends AppModel {
    public $belongsTo = array('Blog');
    }
    ?>
    
    
  6. 我们将所有示例代码放入控制器的index()方法中。创建一个名为posts_controller.php的文件,并将其放置在您的app/controllers文件夹中,内容如下:

    <?php
    class PostsController extends AppController {
    public function index() {
    $this->set(compact('data'));
    }
    }
    ?>
    
    
  7. 现在,创建一个名为posts的文件夹,并将其放置在您的app/views文件夹中。在这个新创建的文件夹内,创建一个名为index.ctp的文件,内容如下:

    <?php debug($data); ?>
    
    

如何做...

通过指定find操作时的分组设置,按某个字段对行进行分组就像简单一样。例如,以下语句虽然本身并不实用,但展示了如何使用设置:

$data = $this->Post->find('all', array(
'group' => array('Blog.id')
));

如果我们还想获取每个分组集的行数,在我们的例子中意味着每个博客的帖子数,我们会这样做:

$data = $this->Post->find('all', array(
'fields' => array('COUNT(Post.id) AS total', 'Blog.*'),
'group' => array('Blog.id')
));

前面的查询将返回以下数据结构:

array(
array(
0 => array(
'total' => 4
),
'Blog' => array(
'id' => 1,
'user_id' => 1,
'name' => 'John Doe\'s Blog'
)
),
array(
0 => array(
'total' => 3
),
'Blog' => array(
'id' => 2,
'user_id' => 2,
'name' => 'Jane Doe\'s Blog'
)
)
)

现在我们确保每次我们有一个计算字段(这些字段在每个结果行的索引0中),它们都成为结果模型的一部分,以便更容易阅读。为此,我们覆盖afterFind()方法。如果您还没有,请确保在您的app/文件夹中创建一个名为app_model.php的文件。确保您的AppModel类包含以下内容:

<?php
class AppModel extends Model {
public function afterFind($results, $primary = false) {
if (!empty($results)) {
foreach($results as $i => $row) {
if (!empty($row[0])) {
foreach($row[0] as $field => $value) {
if (!empty($row[$this->alias][$field])) {
$field = 'total_' . $field;
}
$results[$i][$this->alias][$field] = $value;
}
unset($results[$i][0]);
}
}
}
return parent::afterFind($results, $primary);
}
}
?>

注意

每次覆盖模型方法,如beforeFind()afterFind(),请确保通过使用parent关键字调用父实现。

因此,之前使用GROUPCOUNT的查询现在将看起来像一个非常可读的结果集:

array(
array(
'Blog' => array(
'id' => 1,
'user_id' => 1,
'name' => 'John Doe\'s Blog'
),
'Post' => array(
'total' => 4
)
),
array(
'Blog' => array(
'id' => 2,
'user_id' => 2,
'name' => 'Jane Doe\'s Blog'
),
'Post' => array(
'total' => 3
)
)
)

如果我们想要根据创建月份细分每个博客的帖子数,我们必须添加另一个分组级别:

$data = $this->Post->find('all', array(
'fields' => array(
'CONCAT(YEAR(Post.created), \'-\', MONTH(Post.created)) AS period',
'COUNT(Post.id) AS total',
'Blog.*'
),
'group' => array('Blog.id', 'period')
));

考虑到我们的afterFind实现,前面的查询将产生以下结果:

array(
array(
'Blog' => array(
'id' => 1,
'user_id' => 1,
'name' => 'John Doe\'s Blog'
),
'Post' => array(
'period' => '2010-4',
'total' => 4
)
),
array(
'Blog' => array(
'id' => 1,
'user_id' => 1,
'name' => 'John Doe\'s Blog'
),
'Post' => array(
'period' => '2010-5',
'total' => 1
)
),
array(
'Blog' => array(
'id' => 2,
'user_id' => 2,
'name' => 'Jane Doe\'s Blog'
),
'Post' => array(
'period' => '2010-10',
'total' => 1
)
),
array(
'Blog' => array(
'id' => 2,
'user_id' => 2,
'name' => 'Jane Doe\'s Blog'
),
'Post' => array(
'period' => '2010-4',
'total' => 1
)
)
array(
'Blog' => array(
'id' => 2,
'user_id' => 2,
'name' => 'Jane Doe\'s Blog'
),
'Post' => array(
'period' => '2010-6',
'total' => 1
)
)
)

它是如何工作的...

我们使用group查找设置来指定将用于对结果行进行分组的字段。该设置以数组的形式给出,其中每个元素是要分组的字段。当我们指定多个字段时,例如菜谱中的最后一个示例,行分组将按照分组字段的给定顺序进行。

计算字段,即产生值的表达式(如菜谱中使用的COUNT(*) AS total表达式),放置在每个结果行的索引0中,因为它们不是在模型中定义的真实字段。正因为如此,我们覆盖了afterFind()方法,在获取查找操作的结果后执行,并使用一些基本逻辑确保这些计算字段被包含在结果行中,以一个更可读的索引:模型名称。

菜谱中的最后一个示例不仅展示了如何在多个字段上进行分组,而且还展示了如何正确使用一些 SQL 方法(如MONTHYEAR)以及别名,这样我们就可以轻松返回该表达式的值,并使用它来分组或可选地排序行。

参见

  • 使用虚拟字段

使用虚拟字段

在菜谱中,执行 GROUP 和 COUNT 查询,我们学习了如何将计算 SQL 表达式添加到find操作中。其中一些表达式可能需要定期用于模型,从而引入了虚拟字段的需求。

使用虚拟字段,我们可以得到 SQL 表达式的结果值,就像它们是我们模型的真实字段一样。这使我们能够以前更透明的方式得到之前菜谱中显示的相同结果,而不需要覆盖afterFind

准备工作

我们需要一些示例模型和数据来工作。遵循菜谱中的准备工作部分,执行 GROUP 和 COUNT 查询

如何做...

打开Post模型并添加以下所示的virtualfields定义:

<?php
class Post extends AppModel {
public $belongsTo = array('Blog');
public $virtualFields = array(
'period' => 'CONCAT(YEAR(Post.created), \'-\', MONTH(Post.created))',
'total' => 'COUNT(*)'
);
}
?>

要获取按创建周期分组的每篇博客的所有帖子计数,我们执行以下操作:

$data = $this->Post->find('all', array(
'fields' => array(
'period',
'total',
'Blog.*'
),
'group' => array('Blog.id', 'period')
));

使用我们的示例数据,前面的查询将产生以下数组结构,这与在执行 GROUP 和 COUNT 查询的最后一个示例中获得的完全相同的结果:

array(
array(
'Blog' => array(
'id' => 1,
'user_id' => 1,
'name' => 'John Doe\'s Blog'
),
'Post' => array(
'period' => '2010-4',
'total' => 4
)
),
array(
'Blog' => array(
'id' => 1,
'user_id' => 1,
'name' => 'John Doe\'s Blog'
),
'Post' => array(
'period' => '2010-5',
'total' => 1
)
),
array(
'Blog' => array(
'id' => 2,
'user_id' => 2,
'name' => 'Jane Doe\'s Blog'
),
'Post' => array(
'period' => '2010-10',
'total' => 1
)
),
array(
'Blog' => array(
'id' => 2,
'user_id' => 2,
'name' => 'Jane Doe\'s Blog'
),
'Post' => array(
'period' => '2010-4',
'total' => 1
)
)
array(
'Blog' => array(
'id' => 2,
'user_id' => 2,
'name' => 'Jane Doe\'s Blog'
),
'Post' => array(
'period' => '2010-6',
'total' => 1
)
)
)

在对模型执行find操作时,总是获取虚拟字段。唯一真正避免包含它们的方法是指定要获取的字段列表,并省略虚拟字段:

$data = $this->Post->find('all', array(
'fields' => array_keys($this->Post->schema())
));

注意

schema()模型函数返回模型中的实际字段列表,包括每个字段的信息,例如数据类型和长度。

我们现在将添加一种方法,使我们能够管理哪些虚拟字段(如果有的话)被返回。为此,我们重写beforeFind()afterFind()模型方法。如果您还没有,请确保在您的app/文件夹中创建一个名为app_model.php的文件。确保您的AppModel类包含以下内容:

<?php
class AppModel extends Model {
public function beforeFind($query) {
if (!empty($this->virtualFields)) {
$virtualFields = isset($query['virtualFields']) ?
$query['virtualFields'] :
array_keys($this->virtualFields);
if ($virtualFields !== true) {
$this->_backVirtualFields = $this->virtualFields;
$this->virtualFields = !empty($virtualFields) ?
array_intersect_key($this->virtualFields, array_flip((array) $virtualFields)) :
array();
}
}
return parent::beforeFind($query);
}
public function afterFind($results, $primary = false) {
if (!empty($this->_backVirtualFields)) {
$this->virtualFields = $this->_backVirtualFields;
}
return parent::afterFind($results, $primary);
}
}
?>

如果我们想在执行find操作时禁用虚拟字段,我们可以通过指定virtualFields查找设置为false来轻松实现。我们也可以将其设置为想要包含的虚拟字段列表。例如,要仅包含period虚拟字段,我们执行以下操作:

$person = $this->Post->find('all', array(
'virtualFields' => array('period')
));

它是如何工作的...

CakePHP 将虚拟字段几乎视为真实模型字段。它们并不完全像真实字段,因为我们不能在创建/编辑模型记录时为虚拟字段指定一个值。然而,在find操作方面,它们被像任何其他字段一样对待。

虚拟字段在针对它们所属的模型执行每个find操作时都会被包含。然而,有时我们不想或不需要某些虚拟字段。这在我们包含依赖于分组表达式(如COUNT)的虚拟字段时尤为重要,因为它们会影响返回的行数。在这些情况下,我们希望能够指定应该返回什么,甚至是否应该返回虚拟字段。

为了允许我们控制从find操作返回的虚拟字段,我们通过重写beforeFindafterFind模型回调添加一个新的查找设置。在执行find操作之前执行的beforeFind回调中,我们检查是否存在virtualFields设置。如果定义了此设置,我们使用其值来检查是否应该返回虚拟字段。

根据这些设置值,我们更改模型virtualFields属性的真正值。我们备份其原始值,然后在find操作完成后恢复,即在afterFind回调中。

参见

  • 执行 GROUP 和 COUNT 查询

使用临时的 JOIN 构建查询

CakePHP 有一个非常简单的方式来处理绑定,并且通过使用Containable行为,正如在第二章模型绑定中几个配方所展示的,我们在处理绑定时拥有很多灵活性。

然而,有时我们需要超出正常的查找操作,执行连接多个模型的查询,而不使用正常的绑定操作,以节省一些宝贵的查询。在这个配方中,我们将看到如何在执行模型查找时指定JOIN操作。

准备工作

我们需要一些示例模型和数据来工作。遵循配方中的准备工作部分,执行 GROUP 和 COUNT 查询

为了说明正常绑定操作与这个配方中展示的内容之间的区别,我们需要Containable行为。创建一个名为app_model.php的文件,并将其放置在您的app/文件夹中,内容如下。如果您已经有了,请确保您添加了以下所示的actsAs属性,或者您的actsAs属性包括Containable

<?php
class AppModel extends Model {
public $actsAs = array('Containable');
}
?>

我们还需要Blog模型。创建一个名为blog.php的文件,并将其放置在您的app/models文件夹中,内容如下:

<?php
class Blog extends AppModel {
public $belongsTo = array('User');
}
?>

如何做...

我们希望获取属于Blog的第一篇帖子及其所属的User信息。使用Containable(有关更多信息,请参阅第二章模型绑定中的配方限制 find 操作返回的绑定),我们执行以下操作:

$post = $this->Post->find('first', array(
'contain' => array(
'Blog' => array(
'fields' => array('name'),
'User' => array('fields' => array('name'))
)
)
));

这个操作是由 CakePHP 使用三个 SQL 查询来执行的:

SELECT `Post`.`id`, `Post`.`blog_id`, `Post`.`title`, `Post`.`body`, `Post`.`created`, `Post`.`modified`, `Blog`.`name`, `Blog`.`user_id` FROM `posts` AS `Post` LEFT JOIN `blogs` AS `Blog` ON (`Post`.`blog_id` = `Blog`.`id`) WHERE 1 = 1 LIMIT 1;
SELECT `Blog`.`name`, `Blog`.`user_id` FROM `blogs` AS `Blog` WHERE `Blog`.`id` = 1;
SELECT `User`.`name` FROM `users` AS `User` WHERE `User`.`id` = 1;

如果我们将相关的表合并到一个单一的操作中,我们可以节省一些这些查询。我们使用适当的连接查找设置来指定这些JOIN语句:

$post = $this->Post->find('first', array(
'fields' => array(
'Post.id',
'Post.title',
'Blog.name',
'User.name'
),
'joins' => array(
array(
'type' => 'inner',
'alias' => 'Blog',
'table' => $this->Post->Blog->table,
'conditions' => array(
'Blog.id = Post.blog_id'
)
),
array(
'type' => 'inner',
'alias' => 'User',
'table' => $this->Post->Blog->User->table,
'conditions' => array(
'User.id = Blog.user_id'
)
)
),
'recursive' => -1
));

前面的语句将生成以下 SQL 查询:

SELECT `Post`.`id`, `Post`.`blog_id`, `Post`.`title`, `Post`.`body`, `Post`.`created`, `Post`.`modified` FROM `posts` AS `Post` inner JOIN blogs AS `Blog` ON (`Blog`.`id` = `Post`.`blog_id`) inner JOIN users AS `User` ON (`User`.`id` = `Blog`.`user_id`) WHERE 1 = 1 LIMIT 1
And would generate the following data structure:
array(
'Post' => array(
'id' => 1,
'title' => 'John\'s Post 1'
),
'Blog' => array(
'name' => 'John Doe\'s Blog'
),
'User' => array(
'name' => 'John Doe'
)
)

它是如何工作的...

joins查找设置允许我们定义要添加到生成的 SQL 查询中的JOIN语句。在定义操作时,我们有完全的控制权,能够更改typeleft, rightinner之一),要连接的table,要使用的alias,以及连接时使用的conditions

我们使用此设置将Post模型与两个模型连接起来:通过其表和必需条件与Blog连接,以及使用其适当的表和条件与User连接。由于Post模型belongsTo``Blog模型,CakePHP 将自动尝试与它进行LEFT JOIN,除非我们告诉它不要这样做。

因此,我们将recursive设置为-1,强制 CakePHP 只使用我们定义的JOIN。如果移除递归语句,我们就必须为我们的Blog JOIN定义选择一个不同的别名,因为它将与 CakePHP 的内置绑定冲突。

参见

  • 在第二章的模型绑定中添加可包含功能

搜索所有匹配搜索条件的项目

在大多数网络应用程序中,查找与一组搜索词匹配的记录几乎是必不可少的。即使有大量更深入、更复杂的搜索解决方案,有时我们只需要简单的搜索。

这个配方展示了如何实现基于 LIKE 的搜索来查找匹配某些词的记录。

准备工作

我们需要一些示例模型和数据来工作。遵循配方中的 准备工作 部分,执行 GROUP 和 COUNT 查询

如何做到这一点...

如果我们想要找到所有包含单词 Post 1 或单词 Post 2 的帖子,无论是标题还是帖子内容,我们这样做:

$posts = $this->Post->find('all', array(
'fields' => array('Post.id', 'Post.title'),
'conditions' => array('or' => array(
array('Post.title LIKE ?' => '%Post 1%'),
array('Post.body LIKE ?' => '%Post 1%'),
array('Post.title LIKE ?' => '%Post 2%'),
array('Post.body LIKE ?' => '%Post 2%'),
)),
'recursive' => -1
));

前面的语句将产生以下结果:

array(
'Post' => array(
'id' => 1,
'title' => 'John\'s Post 1'
),
'Post' => array(
'id' => 2,
'title' => 'John\'s Post 2'
),
'Post' => array(
'id' => 5,
'title' => 'Jane\'s Post 1'
),
'Post' => array(
'id' => 6,
'title' => 'Jane\'s Post 2'
)
)

它是如何工作的...

LIKE-based 条件类似于任何其他模型查找条件,除了它们以特殊形式指定:它们成为条件键的一部分,并使用字符 ? 来指示实际值将被插入的位置,该值是一个实际的 LIKE 表达式。因此,以下条件:

array('Post.title LIKE ?' => '%term%')

将被评估为类似 SQL 的如下:

`Post`.`title` LIKE '%term%'

由于 LIKE 表达式被指定为数组索引,需要注意的是,我们需要将每个表达式包裹在其自己的数组中,以避免覆盖先前的表达式。为了说明这一点,让我们为 Post.title 字段添加另一个条件。

array(
'Post.title LIKE ?' => '%term%',
'Post.title LIKE ?' => '%anotherTerm%'
)

这将翻译成以下 SQL 表达式:

`Post`.`title` LIKE '%anotherTerm%'

自然地,第二个索引覆盖了第一个,因为它们都是相同的。因此,我们必须将两个表达式都包裹在数组中,以避免覆盖已使用的索引:

array(
array('Post.title LIKE ?' => '%term%'),
array('Post.title LIKE ?' => '%anotherTerm%')
)

这将翻译成以下 SQL 语句:

`Post`.`title` LIKE '%term%' OR `Post`.`title` LIKE '%anotherTerm%'

参见

  • 实现自定义查找类型

实现自定义查找类型

配方,搜索所有与搜索词匹配的项目,为我们提供了一个很好的起点来创建自定义查找类型。自定义查找类型允许我们扩展任何模型都有的基本查找类型,使我们的代码更易于阅读和扩展。

这个配方展示了如何创建一个自定义查找类型,允许 Post 模型对一组词进行搜索,从而扩展了前一个配方中展示的功能。

准备工作

我们需要一些示例模型和数据来工作。遵循配方中的 准备工作 部分,执行 GROUP 和 COUNT 查询

如何做到这一点...

  1. 打开 post.php 文件,并将 search 查找类型添加到 _findMethods 属性的查找方法列表中,同时添加 _findSearch() 方法的实际实现。

    <?php
    class Post extends AppModel {
    public $belongsTo = array('Blog');
    public $_findMethods = array('search' => true);
    protected function _findSearch($state, $query, $results = array()) {
    if ($state == 'before') {
    if (!empty($query['terms'])) {
    $fields = array('title', 'body');
    $conditions = array();
    foreach ((array) $query['terms'] as $term) {
    foreach ($fields as $field) {
    $model = $this->alias;
    if (strpos($field, '.') !== false) {
    list($model, $field) = explode('.', $field);
    }
    $conditions[] = array(
    $model . '.' . $field . ' LIKE ?' => '%'.$term.'%'
    );
    }
    }
    if (empty($query['fields'])) {
    $query['fields'] = array('Post.title', 'Post.body');
    }
    $query['conditions'][] = array('or' => $conditions);
    }
    return array_diff_key($query, array('terms'=>null));
    }
    return $results;
    }
    }
    ?>
    
    
  2. 我们现在可以通过指定要搜索的词列表来使用这些自定义查找类型:

    $posts = $this->Post->find('search', array(
    'terms' => array(
    'Post 1',
    'Post 2'
    ),
    'recursive' => -1
    ));
    
    
  3. 如果我们现在浏览到 http://localhost/posts,我们会得到四个帖子的 idtitle 字段,如下面的截图部分所示:如何做到这一点...

  4. 让我们现在也允许自定义查找类型的计数操作。因为我们想要一个通用的解决方案,所以我们将此添加到 AppModel 中。打开你的 app/ 文件夹中的 app_model.php 文件(如果没有,则创建它),并按照以下方式重写 find() 方法:

    <?php
    class AppModel extends Model {
    public function find($conditions=null, $fields=array(), $order=null, $recursive=null) {
    if (
    is_string($conditions) && $conditions=='count' &&
    is_array($fields) && !empty($fields['type']) &&
    array_key_exists($fields['type'],$this->_findMethods)
    ) {
    $fields['operation'] = 'count';
    return parent::find($fields['type'], array_diff_key(
    $fields,
    array('type'=>null)
    ));
    }
    return parent::find($conditions, $fields, $order, $recursive);
    }
    }
    ?>
    
    
  5. 现在编辑你的 app/models/post.php 文件,并对 _findSearch() 方法进行以下更改:

    protected function _findSearch($state, $query, $results = array()) {
    if ($state == 'before') {
    if (!empty($query['terms'])) {
    $fields = array('title', 'body');
    $conditions = array();
    foreach ((array) $query['terms'] as $term) {
    foreach ($fields as $field) {
    $model = $this->alias;
    if (strpos($field, '.') !== false) {
    list($model, $field) = explode('.', $field);
    }
    $conditions[] = array(
    $model . '.' . $field . ' LIKE ?' => '%'.$term.'%'
    );
    }
    }
    if (empty($query['fields'])) {
    $query['fields'] = array('Post.title', 'Post.body');
    }
    if (!empty($query['operation']) && $query['operation'] == 'count') {
    $query['fields'] = 'COUNT(*) AS total';
    }
    $query['conditions'][] = array('or' => $conditions);
    }
    return array_diff_key($query, array('terms'=>null));
    } elseif (
    $state == 'after' && !empty($query['operation']) &&
    $query['operation'] == 'count'
    ) {
    return (!empty($results[0][0]['total']) ? $results[0][0]['total'] : 0);
    }
    return $results;
    }
    
    
  6. 如果我们想要获取匹配一组术语的帖子数量,我们会这样做:

    $count = $this->Post->find('count', array(
    'type' => 'search',
    'terms' => array(
    'Post 1',
    'Post 2'
    )
    ));
    
    

    这将正确返回 4。

它是如何工作的...

自定义查找类型在模型的 _findMethods 属性中定义。我们通过将查找类型的名称添加到属性中作为其索引,并在包含查找类型的模型中将其值设置为 true 来添加类型。

负责处理实际查找类型的方法定名为以下语法:_findType(),其中 Type 是查找类型名称,首字母大写。对于一个名为 popular 的查找类型,方法将被命名为 _findPopular()

每个查找类型方法接收三个参数:

  • state: find 操作当前所处的状态。这可以是 before(在查找操作执行之前使用),或者 after(在查找操作完成后执行,这是修改获取到的结果的完美位置。)before 状态是我们更改查询参数以满足需求的地方。

  • query: 查询的数据,包含典型的查找设置(例如 fieldsconditions),以及 find 操作中指定的任何额外设置(在我们的案例中,terms)。

  • results: 仅在状态设置为 after 时适用,并包含 find 操作的结果。

当查找状态设置为 before 时,自定义查找类型实现需要返回查询,作为一个查找设置的数组。因此,在我们的实现中,我们寻找一个名为 terms 的自定义查找设置。如果有指定的术语,我们使用它们向一组固定的字段添加基于 LIKE 的条件。一旦完成,我们返回修改后的查询。

当状态设置为 after 时,实现需要返回结果。这是在返回之前修改结果行(如果需要)的机会。在我们的实现中,我们简单地按收到的样子返回它们。

菜单的最后部分展示了如何为我们的自定义查找类型添加计数支持。这是 CakePHP 默认不提供的功能,因此我们实现了自己的解决方案。我们通过重写 find() 方法并确保满足一系列条件来实现这一点:

  1. 正在执行的 find 操作设置为 count

  2. 查询中指定了 type 设置

  3. type 设置实际上是一个有效的自定义查找类型

当这些条件满足时,我们添加一个名为 operation 的新查询参数,将其设置为 count,然后使用自定义查找类型调用父 find() 实现方法。这样,我们的查找实现可以检查 operation 查找设置,当它设置为 count 时,它将强制 fields 查找设置在 before 状态下为 COUNT(*),并在 after 状态下正确获取计数操作的结果。

相关内容

  • 分页自定义查找类型

  • 搜索所有匹配搜索词的项目

分页自定义查找类型

配方,实现自定义查找类型,展示了扩展内置模型查找类型的力量,包括支持使用实现的自定义类型来获取记录或计数。

现在我们知道了如何获取和计数自定义查找类型,我们可以轻松地分页一组结果行。这个配方展示了如何使用 CakePHP 内置的分页支持来分页一组作为自定义查找类型结果出现的行。

准备工作

我们需要一些示例模型和数据来工作,并且我们需要在 AppModel 中覆盖 find() 方法以允许在自定义查找类型上执行 count 操作。因此,请确保你遵循整个配方,实现自定义查找类型,包括其 准备工作 部分。

如何操作...

  1. 在你的 app/controllers 文件夹中创建一个名为 posts_controller.php 的文件。如果你已经有了,请确保它的 index() 方法如下:

    <?php
    class PostsController extends AppController {
    public function index() {
    $this->paginate['Post'] = array(
    'search',
    'fields' => array(
    'Post.id',
    'Post.title'
    ),
    'terms' => array(
    'Post 1',
    'Post 2'
    ),
    'limit' => 3
    );
    $posts = $this->paginate('Post');
    $this->set(compact('posts'));
    }
    }
    ?>
    
    
  2. index 动作创建视图。如果你的 app/views 文件夹中没有名为 posts 的文件夹,请创建它。接下来,在你的 app/views 文件夹中创建一个名为 index.ctp 的文件,内容如下:

    <p>
    <?php echo $this->Paginator->prev(); ?>
    &nbsp;
    <?php echo $this->Paginator->numbers(); ?>
    &nbsp;
    <?php echo $this->Paginator->next(); ?>
    </p>
    <ul>
    <?php foreach($posts as $post) { ?>
    <li>#<?php echo $post['Post']['id']; ?>: <?php echo $post['Post']['title']; ?></li>
    <?php } ?>
    </ul>
    
    
  3. 如果我们现在浏览到 http://localhost/posts,我们会看到一个分页的匹配帖子列表,显示两页中的前三个帖子,如下截图所示:如何操作...

工作原理...

要分页自定义查找类型,我们需要指定查找类型的名称作为分页设置的索引 0 的值(或如果没有定义索引,则为第一个值)。然后我们可以将任何自定义查找设置作为分页设置的一部分传递,如下代码片段所示:

$this->paginate['Post'] = array(
'search',
'terms' => array(
'Post 1',
'Post 2'
),
'limit' => 3
);

CakePHP 的 paginate() 方法首先会发出一个 count(在 type 查找设置中指定查找类型名称)来获取总行数,然后使用自定义查找类型执行 find 操作来获取当前页面的行。

相关内容

  • 实现基于 AJAX 的分页

实现基于 AJAX 的分页

之前的配方,分页自定义查找类型,展示了如何分页自定义查找类型。每个页面链接都会更改浏览器位置,强制重新加载页面上的所有元素。

这个配方允许我们使用 AJAX(使用 jQuery javascript 库)只加载真正需要的内容,因此每次页面更改时,只有行集会更改,而不需要加载整个新页面。

准备工作

我们需要一些示例模型和数据来工作,并且需要一个自定义查找类型的完整分页功能。遵循整个配方,包括其准备就绪部分,即分页自定义查找类型

如何做到这一点...

  1. 我们首先将jQuery javascript库添加到我们的布局中。如果你还没有,请在你的app/views/layouts目录中创建一个名为default.ctp的文件。确保你添加了 jQuery 库的链接(这里我们使用的是 Google 托管版本),一个用于显示加载信息的占位符(在 AJAX 连接进行时显示),并且将视图内容包裹在一个 ID 设置为content的 DIV 中。

    <head>
    <title><?php echo $title_for_layout; ?></title>
    <?php echo $this->Html->script('http://ajax.googleapis.com/ajax/libs/jquery/1.4.2/jquery.min.js'); ?>
    </head>
    <body>
    <div id="main">
    <div id="loading" style="display: none; float: right;">Loading...</div>
    <div id="content">
    <?php echo $content_for_layout; ?>
    </div>
    </div>
    </body>
    </html>
    
    
  2. 打开PostsController并添加RequestHandler组件,以及Jquery助手引擎(控制器其余部分保持不变):

    <?php
    class PostsController extends AppController {
    public $components = array('RequestHandler');
    public $helpers = array('Js' => 'Jquery');
    public function index() {
    $this->paginate['Post'] = array(
    'search',
    'terms' => array(
    'Post 1',
    'Post 2'
    ),
    'limit' => 3
    );
    $posts = $this->paginate('Post');
    $this->set(compact('posts'));
    }
    }
    ?>
    
    
  3. 现在,让Paginator助手知道我们正在使用基于 AJAX 的分页。编辑视图文件app/views/posts/index.ctp并添加以下高亮行:

    <?php
    $this->Paginator->options(array(
    'evalScripts' => true,
    'update' => '#content',
    'before' => $this->Js->get('#loading')->effect('fadeIn', array('speed'=>'fast')),
    'complete' => $this->Js->get('#loading')->effect('fadeOut', array('speed'=>'fast')),
    ));
    ?>
    <p>
    <?php echo $this->Paginator->prev(); ?>
    &nbsp;
    <?php echo $this->Paginator->numbers(); ?>
    &nbsp;
    <?php echo $this->Paginator->next(); ?>
    </p>
    <ul>
    <?php foreach($posts as $post) { ?>
    <li>#<?php echo $post['Post']['id']; ?>: <?php echo $post['Post']['title']; ?></li>
    <?php } ?>
    </ul>
    <?php echo $this->Js->writeBuffer(); ?>
    
    

它是如何工作的...

当更新设置指定给Paginator助手的options()方法时,Paginator知道它正在处理基于 AJAX 的分页。更新设置指向当每个分页链接被点击时内容发生变化的 DOM 元素的 ID。在我们的例子中,这个 DOM 元素是一个 ID 设置为content的 DIV,它在布局中定义。

我们指定给Paginator助手的另一个选项是evalScripts,它告诉助手评估任何作为 AJAX 请求结果获得的 JavaScript 代码。这样,当通过 AJAX 获取结果页面时,JQuery 引擎自动添加的 JavaScript 代码将被执行。同样,我们需要打印出这段生成的代码,我们通过在index.ctp视图的末尾调用writeBuffer()方法来实现。

我们使用的另外两个选项是beforecomplete,它们直接发送到 AJAX 操作。before选项在 AJAX 请求之前执行,是我们显示加载 DIV 的理想位置。complete选项在 AJAX 操作完成后执行,用于隐藏加载 DIV。

我们也可以将 JavaScript 代码指定给beforecomplete选项,而不是使用 jQuery 引擎提供的助手方法。可以通过以下方式更改选项来达到相同的效果:

<?php
$this->Paginator->options(array(
'evalScripts' => true,
'update' => '#content',
'before' => '$("#loading").fadeIn("fast");',
'complete' => '$("#loading").fadeOut("fast");'
));
?>

第四章:验证和行为

在本章中,我们将涵盖:

  • 添加多个验证规则

  • 创建自定义验证规则

  • 在行为中使用回调

  • 使用行为添加新的字段以保存

  • 使用 Sluggable 行为

  • 使用 Geocodable 行为进行地址地理编码

简介

本章讨论了 CakePHP 模型中两个对大多数应用程序至关重要的方面:验证和行为。

当我们将信息保存到数据源(如数据库)时,CakePHP 会自动确保数据被引号包围,以防止攻击,SQL 注入是最常见的一种。如果我们还需要确保数据遵循某种格式,例如,电话号码是有效的,我们使用验证规则。

有时候,我们不仅需要验证我们正在处理的数据。在某些情况下,我们需要为最终用户无法指定的字段设置值,但这些字段是我们应用程序逻辑的一部分。CakePHP 的行为允许我们通过回调在数据保存之前或之后操作数据来扩展模型提供的功能。

第三个食谱展示了如何在行为中使用模型回调(如 beforeFindafterFind),而第四个食谱展示了如何在使用 save 操作时使用行为添加额外的字段值。

本章的最后两个食谱给出了如何使用 Sluggable 行为创建 SEO 友好 URL 的示例,以及如何使用 Geocodable 行为为 Address 模型添加地理编码支持的示例。

添加多个验证规则

这个食谱展示了如何不仅使用 CakePHP 提供的一些基本验证规则,而且还展示了如何为每个字段使用多个这些规则。

准备工作

为了完成这个食谱,我们需要一个样本表来工作。使用以下 SQL 语句创建一个名为 profiles 的表:

CREATE TABLE `profiles`(
`id` INT UNSIGNED AUTO_INCREMENT NOT NULL,
`email` VARCHAR(255) NOT NULL,
`name` VARCHAR(255) default NULL,
`twitter` VARCHAR(255) default NULL,
PRIMARY KEY(`id`)
);

我们现在继续创建所需的模型。在 app/models 文件夹中创建名为 Profile 的模型,文件名为 profile.php,内容如下:

<?php
class Profile extends AppModel {
public $validate = array(
'email' => array('rule' => 'notEmpty'),
'name' => array('rule' => 'notEmpty')
);
}
?>

在你的 app/controllers 文件夹中创建相应的控制器 ProfilesController,文件名为 profiles_controller.php,内容如下:

<?php
class ProfilesController extends AppController {
public function add() {
if (!empty($this->data)) {
$this->Profile->create();
if ($this->Profile->save($this->data)) {
$this->Session->setFlash('Profile created');
$this->redirect('/');
} else {
$this->Session->setFlash('Please correct the errors');
}
}
}
}
?>

在你的 app/views 文件夹中创建一个名为 profiles 的文件夹。创建一个名为 add.ctp 的视图来保存表单,并将其放置在你的 app/views/profiles 文件夹中,内容如下:

<?php
echo $this->Form->create();
echo $this->Form->inputs(array(
'email',
'name',
'twitter'
));
echo $this->Form->end('Create');
?>

如何做到这一点...

我们已经为 emailname 字段设置了基本的验证规则,这保证了这些字段中的任何一个都不能为空。现在我们想要添加另一个验证规则,以确保输入的电子邮件始终是有效的电子邮件地址。编辑 Profile 模型,并按以下方式更改定义的验证规则:

class Profile extends AppModel {
public $validate = array(
'email' => array(
'valid' => array(
'rule' => 'email',
'message' => 'The email entered is not a valid email address'
),
'required' => array(
'rule' => 'notEmpty',
'message' => 'Please enter an email'
)
),
'name' => array('rule' => 'notEmpty')
);
}

如果我们现在浏览到http://localhost/profiles/add并点击创建按钮而不输入任何信息,我们应该看到email字段的定制错误消息和name字段的默认错误消息,如下面的截图所示:

如何操作...

如果我们指定了一个无效的电子邮件地址,验证消息应该更改为视图中指定的消息。

它是如何工作的...

模型validate属性中指定的每个字段都可以包含任意数量的验证规则。当我们指定多个规则时,我们将它们包裹在一个数组中,并用描述性键对其进行索引,以帮助我们识别哪个规则失败了。因此,我们选择用required键索引notEmpty规则,用valid键索引email规则。

当我们指定多个验证规则时,CakePHP 将按照我们添加到validate属性时的顺序评估每个规则。如果一个字段有多个验证规则失败,则最后失败的规则将用于触发错误消息。在我们的例子中,第一个规则是valid,第二个是required。因此,如果两个规则都失败了,该字段将被设置为失败required规则。

如果我们想要确保某个规则在所有其他规则之后执行,我们使用last规则设置。将其设置为true将确保特定规则在所有其他规则之后执行。在我们的例子中,我们可以在email字段的规则列表中首先定义required验证,并将其last设置设置为true,这将产生与在所有其他规则之后定义required规则相同的结果。

更多内容...

在这个菜谱中,我们使用了模型来指定对于每个失败的规则显示哪个错误消息。我们也可以选择在视图中这样做。

使用标识每个规则的索引,我们可以指定在验证失败时应该显示哪个错误消息。我们通过在字段定义中将error选项设置为错误消息数组来实现,每个错误消息都通过匹配的验证规则键(在我们的情况下,是email字段的requiredvalid之一)进行索引。

编辑app/views/profiles/add.ctp文件,并按以下方式更改email字段定义:

<?php
echo $this->Form->create();
echo $this->Form->inputs(array(
'email' => array(
'error' => array(
'required' => 'Please enter an email',
'valid' => 'The email entered is not a valid email address'
)
),
'name',
'twitter'
));
echo $this->Form->end('Create');
?>

参见

  • 国际化应用程序章节中国际化模型验证消息

创建自定义验证规则

CakePHP 提供了一些内置的验证规则,这些规则一起满足了大多数应用程序的需求。以下表格列出了内置的验证规则(位于 CakePHP 的Validation类中。)

规则 目的
_alphaNumeric 检查值是否只包含整数或字母。
_between 检查值的字符串长度是否在指定的范围内。
_blank 如果值是空的,或者只包含空格(空白字符、制表符、换行符等),则成功。
_boolean 检查值是否可以解释为布尔值。
_cc 验证信用卡号码。
_comparison 使用指定的运算符将值与给定的值进行比较。
_custom 使用自定义正则表达式验证值。
_date 使用给定的格式或正则表达式将值验证为日期。
_decimal 如果值是有效的十进制数,则成功。
_email 验证电子邮件地址。
_equalTo 如果值等于给定的值,则成功。
_extension 将值解释为文件名并检查给定的扩展名。
_inList 检查值是否在允许值的列表中。
_ip 验证 IP 地址。
_maxLength 检查字符串值的长度不超过一定数量的字符。
_minLength maxLength 类似,但确保字符串值至少有给定数量的字符。
_money 检查值是否是有效的货币金额。
_multiple 验证多选与一组选项。
_numeric 如果值是数字,则成功。
_phone 检查电话号码。
_postal 验证邮政编码。
_range 如果值在数值范围内,则成功。
_ssn 检查社会保障/国家身份号码。
_time 将值验证为时间(24 小时格式)。
_uuid 验证值是否为 UUID。
_url 如果值是有效的 URL,则成功。

然而,有时我们需要自定义验证,或者我们需要更改现有验证的方式。

在这个菜谱中,我们将学习如何创建自定义验证规则来检查给定 Twitter 用户名的有效性。

准备就绪

我们需要一些样本模型来工作。遵循菜谱“添加多个验证规则”中的“准备就绪”部分。

如何做...

通过打开你的 app/models/profile.php 文件来编辑 Profile 模型,并做出以下更改:

class Profile extends AppModel {
public $validate = array(
'email' => array('rule' => 'notEmpty'),
'name' => array('rule' => 'notEmpty'),
'twitter' => array(
'rule' => 'validateTwitter',
'allowEmpty' => true,
'message' => 'This twitter account is not valid'
)
);
protected static $httpSocket;
protected function validateTwitter($data) {
if (!isset(self::$httpSocket)) {
App::import('Core', 'HttpSocket');
self::$httpSocket = new HttpSocket();
}
$value = current($data);
self::$httpSocket->get('http://twitter.com/status/user_timeline/' . $value . '.json?count=1');
return (self::$httpSocket->response['status']['code'] != 404);
}
}

如果我们现在浏览到 http://localhost/profiles/add 并在输入一个不存在的 Twitter 账号后点击“创建”按钮,我们应该会看到以下截图所示的twitter字段的错误信息:

如何做...

如果我们指定一个有效的账号,或者将其留空,则不会为twitter字段显示错误信息。

如何工作...

当我们将 rule 验证选项设置为模型中可用的方法名称(在我们的例子中是 validateTwitter())时,CakePHP 会在字段需要验证时调用该方法。

validateTwitter() 方法,像任何自定义验证方法一样,其第一个参数接收一个数组。这个数组按字段名称索引,值设置为用户输入的值。在前一个截图所示的例子中,data 参数如下所示:

array('twitter' => 'nonexistingtwitteraccount')

验证方法需要返回一个布尔值来指示成功:如果验证成功,则返回 true;如果失败,则返回 false。如果我们没有将 allowEmpty 选项设置为 true,那么当字段值为空时,验证方法也会被调用。

注意

如果自定义验证方法返回一个字符串,则字段会被标记为验证失败,并使用返回的字符串作为错误信息。

validateTwitter() 方法首先检查 CakePHP 的 HttpSocket 类的实例是否已经设置。我们使用静态实例来确保类只初始化一次,从而避免在相同过程中多次调用该方法时进行不必要的处理。

一旦我们有了 HttpSocket 实例,我们就获取要验证的值(如上所示,数组中设置的第一个值),并使用它来获取推特 URL 的内容。

注意

我们本可以使用 [twitter.com/\(account`](http://twitter.com/\)account)URL,该 URL 返回包含用户最新推文的 HTML。然而,我们选择使用JSON请求,并将推文数量限制为1`,以减少服务器带宽的使用。

这个公开可用的推特 URL 用于获取推特账户的时间线,当账户未在推特上注册时,返回 HTTP 状态码 404。如果状态码确实是 404,我们认为推特账户不存在,从而验证失败。任何其他状态码都将导致验证成功。

更多内容...

一些自定义验证方法需要除了要验证的值之外的信息才能判断验证是否成功。幸运的是,CakePHP 不仅通过第二个参数发送用于执行验证的选项数组,还提供了一个简单的方法来向我们的验证方法添加参数。使用我们的示例,我们现在希望能够提供在检查推特账户时使用的一个不同的 URL。

要利用选项数组,通过打开 app/models/profile.php 文件编辑 Profile 模型,进行以下更改:

class Profile extends AppModel {
public $validate = array(
'email' => array('rule' => 'notEmpty'),
'name' => array('rule' => 'notEmpty'),
'twitter' => array(
'rule' => 'validateTwitter',
'allowEmpty' => true,
'url' => 'http://twitter.com/%TWITTER%'
)
);
protected function validateTwitter($data, $options) {
static $httpSocket;
if (!isset($httpSocket)) {
App::import('Core', 'HttpSocket');
$httpSocket = new HttpSocket();
}
$options = array_merge(array(
'url' => 'http://twitter.com/status/user_timeline/%TWITTER%.json?count=1'
), $options);
$value = current($data);
$httpSocket->get(str_ireplace('%TWITTER%', $value, $options['url']));
return ($httpSocket->response['status']['code'] != 404);
}
}

如果我们想要利用额外的参数能力,而不是使用选项数组,我们只需向验证方法添加参数,并将这些参数值作为 validate 定义中的元素传递。为此,通过打开 app/models/profile.php 文件编辑 Profile 模型,进行以下更改:

class Profile extends AppModel {
public $validate = array(
'email' => array('rule' => 'notEmpty'),
'name' => array('rule' => 'notEmpty'),
'twitter' => array(
'rule' => array(
'validateTwitter',
'http://twitter.com/%TWITTER%'
),
'allowEmpty' => true
)
);
protected static $httpSocket;
protected function validateTwitter($data, $url = 'http://twitter.com/status/user_timeline/%TWITTER%.json?count=1') {
if (!isset(self::$httpSocket)) {
App::import('Core', 'HttpSocket');
self::$httpSocket = new HttpSocket();
}
$value = current($data);
self::$httpSocket->get(str_ireplace('%TWITTER%', $value, $url));
return (self::$httpSocket->response['status']['code'] != 404);
}
}

参见

  • 添加多个验证规则

在行为中使用回调

CakePHP 行为不仅是一种扩展模型功能的好方法,还可以在不同模型和应用程序之间共享该功能。使用行为,我们可以使模型代码简洁明了,提取与我们的业务逻辑不直接相关但仍然影响模型行为的代码。

在本教程中,我们将学习如何使用模型回调自动检索每个配置文件的最新推文,以及如何向行为添加自定义验证方法。

准备工作

我们需要一些示例模型来工作。遵循食谱添加多个验证规则中的准备就绪部分。

我们还需要一个列出所有配置文件的方法。编辑您的app/controllers/profiles_controller.php文件,并将以下index()方法添加到ProfilesController类中:

public function index() {
$profiles = $this->Profile->find('all');
$this->set(compact('profiles'));
}

在名为app/views/profiles/index.ctp的文件中创建相应的视图,内容如下:

<?php foreach($profiles as $profile) { ?>
<p>
<?php echo $this->Html->link(
$profile['Profile']['twitter'],
'http://twitter.com/' . $profile['Profile']['twitter'],
array('title' => $profile['Profile']['twitter'])
); ?>
</p>
<?php } ?>

如何操作...

  1. 在名为twitter_account.php的文件中创建一个名为TwitterAccountBehavior的类,并将其放置在您的app/models/behaviors文件夹中,内容如下:

    <?php
    App::import('Core', 'HttpSocket');
    class TwitterAccountBehavior extends ModelBehavior {
    protected static $httpSocket;
    public function setup($model, $config = array()) {
    parent::setup($model, $config);
    $this->settings[$model->alias] = array_merge(array(
    'field' => 'twitter'
    ), $config);
    }
    protected function timeline($twitter, $count = 10, $returnStatus = false) {
    if (!isset(self::$httpSocket)) {
    self::$httpSocket = new HttpSocket();
    }
    $content = self::$httpSocket->get('http://twitter.com/status/user_timeline/' . $twitter . '.json?count=' . $count);
    $status = self::$httpSocket->response['status']['code'];
    if (!empty($content)) {
    $content = json_decode($content);
    }
    if ($returnStatus) {
    return compact('status', 'content');
    }
    return $content;
    }
    }
    ?>
    
    
  2. 现在我们已经创建了一个具有实现setup()方法和用于从 Twitter 账户获取推文的辅助timeline()方法的动作,我们可以继续添加所需的验证。

    将以下自定义验证方法添加到TwitterAccountBehavior类中:

    public function validateTwitter($model, $data) {
    $field = $this->settings[$model->alias]['field'];
    if (!empty($data[$field])) {
    $value = $data[$field];
    $result = $this->timeline($value, 1, true);
    if ($result['status'] == 404) {
    $result = false;
    }
    }
    return $result;
    }
    
    
  3. 让我们现在将行为附加到Profile模型,并为twitter字段添加验证。打开您的app/models/profile.php文件,并添加以下actsAs属性和twitter字段验证:

    <?php
    class Profile extends AppModel {
    public $actsAs = array('TwitterAccount');
    public $validate = array(
    'email' => array('rule' => 'notEmpty'),
    'name' => array('rule' => 'notEmpty'),
    'twitter' => array(
    'rule' => 'validateTwitter',
    'allowEmpty' => true,
    'message' => 'This twitter account is not valid'
    )
    );
    }
    ?>
    
    
  4. 就像在创建自定义验证规则的食谱中一样,输入不存在的 Twitter 账户应该显示以下截图所示的twitter字段的错误消息:如何操作...

  5. 让我们现在使用其他回调在执行查找操作后为每个配置文件获取一定数量的推文。将以下beforeFind()afterFind()方法添加到TwitterAccountBehavior类中:

    public function beforeFind($model, $query) {
    $this->settings[$model->alias]['tweets'] = !isset($query['tweets']) ? true : $query['tweets'];
    return parent::beforeFind($model, $query);
    }
    public function afterFind($model, $results, $primary) {
    $rows = parent::afterFind($model, $results, $primary);
    if (!is_null($rows)) {
    $results = $rows;
    }
    if (!empty($this->settings[$model->alias]['tweets'])) {
    $field = $this->settings[$model->alias]['field'];
    $count = is_int($this->settings[$model->alias]['tweets']) ?
    $this->settings[$model->alias]['tweets'] :
    10;
    foreach($results as $i => $result) {
    $twitter = $result[$model->alias][$field];
    $tweets = array();
    if (!empty($result[$model->alias][$field])) {
    $result = $this->timeline($twitter, $count);
    if (!empty($result) && is_array($result)) {
    foreach($result as $tweet) {
    $tweets[] = array(
    'created' => date('Y-m-d H:i:s', strtotime($tweet->created_at)),
    'source' => $tweet->source,
    'user' => $tweet->user->screen_name,
    'text' => $tweet->text
    );
    }
    }
    }
    $results[$i]['Tweet'] = $tweets;
    }
    }
    return $results;
    }
    
    
  6. 编辑app/views/profiles/index.ctp视图,并做出以下更改:

    <?php foreach($profiles as $profile) { ?>
    <p>
    <?php echo $this->Html->link(
    $profile['Profile']['twitter'],
    'http://twitter.com/' . $profile['Profile']['twitter'],
    array('title' => $profile['Profile']['twitter'])
    ); ?>
    <?php if (!empty($profile['Tweet'])) { ?>
    <ul>
    <?php foreach($profile['Tweet'] as $tweet) { ?>
    <li>
    <code><?php echo $tweet['text']; ?></code>
    from <?php echo $tweet['source']; ?>
    on <?php echo $tweet['created']; ?>
    </li>
    <?php } ?>
    </ul>
    <?php } ?>
    </p>
    <?php } ?>
    
    

在添加有效的 Twitter 账户后,浏览到http://localhost/profiles将生成一个列表,如下面的截图所示:

如何操作...

它是如何工作的...

我们从我们的TwitterAccountBehavior的骨架开始,实现了由 CakePHP 在行为附加到模型时自动调用的setup()方法,以及timeline()方法,这仅仅是创建自定义验证规则食谱中显示的validateTwitter()方法,经过优化以供重用。

beforeFind回调在 CakePHP 即将执行查找操作时触发,我们使用它来检查自定义的tweets查找设置的是否存在。我们使用此设置允许开发者通过将其设置为false来禁用推文的获取:

$this->Profile->find('all', array('tweets' => false));

或者指定要获取的推文数量。例如,如果我们只想获取最新的推文,我们会这样做:

$this->Profile->find('all', array('tweets' => 1));

afterFind回调在执行查找操作后执行,并给我们一个修改结果的机会。因此,我们检查是否被告知要获取推文,如果是,我们使用timeline()方法获取指定数量的推文。然后我们将每条推文的基本信息追加到每个配置文件的索引Tweet中。

还有更多...

在我们的实现中,有一件事是明确的,除非我们将 tweets 查找选项设置为 false,否则我们将在对 Profile 模型执行的每个 find 操作中获取每个资料记录的推文。添加缓存支持将大大提高我们 find 操作的性能,因为我们只有在缓存信息不再有效时才会获取推文。

注意

关于通过 CakePHP 的 Cache 类获取更多缓存信息的详细信息,请参阅 book.cakephp.org/view/1511/Cache

我们将允许开发者指定在缓存推文时使用什么缓存配置。打开 TwitterAccountBehavior 类,并对其 setup() 方法进行以下修改:

public function setup($model, $config = array()) {
parent::setup($model, $config);
$this->settings[$model->alias] = array_merge(array(
'field' => 'twitter',
'cache' => 'default'
), $config);
}

在编辑 TwitterAccountBehavior 类时,对其 afterFind() 方法进行以下修改:

public function afterFind($model, $results, $primary) {
$rows = parent::afterFind($model, $results, $primary);
if (!is_null($rows)) {
$results = $rows;
}
if (!empty($this->settings[$model->alias]['tweets'])) {
$field = $this->settings[$model->alias]['field'];
$count = is_int($this->settings[$model->alias]['tweets']) ?
$this->settings[$model->alias]['tweets'] :
10;
$cacheConfig = $this->settings[$model->alias]['cache'];
foreach($results as $i => $result) {
$twitter = $result[$model->alias][$field];
$tweets = array();
if (!empty($cacheConfig)) {
$tweets = Cache::read('tweets_' . $twitter, $cacheConfig);
}
if (empty($tweets) && !empty($result[$model->alias][$field])) {
$result = $this->timeline($twitter, $count);
if (!empty($result) && is_array($result)) {
foreach($result as $tweet) {
$tweets[] = array(
'created' => date('Y-m-d H:i:s', strtotime($tweet->created_at)),
'source' => $tweet->source,
'user' => $tweet->user->screen_name,
'text' => $tweet->text
);
}
}
Cache::write('tweets_' . $twitter, $tweets, $cacheConfig);
}
$results[$i]['Tweet'] = $tweets;
}
}
return $results;
}

最后,添加以下 beforeDeleteafterDelete 回调实现:

public function beforeDelete($model, $cascade = true) {
$field = $this->settings[$model->alias]['field'];
$this->settings[$model->alias]['delete'] = $model->field($field, array(
$model->primaryKey => $model->id
));
return parent::beforeDelete($cascade);
}
public function afterDelete($model) {
if (!empty($this->settings[$model->alias]['delete'])) {
$cacheConfig = $this->settings[$model->alias]['cache'];
$twitter = $this->settings[$model->alias]['delete'];
Cache::delete('tweets_' . $twitter, $cacheConfig);
}
return parent::afterDelete($model);
}

使用 beforeDelete() 我们存储要删除的推文。如果确实删除了资料,afterDelete() 方法将删除其缓存的推文。

参见

  • 添加多个验证规则

  • 创建自定义验证规则

  • 使用行为为保存添加新字段

使用行为为保存添加新字段

在行为中使用回调 的配方中,我们学习了如何实现不同的模型回调来自动执行一些任务。在这个配方中,我们将继续这个过程,并学习如何自动保存可能不在 save 操作中提供的资料。

我们将使用本章中一直在使用的 Twitter 示例,这样当保存资料时,其 Twitter URL 和最后一条推文将在创建新记录或更新现有记录时保存。

准备工作

我们需要一个工作的 TwitterAccountBehavior 以及其控制器、模型和视图。遵循 在行为中使用回调 的配方(在行为中不需要启用缓存,因此可以省略 还有更多 部分)。

通过以下 SQL 命令向用户资料表添加两个字段,urllast_tweet

ALTER TABLE `profiles`
ADD COLUMN `url` VARCHAR(255) default NULL,
ADD COLUMN `last_tweet` VARCHAR(140) default NULL;

如何操作...

  1. 编辑你的 app/models/behaviors/twitter_account.php 文件,并将以下 beforeSave 实现添加到 TwitterAccountBehavior 类中:

    public function beforeSave($model) {
    $field = $this->settings[$model->alias]['field'];
    $twitter = null;
    if (!array_key_exists($field, $model->data[$model->alias]) && $model->exists()) {
    $twitter = $model->field($field, array(
    $model->primaryKey => $model->id
    ));
    } elseif (array_key_exists($field, $model->data[$model->alias])) {
    $twitter = $model->data[$model->alias][$field];
    }
    $data = array(
    'url' => !empty($twitter) ? 'http://twitter.com/' . $twitter : null,
    'last_tweet' => null
    );
    if (!empty($twitter)) {
    $tweets = $this->timeline($twitter, 1);
    if (!empty($tweets) && is_array($tweets)) {
    $data['last_tweet'] = $tweets[0]->text;
    }
    }
    $model->data[$model->alias] = array_merge(
    $model->data[$model->alias],
    $data
    );
    $this->_addToWhitelist($model, array_keys($data));
    return parent::beforeSave($model);
    }
    
    
  2. 每当我们创建一个新的具有有效 Twitter 账户的资料时,urllast_tweet 字段将自动填充。如果我们正在修改资料,则 last_tweet 字段将更新以反映相关账户的最新推文。

它是如何工作的...

在对模型执行保存操作之前,beforeSave 回调会被触发,这给了我们机会向即将保存的字段集合中添加新字段,或者修改其他字段的值。

我们首先确定与要保存的配置文件链接的 Twitter 账户。如果即将保存的数据中没有指定 Twitter 账户,并且如果我们正在修改现有记录(我们使用$model->exists()进行检查),则从其twitter字段中获取指定的账户。如果数据中指定了账户,则使用该账户。

无论即将进行的保存操作类型(创建或更新记录)如何,我们都将last_tweet字段设置为特定 Twitter 账户发布的最后一条推文。然而,只有在我们创建新记录时,我们才将url字段设置为基于 Twitter 账户的适当 URL。

一旦我们将要保存的数据设置到$data数组中,我们就将该数据追加到包含所有将要保存信息的$model->data属性中。然后我们使用行为中定义的_addToWhitelist()方法,该方法在 CakePHP 的ModelBehavior类中定义,我们的行为是从该类扩展的,这样如果开发者选择仅将保存操作限制在特定字段集,那么我们的字段将保证被保存,不受此限制的影响。

参见

  • 在行为中使用回调

使用 Sluggable 行为

大多数应用程序的主要关注点之一是优化其内容以适应搜索引擎,以便在大多数搜索引擎上获得尽可能高的排名。在大多数 SEO(搜索引擎优化)指南中找到的几项建议中,构建包含相关关键词的 URL 是最有效的一项。

如果我们正在构建一个基于内容的网站,这可以通过确保每个项目的永久链接包含项目标题中的大多数单词来实现。例如,如果我们有一个标题为Top 10 CakePHP Behaviors的文章,一个 SEO 友好的 URL 可以是:

http://localhost/articles/view/top-10-cakephp-behaviors

top-10-cakephp-behaviors部分通常被称为slug,它是 URL 的一部分,使用了相关关键词。在这个食谱中,我们将学习如何使用公开可用的Sluggable行为自动为我们应用程序添加 slugs。

注意

Sluggable行为是我发布为开源的许多类之一,旨在帮助其他 CakePHP 开发者。请随时向我提供任何反馈。

准备工作

要完成这个食谱,我们需要一个样本表来操作。使用以下 SQL 语句创建一个名为posts的表:

CREATE TABLE `posts`(
`id` INT UNSIGNED AUTO_INCREMENT NOT NULL,
`slug` VARCHAR(255) NOT NULL,
`title` VARCHAR(255) NOT NULL,
`text` TEXT NOT NULL,
PRIMARY KEY(`id`),
UNIQUE KEY `slug`(`slug`)
);

我们现在继续创建所需的模型。在名为post.php的文件中创建模型Post,并将其放置在您的app/models文件夹中,内容如下:

<?php
class Post extends AppModel {
public $validate = array(
'title' => array('rule' => 'notEmpty'),
'text' => array('rule' => 'notEmpty')
);
}
?>

在名为posts_controller.php的文件中创建相应的控制器PostsController,并将其放置在您的app/controllers文件夹中,内容如下:

<?php
class PostsController extends AppController {
public function add() {
if (!empty($this->data)) {
$this->Post->create();
if ($this->Post->save($this->data)) {
$this->Session->setFlash('Post created');
$this->redirect('/');
} else {
$this->Session->setFlash('Please correct the errors');
}
}
}
}
?>

在您的app/views文件夹中创建一个名为posts的文件夹,然后创建一个名为add.ctp的视图来保存表单,并将其放置在您的app/views/posts文件夹中,内容如下:

<?php
echo $this->Form->create();
echo $this->Form->inputs(array(
'title',
'text'
));
echo $this->Form->end('Create');
?>

最后,我们需要下载 Syrup 插件。访问github.com/mariano/syrup/downloads并下载最新版本。将下载的文件解压缩到你的app/plugins文件夹中。现在你应该在app/plugins中有一个名为syrup的目录。

如何操作...

  1. 我们首先将Sluggable行为附加到Post模型上。编辑你的app/models/post.php文件并添加$actsAs属性:

    <?php
    class Post extends AppModel {
    public $actsAs = array('Syrup.Sluggable');
    public $validate = array(
    'title' => array('rule' => 'notEmpty'),
    'text' => array('rule' => 'notEmpty')
    );
    }
    ?>
    
    
  2. 让我们创建一个列出帖子的操作。将以下方法添加到PostsController类中:

    public function index() {
    $this->paginate['limit'] = 10;
    $posts = $this->paginate();
    $this->set(compact('posts'));
    }
    
    
  3. 创建视图views/posts/index.ctp,内容如下:

    <div class="paging">
    <?php echo $this->Paginator->prev(); ?>
    &nbsp;
    <?php echo $this->Paginator->numbers(); ?>
    &nbsp;
    <?php echo $this->Paginator->next(); ?>
    </div>
    <br />
    <ul>
    <?php foreach($posts as $post) { ?>
    <li><?php echo $this->Html->link($post['Post']['title'], array('action'=>'view', $post['Post']['slug'])); ?></li>
    <?php } ?>
    </ul>
    
    

    接下来,创建一个通过 slug 查看帖子的操作。将以下方法添加到PostsController类中:

    public function view($slug) {
    $post = $this->Post->find('first', array(
    'conditions' => array('Post.slug' => $slug),
    'recursive' => -1
    ));
    $this->set(compact('post'));
    }
    
    

    创建视图views/posts/view.ctp,内容如下:

    <h1><?php echo $post['Post']['title']; ?></h1>
    <p><?php echo $post['Post']['text']; ?></p>
    <?php echo $this->Html->link('Posts', array('action'=>'index')); ?>
    
    

    在使用http://localhost/posts上的表单创建了一些帖子后,帖子的列表可能看起来像以下截图:

    如何操作...

  4. 如果你悬停在链接上,你应该能看到 SEO 友好的链接。例如,对于标题为使用 CakePHP 自动任务的帖子,其 URL 将是:

    http://localhost/posts/view/automatic-tasks-with-cakephp
    
    
  5. 点击此 URL 将显示帖子的详细信息。

它是如何工作的...

Sluggable行为实现了beforeSave回调,以自动在指定的字段上添加生成的 slug。它确保所有生成的 slug 都是唯一的,并提供了一整套选项来修改 slug 的生成方式。在将行为附加到模型时可以指定以下选项:

选项 目的
ignore 不应包含在 slug 中的单词列表。可选,默认为:and, for, is, ofthe
label 用于创建 slug 的字段名称(字符串),或字段名称列表(数组中)。默认为一个名为title的单个字段。
length 生成的 slug 的最大长度。默认为100
overwrite 如果设置为true,则在修改已经具有 slug 的记录时也会生成 slug。默认为false
real 如果设置为true,将确保在label选项中定义的字段名称存在于表中。默认为true
separator 在 slug 中分隔单词时使用的字符。默认为-
slug 存储 slug 的字段名称。默认为slug

使用 Geocodable 行为进行地址地理编码

自从 Google Maps 和其他位置服务推出以来,网络应用打开了一系列可能性,允许使用地理信息来构建服务。

这个菜谱展示了如何使用 Geocode 插件将位置信息添加到我们自己的Address模型中,使我们能够通过邻近性搜索地址记录。

备注

Geocode插件是我发布的另一个开源项目。更多关于它的信息可以在github.com/mariano/geocode找到。

准备工作

为了完成这个食谱,我们需要一个用于工作的示例表。使用以下 SQL 语句创建一个名为 addresses 的表:

CREATE TABLE `addresses`(
`id` INT UNSIGNED AUTO_INCREMENT NOT NULL,
`address_1` VARCHAR(255) NOT NULL,
`city` VARCHAR(255) default NULL,
`state` VARCHAR(255) NOT NULL,
`zip` VARCHAR(10) default NULL,
`latitude` FLOAT(10,7) NOT NULL,
`longitude` FLOAT(10,7) NOT NULL,
PRIMARY KEY(`id`)
);

我们现在继续创建所需的模型。在 app/models 文件夹中创建名为 Address 的模型,在名为 address.php 的文件中放置以下内容(我们只指定了几个状态以提高可读性):

<?php
class Address extends AppModel {
public $validate = array(
'address_1' => array('rule' => 'notEmpty'),
'state' => array('rule' => 'notEmpty')
);
public static $states = array(
'CA' => 'California',
'FL' => 'Florida',
'NY' => 'New York'
);
}
?>

创建相应的控制器 AddressesController,在名为 addresses_controller.php 的文件中,并将其放置在 app/controllers 文件夹中。内容如下:

<?php
class AddressesController extends AppController {
public function add() {
if (!empty($this->data)) {
$this->Address->create();
if ($this->Address->save($this->data)) {
$this->Session->setFlash('Address created');
$this->redirect('/');
} else {
$this->Session->setFlash('Please correct the errors');
}
}
$states = $this->Address->states;
$this->set(compact('states'));
}
}
?>

在您的 app/views 文件夹中创建一个名为 addresses 的文件夹,然后创建一个名为 add.ctp 的视图文件来保存表单,并将其放置在 app/views/addresses 文件夹中,内容如下:

<?php
echo $this->Form->create();
echo $this->Form->inputs(array(
'address_1' => array('label' => 'Address'),
'city',
'state' => array('options'=>$states),
'zip'
));
echo $this->Form->end('Create');
?>

我们需要下载 CakePHP 的 Geocode 插件。请访问 github.com/mariano/geocode/downloads 并下载最新版本。将下载的文件解压缩到您的 app/plugins 文件夹中。现在您应该在 app/plugins 文件夹内有一个名为 geocode 的目录。

最后,我们需要注册一个 Google Maps API 密钥。为此,请访问 code.google.com/apis/maps/signup.html 并遵循给出的说明。

注意

Geocode 插件也支持 Yahoo 地图。如果您希望使用 Yahoo 地图而不是 Google 地图,请遵循插件主页上的说明。

如何做到这一点...

  1. 编辑您的 app/config/bootstrap.php 文件,并在关闭 PHP 语句之前放置以下语句,将字符串 APIKEY 替换为您自己的 Google Maps API 密钥:

    Configure::write('Geocode.key', 'APIKEY');
    
    
  2. 我们现在将使我们的 Address 模型继承插件提供的骨架模型。编辑您的 app/models/address.php 文件并做出以下更改:

    <?php
    App::import('Model', 'Geocode.GeoAddress');
    class Address extends GeoAddress {
    public $validate = array(
    'address_1' => array('rule' => 'notEmpty'),
    'state' => array('rule' => 'notEmpty')
    );
    public static $states = array(
    'CA' => 'California',
    'FL' => 'Florida',
    'NY' => 'New York'
    );
    }
    ?>
    
    
  3. 通过扩展 GeoAddressGeocodable 行为会自动附加到我们的模型上。现在我们可以使用 http://localhost/addresses/add 上的表单来添加新的地址。添加了相当多的地址后,我们就准备好实现一个支持查找特定位置附近地址的分页列表。

  4. 为了简化这个操作,我们将在控制器操作中强制指定起点,而不是让用户指定地址。考虑到这一点,向 AddressesController 类中添加以下操作:

    public function index() {
    $address = '1211 La Brad Lane, Tampa, FL';
    $this->paginate = array(
    'near',
    'address' => $address
    );
    $addresses = $this->paginate();
    $this->set(compact('address', 'addresses'));
    }
    
    
  5. 现在创建视图 app/views/addresses/index.ctp,内容如下:

    <h1>Addresses near <strong><?php echo $address; ?></strong></h1>
    <div class="paging">
    <?php echo $this->Paginator->prev(); ?>
    &nbsp;
    <?php echo $this->Paginator->numbers(); ?>
    &nbsp;
    <?php echo $this->Paginator->next(); ?>
    </div>
    <br />
    <ul>
    <?php foreach($addresses as $currentAddress) { ?>
    <li>
    <?php echo $currentAddress['Address']['address_1']; ?>
    at
    <strong><?php echo number_format($currentAddress['Address']['distance'], 2) . ' km.'; ?></strong>
    </li>
    <?php } ?>
    </ul>
    
    

如果您插入了靠近指定地址的示例地址,输出可能类似于以下截图所示:

如何做到这一点...

它是如何工作的...

我们首先下载了插件,并通过在 bootstrap.php 配置文件中设置我们自己的 Google Maps API 密钥来配置它。然后,我们使我们的 Address 模型继承插件提供的 GeoAddress 模型,这使得我们的模型使用 Geocodable 行为,并实现了 near 自定义查找类型。

由于我们的地址模型现在附加了可地理编码行为,每次我们创建新的地址记录时,插件将使用谷歌地图 API 在纬度经度字段中保存适当的位置。

使用near自定义查找类型,我们可以轻松地找到靠近某个地址的地址,我们还可以看到每个地址与起点之间的距离。

还有更多...

地理编码插件非常灵活,甚至包括一个显示地址在可视地图中的辅助工具。要了解它提供的一切,请访问其网站github.com/mariano/geocode

第五章:数据源

在本章中,我们将涵盖:

  • 改进 SQL 数据源查询日志

  • 使用数据源解析 CSV 文件

  • 使用数据源消费 RSS 源

  • 构建 Twitter 数据源

  • 向 MySQL 数据源添加事务和锁定支持

简介

数据源是几乎所有模型操作的基础。它们在模型逻辑和底层数据层之间提供了一个抽象,允许更灵活的数据操作方法。通过这个抽象,CakePHP 应用程序能够在不知道数据存储或检索的具体细节的情况下操作数据。

本章展示了如何从现有数据源获取信息,使用预构建的数据源处理非关系数据,并教我们如何创建一个功能齐全的 Twitter 数据源。

改进 SQL 数据源查询日志

这个配方展示了如何创建一个组件,该组件将提供对在支持 EXPLAIN 命令的任何 SQL 基础数据源上执行的所有查询的扩展日志记录,并在适当的调试设置被设置时显示这些信息(这个配方是为 MySQL 设计的,但可以适应其他基于 SQL 的数据源)。

准备工作

为了完成这个配方,我们需要一个样本表来操作。使用以下 SQL 语句创建一个名为 accounts 的表:

CREATE TABLE `accounts`(
`id` INT UNSIGNED AUTO_INCREMENT NOT NULL,
`email` VARCHAR(255) NOT NULL,
PRIMARY KEY(`id`)
);

使用以下 SQL 语句创建一个名为 profiles 的表:

CREATE TABLE `profiles`(
`id` INT UNSIGNED AUTO_INCREMENT NOT NULL,
`account_id` INT UNSIGNED NOT NULL,
`name` VARCHAR(255) default NULL,
PRIMARY KEY(`id`),
KEY `account_id`(`account_id`),
FOREIGN KEY `profiles__accounts`(`account_id`) REFERENCES `accounts`(`id`)
);

使用以下 SQL 语句添加一些样本数据:

INSERT INTO `accounts`(`id`, `email`) VALUES
(1, 'john.doe@example.com'),
(2, 'jane.doe@example.com');
INSERT INTO `profiles`(`id`, `account_id`, `name`) VALUES
(1, 1, 'John Doe'),
(2, 2, 'Jane Doe');

我们现在继续创建所需的模型。在名为 profile.php 的文件中创建模型 Profile,并将其放置在你的 app/models 文件夹中,内容如下:

<?php
class Profile extends AppModel {
public $belongsTo = array(
'Account' => array('type' => 'INNER')
);
}
?>

在一个名为 profiles_controller.php 的文件中创建适当的控制器 ProfilesController,并将其放置在你的 app/controllers 文件夹中,内容如下:

<?php
class ProfilesController extends AppController {
public function index() {
$profiles = $this->Profile->find('all');
$this->set(compact('profiles'));
}
}
?>

在你的 app/views 文件夹中创建一个名为 profiles 的文件夹,然后在名为 index.ctp 的文件中创建视图,并将其放置在你的 app/views/profiles 文件夹中,内容如下:

<ul>
<?php foreach($profiles as $profile) { ?>
<li>#<?php echo $profile['Profile']['id']; ?>:
<?php echo $this->Html->link($profile['Profile']['name'], 'mailto:' . $profile['Account']['email']); ?></li>
<?php } ?>
</ul>

如果你没有布局,将 cake/libs/view/layouts 文件夹中的布局文件 default.ctp 复制到你的应用程序 app/views/layouts 文件夹中。如果你已经有了布局,确保它包括你想要放置 SQL 日志的标准 SQL 视图元素:

<?php echo $this->element('sql_dump'); ?>

最后,通过编辑你的 app/config/core.php 文件并将 Configure::write('debug') 行更改为来设置你的调试级别为 2

Configure::write('debug', 2);

如何做到这一点...

  1. 创建一个名为 query_log.php 的文件,并将其放置在你的 app/controllers/components 文件夹中,内容如下:

    <?php
    class QueryLogComponent extends Object {
    public $minimumTime = 10;
    public $explain = 'EXPLAIN %s';
    public function initialize($controller, $settings = array()) {
    $this->_set($settings);
    if (!is_bool($this->enabled)) {
    $this->enabled = Configure::read('debug') >= 2;
    }
    }
    }
    ?>
    
    
  2. 在编辑 query_log.php 文件的同时,向 QueryLogComponent 添加以下方法:

    class:public function beforeRender($controller)
    {
    if ($this->enabled)
    {
    $queryLog = array();
    $datasources = ConnectionManager::sourceList();
    foreach($datasources as $name)
    {
    $datasource = ConnectionManager::getDataSource($name);
    if ($datasource->isInterfaceSupported('getLog'))
    {
    $log = $datasource->getLog();
    foreach($log['log'] as $i => $line)
    {
    if (empty($line['error']) && $line['took'] >= $this->minimumTime && stripos(trim($line['query']), 'SELECT') === 0)
    {
    $explain = $datasource->query(sprint ($this->explain, $line['query']
    ));
    if (!empty($explain))
    {
    foreach($explain as $j => $explainLine)
    {
    $explain[$j] = array_combine (array_map('strtolower', array_keys($explainLine[0])), $explainLine[0]);
    }
    $log['log'][$i]['explain'] = $explain;
    }
    }
    }
    if (!empty($log['log']))
    {
    $queryLog[$name] = $log;
    }
    }
    }
    if (!empty($queryLog))
    {
    $controller->set(compact('queryLog'));
    }
    }
    }
    
    
  3. QueryLog 组件添加到所有你的控制器中。创建一个名为 app_controller.php 的文件,并将其放置在你的 app/ 文件夹中,内容如下:

    <?php
    class AppController extends Controller
    {
    public $components = array( 'QueryLog' => array( 'minimumTime' => 0 )
    );
    }
    ?>
    
    

    如果你已经有了一个 app_controller.php 文件,确保你的 components 属性包括之前显示的 QueryLog 组件。

  4. 创建一个名为 query_log.ctp 的文件,并将其放置在您的 app/views/elements 文件夹中,内容如下:

    <?php
    if (empty($queryLog))
    {
    echo $this->element('sql_dump');
    return;
    }
    foreach($queryLog as $datasource => $log)
    {
    ?>
    <table class="cake-sql-log">
    <caption>
    Datasource <strong><?php echo $datasource; ?></strong>:
    <?php echo number_format($log['count']) . ' queries (' . $log['time'] . ' ms. total time)'; ?>
    </caption>
    <thead><tr>
    <th>Query</th>
    <th>Error</th>
    <th>Affected</th>
    <th>Num. rows</th>
    <th>Took</th>
    </tr></thead>
    <tbody>
    <?php foreach($log['log'] as $line) { ?>
    <tr>
    <td>
    <?php echo $line['query']; ?>
    <?php if (!empty($line['explain'])) { ?>
    <br /><br />
    <table class="cake-sql-log-explain">
    <thead><tr>
    <th>ID</th>
    <th>Select Type</th>
    <th>Table</th>
    <th>Type</th>
    <th>Possible Keys</th>
    <th>Key</th>
    <th>Ref</th>
    <th>Rows</th>
    <th>Extra</th>
    </tr></thead>
    <tbody>
    <?php foreach($line['explain'] as $explainLine) { ?>
    <tr>
    <td><?php echo $explainLine['id']; ?></td>
    <td><?php echo $explainLine['select_type']; ?></td>
    <td><?php echo $explainLine['table']; ?></td>
    <td><?php echo $explainLine['type']; ?></td>
    <td><?php echo $explainLine['possible_keys']; ?></td>
    <td><?php
    echo $explainLine['key'];
    if (!empty($explainLine['key_len'])) {
    echo ' (' . number_format($explainLine['key_len']) . ' )';
    }
    ?></td>
    <td><?php echo $explainLine['ref']; ?></td>
    <td><?php echo number_format($explainLine['rows']); ?></td>
    <td><?php echo $explainLine['extra']; ?></td>
    </tr>
    <?php } ?>
    </tbody>
    </table>
    <?php } ?>
    </td>
    <td><?php echo $line['error']; ?></td>
    <td><?php echo number_format($line['affected']); ?></td>
    <td><?php echo number_format($line['numRows']); ?></td>
    <td><?php echo number_format($line['took']) . ' ms.'; ?></td>
    </tr>
    <?php } ?>
    </tbody>
    </table>
    <?php } ?>
    
    
  5. 最后,编辑您的 app/views/layouts/default.ctp 文件,并将读取 <?php echo $this->element('sql_dump'); ?> 的行替换为以下内容:

    <?php echo $this->element('query_log'); ?>
    
    

如果我们现在浏览到 http://localhost/profiles,我们应该看到改进后的查询日志,其中包含 SELECT 查询的解释,如下面的截图所示:

如何操作...

它是如何工作的...

SQL 命令 EXPLAIN 用于获取 SELECT 查询的执行计划。当使用 EXPLAIN 时,MySQL 会包括有关查询中连接了哪些表、它们以何种顺序连接以及使用了哪些键(如果有的话)以优化查询的信息。这些信息可用于优化查询并显著减少它们的执行时间。

QueryLog 组件检查 debug 设置以确定是否应该处理查询日志,并使用 minimumTime 设置为那些耗时一定毫秒数或更多的查询添加更多信息。在我们的示例中,当我们将组件添加到 AppController 时,我们将此值设置为 0,以确保所有 SELECT 查询都得到适当的解释。

组件使用 beforeRender 回调在视图即将渲染之前执行其处理。它首先使用 ConnectionManager::sourceList() 方法获取所有可用的数据源列表(即 app/config/database.php 文件中定义的所有连接的名称)。对于这些连接名称中的每一个,它使用 ConnectionManager::getDataSource() 方法获取实际的源对象。正如我们将在本章的其他菜谱中看到的那样,源可能不会实现所有方法,因此组件随后使用所有数据源中都有的 isInterfaceSupported() 方法,以查看该特定源是否实现了 getLog() 方法。

使用 getLog() 方法,组件获取特定源上发出的查询列表,并过滤出只包含在 minimumTime 设置中指定最小时间的 SELECT 查询。一旦它获得了需要解释的 SELECT 查询列表,它就会发出一个 EXPLAIN SQL 语句,并将结果处理成更易读的格式,确保所有获取的字段都是小写。

最后,现在查询日志已经正确处理,它设置了适当的视图变量,该变量由 query_log.ctp 元素用于显示日志。

使用数据源解析 CSV 文件

本菜谱展示了如何使用数据源解析 逗号分隔值CSV)文件,展示了 CSV 处理的清晰方法。

准备工作

我们首先安装 CakePHP 的数据源插件。从 github.com/mariano/datasources/downloads 下载最新版本,并将其解压缩到您的 app/plugins 文件夹中。现在您应该在 app/plugins 内部有一个名为 datasources 的目录。

数据源插件位于 github.com/cakephp/datasources,是官方 CakePHP 插件,提供了一些社区提供的源,如 XML-RPC 和 SOAP。此食谱和其他食谱使用插件的定制版本,为本书的目的进行了修改。

我们需要一些样本数据来工作。创建一个名为 contacts.csv 的文件,并将其放置在您选择的文件夹中(例如 /home/mariano),内容类似于以下所示。此示例仅包含两行数据,但此食谱中使用的文件包含更多行,并且应包括起始标题行:

name,email,country,gender,age
"John Doe","john.doe@email.com","United States of America","Male",34
"Jane Doe","jane.doe@email.com","United Kingdom","Female",25

如何做...

  1. 我们首先创建一个连接以使用 CSV 数据源。打开您的 app/config/database.php 文件,并添加以下连接:

    public $csv = array(
    'datasource' => 'datasources.CsvSource',
    'path' => '/home/mariano/',
    'readonly' => true
    );
    
    
  2. 在名为 contact.php 的文件中创建一个名为 Contact 的模型,并将其放置在您的 app/models 文件夹中,内容如下:

    <?php
    class Contact extends AppModel
    {
    public $useDbConfig = 'csv';
    }
    ?>
    
    
  3. 在名为 contacts_controller.php 的文件中创建其控制器,并将其放置在您的 app/controllers 文件夹中,内容如下:

    <?php
    class ContactsController extends AppController
    {
    public function index()
    {
    $this->set('contacts', $this->paginate());
    }
    }
    ?>
    
    
  4. 最后,我们需要创建视图。在您的 app/views 文件夹中创建一个名为 contacts 的文件夹,并在该文件夹中创建一个名为 index.ctp 的文件,内容如下:

    <p>
    <?php echo $this->Paginator->prev(); ?>&nbsp;
    <?php echo $this->Paginator->numbers(); ?>&nbsp;
    <?php echo $this->Paginator->next(); ?>
    </p>
    <table>
    <thead><tr>
    <th>ID</th>
    <th>Name</th>
    <th>Email</th>
    <th>Country</th>
    <th>Gender</th>
    <th>Age</th>
    </tr></thead>
    <tbody>
    <?php foreach($contacts as $contact) { ?>
    <tr>
    <td><?php echo $contact['id']; ?></td>
    <td><?php echo $contact['name']; ?></td>
    <td><?php echo $contact['email']; ?></td>
    <td><?php echo $contact['country']; ?></td>
    <td><?php echo $contact['gender']; ?></td>
    <td><?php echo $contact['age']; ?></td>
    </tr>
    <?php } ?>
    </tbody>
    </table>
    
    

    如果我们现在浏览到 http://localhost/contacts,我们应该看到一个分页列表,如下面的截图所示:

    如何做...

它是如何工作的...

我们首先创建一个新的连接名为 csv,指定其类型为 datasources.CsvSource,即名为 CsvSource 的数据源,它是 datasources 插件的一部分。我们使用 path 设置将 CSV 文件的路径设置为 CakePHP 的临时目录,并指定我们不想创建该路径,通过将 readonly 设置为 true。

注意

在这个食谱中我们使用的分支为原始插件添加了一个功能:允许通过模型的 table 属性更改所使用的 CSV 文件。

然后,我们创建 Contact 模型,指定其底层连接为 csv,通过 useDbConfig 属性。CSV 数据源将使用相应的表名作为文件名,并将其附加 csv 扩展名。在这种情况下,CSV 数据源将使用 Contact 模型的 contacts,这可以通过模型属性 table 进行更改。

使用该文件名,它将在连接设置中定义的路径中查找。如果文件无法加载,或者路径不存在,它将抛出一个缺少表错误,就像任何缺少表的模型一样。

注意

默认的csv扩展可以通过在连接中指定extension设置来更改。

一旦文件被正确加载,数据源允许我们通过发出简单的find()调用来获取记录。它支持一些最常见的查找设置:limit, page, fields,并且包括对定义设置conditions以限制获取的记录的基本支持(见下文更多内容部分)。

该食谱的其余部分展示了我们如何像使用任何模型一样使用我们的Contact模型,通过解析 CSV 记录的分页列表来展示这种灵活性。

更多内容...

除了能够定义获取哪一页(通过page查找设置)以及获取多少条记录(使用limit查找设置)之外,CSV 数据源还允许通过方便的Set::matches()方法进行一些基本的过滤。例如,我们可以修改我们的分页列表以获取年龄超过 30 岁的联系人,通过向我们的index()方法添加以下conditions设置:

public function index()
{
$this->paginate = array(
'conditions' => array('age >' => 30)
);
$this->set('contacts', $this->paginate());
}

CSV 文件的动态加载

本食谱中使用的示例通过为Contact模型命名的默认表绑定到contacts.csv文件,但如果我们需要处理多个 CSV 文件并且不想为每个文件创建一个模型,需要什么?

使用table模型属性,我们可以动态更改模型导入的底层 CSV 文件,并执行我们的find操作,就像我们为该文件创建了一个特定的模型一样。我们首先创建一个使用csv连接的模型,但该模型没有绑定到任何文件:

<?php
class Csv extends AppModel
{
public $useDbConfig = 'csv';
public $useTable = false;
}
?>

useTable设置为false允许我们避免任何文件加载。然后我们可以使用listSources()数据源方法获取所有可导入的 CSV 文件列表,然后动态更改每个文件的table模型属性,并获取实际的记录。我们在以下controller方法中这样做:

public function import()
{
$this->loadModel('Csv');
$sources = array_flip($this->Csv->getDataSource()->listSources());
foreach($sources as $source => $null)
{
$this->Csv->table = $source;
$sources[$source] = $this->Csv->find('all');
}
debug($sources);
exit;
}

通过listSources()方法获取的文件列表是从数据源配置中指定的path设置获取的,如app/config/database.php中定义的。此路径可以通过首先清理当前连接来更改,这会释放先前配置的路径的句柄,使用数据源的setConfig()方法更改path设置,然后调用它的connect()方法来加载路径:

$dataSource = $this->Csv->getDataSource();
$dataSource->close();
$dataSource->setConfig(array('path' => '/home/john/'));
$dataSource->connect();

使用数据源消费 RSS 源

该食谱展示了如何使用数据源从远程 RSS 源获取内容。

准备工作

我们首先安装 CakePHP 数据源插件的分支。从 github.com/mariano/datasources/downloads 下载最新版本,并将下载的文件解压缩到您的 app/plugins 文件夹中。现在您应该有一个名为 datasources 的目录。本食谱中使用的分支使用由 Loadsys 咨询公司成员 Donatas Kairys 开发的 RSS 数据源的重构版本。此修改版本提高了数据源的性能,并添加了通过查找设置更改馈送 URL 的可能性。有关原始数据源的信息,可以在 blog.loadsys.com/2009/06/19/cakephp-rss-feed-datasource 获取。

如何做...

  1. 我们首先创建一个连接来使用 RSS 数据源。打开您的 app/config/database.php 文件,并添加以下连接:

    public $feed = array(
    'datasource' => 'datasources.RssSource',
    'url' => 'http://marianoiglesias.com.ar/category/cakephp/feed/'
    );
    
    
  2. 在名为 post.php 的文件中创建一个名为 Post 的模型,并将其放置在您的 app/models 文件夹中,内容如下:

    <?php
    class Post extends AppModel {
    public $useDbConfig = 'feed';
    }
    ?>
    
    
  3. 在名为 posts_controller.php 的文件中创建其控制器,并将其放置在您的 app/controllers 文件夹中,内容如下:

    <?php
    class PostsController extends AppController
    {
    public $helpers = array('Time');
    public function index()
    {
    $this->paginate = array(
    'order' => array('pubDate' => 'desc'),
    'limit' => 9
    );
    $this->set('posts', $this->paginate());
    }
    }
    ?>
    
    
  4. 最后,我们需要创建视图。在您的 app/views 文件夹中创建一个名为 posts 的文件夹,并在该文件夹中创建一个名为 index.ctp 的文件,内容如下:

    <p>
    <?php echo $this->Paginator->prev(); ?>&nbsp;
    <?php echo $this->Paginator->numbers(); ?>&nbsp;
    <?php echo $this->Paginator->next(); ?>
    </p>
    <table>
    <thead><tr><th>Title</th><th>Published</th></tr></thead>
    <tbody>
    <?php foreach($posts as $post) { ?>
    <tr>
    <td><?php echo $this->Html->link($post['Post']['title'], $post['Post']['link']); ?></td>
    <td><?php echo $this->Time->nice($post['Post']['pubDate']); ?></td>
    </tr>
    <?php } ?>
    </tbody>
    </table>
    
    

如果我们现在浏览到 http://localhost/posts,我们应该看到如以下截图所示的帖子分页列表:

如何做...

工作原理...

我们首先创建一个名为 feed 的新连接,指定其类型为 datasources.FeedSource。我们使用设置 url 来指定馈送源的地址。在其他可用的连接设置中,我们有:

  • encoding: 设置要使用的字符编码。默认为 CakePHP 的 App.encoding 配置设置。

  • cache: 如果设置为 false,则不会进行缓存。否则,这是要使用的缓存配置名称。默认为名为 default 的配置。

然后,我们创建 Post 模型,通过 useDbConfig 属性指定其底层连接为 feed。然后我们继续设置按发布日期(pubDate 字段)降序排列的帖子分页列表,每页限制九个帖子。

正如食谱中所示的 CSV 数据源,使用数据源解析 CSV 文件,RSS 数据源允许一些基本的过滤。例如,要仅显示在 2009 年或之后创建的帖子,我们将在 index() 方法中添加以下 conditions 设置:

public function index()
{
$this->paginate = array(
'conditions' => array('pubDate >=' => '2009-01-01'),
'order' => array('pubDate' => 'desc'),
'limit' => 9
);
$this->set('posts', $this->paginate());
}

更多内容...

有时候我们可能无法在配置文件中定义馈送 URL,例如,如果 URL 来自动态数据源。幸运的是,对于这些情况,我们有通过自定义查找设置定义馈送地址的选项。

在上述示例中,我们可以从连接设置中移除馈送 URL,并将其指定为名为 url 的查找设置:

$this->paginate = array(
'url' => 'http://marianoiglesias.com.ar/category/cakephp/feed/',
'order' => array('pubDate' => 'desc'),
'limit' => 9
);
$this->set('posts', $this->paginate());

在运行时更改连接设置

我们已经看到,我们可以通过使用自定义查找设置来更改源 URL。然而,我们也可以通过修改连接设置来更改此地址。使用所有数据源中都可用的setConfig()方法,我们可以更改任何连接设置。例如,我们不是使用url自定义查找设置,而是通过更改连接来更改源 URL:

$this->Post->getDataSource()->setConfig(array(
'url' => 'http://marianoiglesias.com.ar/category/cakephp/feed/'
));
$this->paginate = array(
'order' => array('pubDate' => 'desc'),
'limit' => 9
);
$this->set('posts', $this->paginate());

构建 Twitter 数据源

在这个菜谱中,我们将学习如何通过提供从 Twitter 账户读取和发送消息的方式来实现我们自己的数据源。

准备工作

我们将集成此数据源与 OAuth,这是 Twitter 支持的一种身份验证机制。为此,我们将使用 Neil Crookes 开发的一个名为HttpSocketOauth的类,它是 CakePHP 自己的HttpSocket类的一个扩展,以干净优雅的方式添加了 OAuth 支持。从 URL github.com/neilcrookes/http_socket_oauth/raw/master/http_socket_oauth.php 下载名为 http_socket_oauth.php 的文件,并将其放置在您的 app/vendors 文件夹中。

与像 Twitter 这样的OAuth提供者通信还有其他方法,最明显的是使用位于code.google.com/p/oauth-phpPHP OAuth 库。此菜谱使用 Neil 的方法,因为它简单。

让我们继续创建 Tweet 模型。创建一个名为 tweet.php 的文件,并将其放置在您的 app/models 文件夹中,以下是其内容:

<?php
class Tweet extends AppModel {
public $useDbConfig = 'twitter';
}
?>

在名为 tweets_controller.php 的文件中创建其控制器,并将其放置在您的 app/controllers 目录中,以下是其内容:

<?php
class TweetsController extends AppController {
public function index($twitter) {
$tweets = $this->Tweet->find('all', array(
'conditions' => array('username' => $twitter)
));
$this->set(compact('tweets', 'twitter'));
}
public function add($twitter) {
if (!empty($this->data)) {
$this->Tweet->create();
if ($this->Tweet->save($this->data)) {
$this->Session->setFlash('Succeeded');
} else {
$this->Session->setFlash('Failed');
}
}
$this->redirect(array('action'=>'index', $twitter));
}
}
?>

我们现在需要适当的视图。在您的 app/views 文件夹中创建一个名为 tweets 的文件夹,并在其中创建一个名为 index.ctp 的文件,以下是其内容:

<?php
echo $this->Form->create(array('url' => array('action'=>'add', $twitter)));
echo $this->Form->inputs(array(
'status' => array('label'=>false)
));
echo $this->Form->end('Tweet this');
?>
<?php foreach($tweets as $tweet) { ?>
<p><?php echo $tweet['Tweet']['text']; ?></p>
<p><small>
<?php echo $this->Html->link(
date('F d, Y', strtotime($tweet['Tweet']['created_at'])),
'http://www.twitter.com/' . $tweet['User']['screen_name'] . '/status/' . $tweet['Tweet']['id']
); ?>
with <?php echo $tweet['Tweet']['source']; ?>
</small></p>
<br />
<?php } ?>

接下来,我们需要在 Twitter 上注册我们的应用程序。访问 URL twitter.com/apps/new 并填写表格(以下图示了一个示例。)当被要求输入应用程序网站时,请确保指定一个不同于localhost的域名,并且在被要求输入默认访问类型时选择读/写。您还需要指定浏览器作为应用程序类型,并将http://localhost/tweets作为回调 URL,用您自己的主机替换localhost。这个回调实际上不会被利用,因为我们将它在运行时定义,但它强制性的,所以我们需要填写它。

准备工作

当您成功提交此表单时,Twitter 将为您提供一些有关您新注册的应用程序的信息。在那个屏幕上,请确保抓取显示为消费者密钥消费者密钥的内容,因为我们将在执行此菜谱时需要它。

通过以下内容添加一个新的连接$twitter到您的app/config/database.php,并用您上面获得的消费者密钥替换KEY,用消费者密钥替换SECRET_KEY

public $twitter = array(
'datasource' => 'twitter',
'key' => 'KEY',
'secret' => 'SECRET_KEY'
);

如何做...

我们首先完全实现数据源。创建一个名为twitter_source.php的文件,并将其放置在您的app/models/datasources文件夹中,内容如下:

<?php
App::import('Vendor', 'HttpSocketOauth');
class TwitterSource extends DataSource {
public $_baseConfig = array(
'key' => null,
'secret' => null
);
protected $_schema = array(
'tweets' => array(
'id' => array(
'type' => 'integer',
'null' => true,
'key' => 'primary',
'length' => 11,
),
'text' => array(
'type' => 'string',
'null' => true,
'key' => 'primary',
'length' => 140
),
'status' => array(
'type' => 'string',
'null' => true,
'key' => 'primary',
'length' => 140
),
)
);
public function __construct($config = null, $autoConnect = true) {
parent::__construct($config, $autoConnect);
if ($autoConnect) {
$this->connect();
}
}
public function listSources() {
return array('tweets');
}
public function describe($model) {
return $this->_schema['tweets'];
}
public function connect() {
$this->connected = true;
$this->connection = new HttpSocketOauth();
return $this->connected;
}
public function close() {
if ($this->connected) {
unset($this->connection);
$this->connected = false;
}
}
}

现在我们已经有了基本的数据源骨架,我们需要为我们的数据源添加连接到 Twitter 的能力,使用 OAuth。向TwitterSource添加以下方法:

class created before:public function token($callback = null) {
$response = $this->connection->request(array(
'method' => 'GET',
'uri' => array(
'host' => 'api.twitter.com',
'path' => '/oauth/request_token'
),
'auth' => array(
'method' => 'OAuth',
'oauth_callback' => $callback,
'oauth_consumer_key' => $this->config['key'],
'oauth_consumer_secret' => $this->config['secret']
)
));
if (!empty($response)) {
parse_str($response, $response);
if (empty($response['oauth_token']) && count($response) == 1 && current($response) == '') {
trigger_error(key($response), E_USER_WARNING);
} elseif (!empty($response['oauth_token'])) {
return $response['oauth_token'];
}
}
return false;
}
public function authorize($token, $verifier) {
$return = false;
$response = $this->connection->request(array(
'method' => 'GET',
'uri' => array(
'host' => 'api.twitter.com',
'path' => '/oauth/access_token'
),
'auth' => array(
'method' => 'OAuth',
'oauth_consumer_key' => $this->config['key'],
'oauth_consumer_secret' => $this->config['secret'],
'oauth_token' => $token,
'oauth_verifier' => $verifier
)
));
if (!empty($response)) {
parse_str($response, $response);
if (count($response) == 1 && current($response) == '') {
trigger_error(key($response), E_USER_WARNING);
} else {
$return = $response;
}
}
return $return;
}

我们的数据源现在能够通过请求 Twitter 的正确授权来连接。下一步是添加通过实现数据源read()方法来获取推文的支持。向TwitterSource添加以下方法:

class:public function read($model, $queryData = array()) {
if (
empty($queryData['conditions']['username']) ||
empty($this->config['authorize'])
) {
return false;
}
$response = $this->connection->request(array(
'method' => 'GET',
'uri' => array(
'host' => 'api.twitter.com',
'path' => '1/statuses/user_timeline/' . $queryData['conditions']['username'] . '.json'
),
'auth' => array_merge(array(
'method' => 'OAuth',
'oauth_consumer_key' => $this->config['key'],
'oauth_consumer_secret' => $this->config['secret']
), $this->config['authorize'])
));
if (empty($response)) {
return false;
}
$response = json_decode($response, true);
if (!empty($response['error'])) {
trigger_error($response['error'], E_USER_ERROR);
}
$results = array();
foreach ($response as $record) {
$record = array('Tweet' => $record);
$record['User'] = $record['Tweet']['user'];
unset($record['Tweet']['user']);
$results[] = $record;
}
return $results;
}

如果我们无法使用数据源发布新推文,那么工作就不会完成。为了完成我们的实现,向TwitterSource添加以下方法:

class:public function create($model, $fields = array(), $values = array()) {
if (empty($this->config['authorize'])) {
return false;
}
$response = $this->connection->request(array(
'method' => 'POST',
'uri' => array(
'host' => 'api.twitter.com',
'path' => '1/statuses/update.json'
),
'auth' => array(
'method' => 'OAuth',
'oauth_token' => $this->config['authorize']['oauth_token'],
'oauth_token_secret' => $this->config['authorize']['oauth_token_secret'],
'oauth_consumer_key' => $this->config['key'],
'oauth_consumer_secret' => $this->config['secret']
),
'body' => array_combine($fields, $values)
));
if (empty($response)) {
return false;
}
$response = json_decode($response, true);
if (!empty($response['error'])) {
trigger_error($response['error'], E_USER_ERROR);
}
if (!empty($response['id'])) {
$model->setInsertId($response['id']);
return true;
}
return false;
}

为了使数据源工作,我们将在所有请求 Twitter 时获取 OAuth 授权。为此,我们实现了一个方法,该方法将与数据源通信以获取授权密钥,并处理 Twitter 将发出的授权回调。编辑您的app/controllers/tweets_controller.php文件,并在TweetsController类的开头添加以下内容:

public function beforeFilter() {
parent::beforeFilter();
if (!$this->_authorize()) {
$this->redirect(null, 403);
}
}
protected function _authorize() {
$authorize = $this->Session->read('authorize');
if (empty($authorize)) {
$source = $this->Tweet->getDataSource();
$url = Router::url(null, true);
if (
!empty($this->params['url']['oauth_token']) &&
!empty($this->params['url']['oauth_verifier'])
) {
$authorize = $source->authorize(
$this->params['url']['oauth_token'],
$this->params['url']['oauth_verifier']
);
$this->Session->write('authorize', $authorize);
} elseif (!empty($this->params['url']['denied'])) {
return false;
} else {
$token = $source->token($url);
$this->redirect('http://api.twitter.com/oauth/authorize?oauth_token=' . $token);
}
}
if (!empty($authorize)) {
$this->Tweet->getDataSource()->setConfig(compact('authorize'));
}
return $authorize;
}

假设您的 Twitter 账户名称是cookbook5,我们现在浏览到http://localhost/tweets/index/cookbook5,应该会看到一个分页的推文列表,如图所示:

如何做...

使用表单发布新推文应将我们的文本提交到 Twitter,并在列表中显示我们的新推文。

它是如何工作的...

Twitter 数据源首先指定两个新的连接设置:

  • key:Twitter 应用程序消费者密钥

  • secret:Twitter 应用程序消费者密钥

然后通过_schema属性和listSources()describe()方法实现来定义一个静态模式,以描述推文帖子是如何构建的。这样做纯粹是为了添加对基于 Twitter 的模型的支持,以便与 CakePHP 的FormHelper一起工作。这样做允许FormHelper在渲染基于 Twitter 的模型的表单时确定使用哪种类型的字段。

connect()close()方法分别实例化和删除HttpSocketOauth类的一个实例,这是我们与 Twitter API 通信的处理程序。

注意

OAuth 是一个复杂的过程,理解它可能是一个挑战。如果您想获取有关此协议的更详细信息,可能没有比OAuth 入门指南更好的资源了。

token() 方法使用连接从 Twitter 请求一个令牌,这对于我们的请求成功是必需的。当获得令牌后,我们使用这个令牌将用户带到特定的 Twitter URL(重定向发生在控制器中的 _authorize() 方法中),然后 Twitter 使用这个令牌请求用户进行授权。

如果用户允许访问他的/她的 Twitter 账户,Twitter API 将将浏览器重定向到数据源 token() 方法中 callback 参数指定的 URL。这个回调在 _authorize() 中设置为当前 URL。

当用户被带回到我们的应用程序后,_authorize() 方法将检查 Twitter 发送的两个参数的存在:oauth_tokenoauth_verifier。这些参数作为参数传递给数据源的 authorize() 方法,该方法与 Twitter API 通信以完成 OAuth 授权过程的最后阶段。这一阶段以 Twitter 返回一个有效的令牌和一个令牌密钥结束。它们被保存为控制器中的会话变量,以避免在每次请求时都这样做。

一旦我们有了授权信息,我们就通过使用所有数据源中可用的 setConfig() 方法,并将这些信息设置在名为 authorize 的设置中,将其设置为连接设置,因为我们没有这个授权就无法从我们的 Twitter 账户读取或发布。

数据源 read() 方法是实现我们数据源上所有读取过程的实现。在我们的情况下,我们只允许包含在字段 username 上条件的查找操作。这个条件告诉我们从哪个用户账户获取推文。使用这个账户名称和授权信息,我们向 Twitter API 发送请求以获取用户时间线。因为请求使用的是 JSON 格式(可以从请求 URL 中识别出来),所以我们使用 PHP 的 json_decode() 函数来解析响应。然后我们浏览生成的项目(如果没有抛出错误),并将它们转换为更友好的格式。

数据源 write() 方法是实现保存操作,即创建新的推文(在此实现中不支持现有推文的修改)。与 read() 方法类似,我们使用授权信息向 Twitter API 发送 POST 请求,指定作为推文数据的任何字段(fieldsvalues 参数的组合)。

向 MySQL 数据源添加事务和锁定支持

CakePHP 内置的 MySQL 数据源通过将所有未知方法调用直接发送到数据源提供一些基本的事务支持。然而,这仅使我们能够使用一些基本的事务命令,并且任何锁定都必须通过手动 SQL 查询来执行。

注意

表锁定是一种机制,用于有效地管理不同客户端会话对表内容的并发访问。有关 MySQL 中锁定的更多信息,请参阅dev.mysql.com/doc/refman/5.5/en/internal-locking.html

本菜谱展示了如何通过实现更好的事务支持到 MySQL 驱动程序,添加锁定操作,并最终允许对锁定查询进行恢复程序来修改现有的数据源。

注意

有关 MySQL 数据库中事务支持的更多信息,请参阅dev.mysql.com/doc/refman/5.5/en/commit.html

准备工作

为了完成这个菜谱,我们需要一个用于操作的示例表。使用以下 SQL 语句创建一个名为 profiles 的表:

CREATE TABLE `profiles`(
`id` INT UNSIGNED AUTO_INCREMENT NOT NULL,
`name` VARCHAR(255) default NULL,
PRIMARY KEY(`id`)
) ENGINE=InnoDb;

注意

上述查询包括对 MySQL 数据库引擎的指定。即使 MyISAM(另一个可用的引擎)可以处理表级锁定,行级锁定也仅在 InnoDb 表上可行。此外,事务仅在 InnoDb 上受支持。有关不同引擎及其支持特性的更多信息,请参阅dev.mysql.com/doc/refman/5.5/en/storage-engines.html

使用以下 SQL 语句添加一些示例数据:

INSERT INTO `profiles`(`id`, `name`) VALUES
(1, 'John Doe'),
(2, 'Jane Doe');

我们现在开始创建所需的模型。在名为 profile.php 的文件中创建模型 Profile,并将其放置在您的 app/models 文件夹中,内容如下:

<?php
class Profile extends AppModel {
}
?>

在名为 profiles_controller.php 的文件中创建相应的控制器 ProfilesController,并将其放置在您的 app/controllers 文件夹中,内容如下:

<?php
class ProfilesController extends AppController {
public function index() {
}
}
?>

如何操作...

  1. 我们首先创建数据源的骨架。在您的 app/models/datasources 文件夹内创建一个名为 dbo 的文件夹。在 dbo 文件夹中,创建一个名为 dbo_mysql_transaction.php 的文件,内容如下:

    <?php
    App::import('Core', 'DboMysql');
    class DboMysqlTransaction extends DboMysql {
    protected $backAutoCommit;
    protected $lockTimeoutErrorCode = 1205;
    public function __construct($config = null, $autoConnect = true) {
    $this->_baseConfig = Set::merge(array(
    'lock' => array(
    'log' => LOGS . 'locks.log',
    'recover' => true,
    'retries' => 1
    ),
    'autoCommit' => null
    ), $this->_baseConfig);
    $this->_commands = array_merge(array(
    'lock' => 'LOCK TABLES {$table} {$operation}',
    'unlock' => 'UNLOCK TABLES',
    'setAutoCommit' => 'SET @@autoCommit={$autoCommit}'
    ), $this->_commands);
    parent::__construct($config, $autoConnect);
    if (
    !is_null($this->config['autoCommit']) &&
    !$this->setAutoCommit($this->config['autoCommit'])
    ) {
    trigger_error('Could not set autoCommit', E_USER_WARNING);
    }
    }
    }
    ?>
    
    
  2. 我们继续添加锁定和解锁表的方法。编辑您的 app/models/datasources/dbo/dbo_mysql_transaction.php 文件,并将以下方法添加到 DboMysqlTransaction 类中:

    public function lock($model = null, $options = array()) {
    if (!is_object($model) && empty($options)) {
    $options = $model;
    $model = null;
    }
    if (empty($options) && !isset($model)) {
    trigger_error('Nothing to lock', E_USER_WARNING);
    return false;
    } elseif (!is_array($options)) {
    $options = array('table' => $options);
    } elseif (Set::numeric(array_keys($options))) {
    if (count($options) > 1) {
    $options = array('table' => $options[0], 'operation' => $options[1]);
    } else {
    if (!empty($options[0]) && is_array($options[0])) {
    $options = $options[0];
    } else {
    $options = array('table' => $options[0]);
    }
    }
    }
    if (empty($options['table']) && isset($model)) {
    $options = array_merge(array(
    'table' => $model->table,
    'alias' => $model->alias
    ), $options);
    if (!empty($options['operation']) && $options['operation'] == 'read') {
    unset($options['alias']);
    }
    }
    $options = array_merge(array('alias'=>null, 'operation'=>'read', 'local'=>false, 'low'=>false), $options);
    if (!in_array(strtolower($options['operation']), array('read', 'write'))) {
    trigger_error(sprintf('Invalid operation %s for locking', $options['operation']), E_USER_WARNING);
    return false;
    }
    $table = $this->fullTableName($options['table']);
    if (!empty($options['alias'])) {
    $table .= ' AS ' . $this->name($options['alias']);
    }
    $operation = strtoupper($options['operation']);
    if ($options['operation'] == 'read' && $options['local']) {
    $operation .= ' LOCAL';
    } elseif ($options['operation'] == 'write' && $options['low']) {
    $operation = 'LOW_PRIORITY ' . $operation;
    }
    $sql = strtr($this->_commands['lock'], array(
    '{$table}' => $table,
    '{$operation}' => $operation
    ));
    return ($this->query($sql) !== false);
    }
    public function unlock($model = null, $options = array()) {
    return ($this->query($this->_commands['unlock']) !== false);
    }
    While still editing the DboMysqlTransaction class, add the following methods to allow us to get and change the auto commit status:public function getAutoCommit($model = null) {
    if (is_null($this->config['autoCommit'])) {
    if (!$this->isConnected() && !$this->connect()) {
    trigger_error('Could not connect to database', E_USER_WARNING);
    return false;
    }
    $result = $this->query('SELECT @@autocommit AS ' . $this->name('autocommit'));
    if (empty($result)) {
    trigger_error('Could not fetch autoCommit status from database', E_USER_WARNING);
    return false;
    }
    $this->config['autoCommit'] = !empty($result[0][0]['autocommit']);
    }
    return $this->config['autoCommit'];
    }
    public function setAutoCommit($model, $autoCommit = null) {
    if (!$this->isConnected() && !$this->connect()) {
    trigger_error('Could not connect to database', E_USER_WARNING);
    return false;
    }
    if (is_bool($model)) {
    $autoCommit = $model;
    $model = null;
    } elseif (is_array($autoCommit)) {
    list($autoCommit) = $autoCommit;
    }
    $this->config['autoCommit'] = !empty($autoCommit);
    $sql = strtr($this->_commands['setAutoCommit'], array(
    '{$autoCommit}' => ($this->config['autoCommit'] ? '1' : '0')
    ));
    return ($this->query($sql) !== false);
    }
    
    
  3. 我们现在将添加基本的事务命令。编辑您的 app/models/datasources/dbo/dbo_mysql_transaction.php 文件,并将以下方法添加到 DboMysqlTransaction 类中:

    public function begin($model) {
    $this->_startTransaction();
    return parent::begin($model);
    }
    public function commit($model) {
    $result = parent::commit($model);
    $this->_endTransaction();
    return $result;
    }
    public function rollback($model) {
    $result = parent::rollback($model);
    $this->_endTransaction();
    return $result;
    }
    protected function _startTransaction() {
    if ($this->getAutoCommit()) {
    $this->backAutoCommit = $this->getAutoCommit();
    $this->setAutoCommit(false);
    }
    }
    protected function _endTransaction() {
    if (isset($this->backAutoCommit)) {
    $this->setAutoCommit($this->backAutoCommit);
    $this->backAutoCommit = null;
    }
    }
    public function query() {
    $args = func_get_args();
    if (!empty($args) && count($args) > 2 && in_array($args[0], array_keys($this->_commands))) {
    list($command, $params, $model) = $args;
    if ($this->isInterfaceSupported($command)) {
    return $this->{$command}($model, $params);
    }
    }
    return call_user_func_array(array('parent', 'query'), $args);
    }
    
    
  4. 我们通过添加从锁定查询中恢复的方法以及记录这些锁的方法来结束。再次编辑您的 app/models/datasources/dbo/dbo_mysql_transaction.php 文件,并将以下方法添加到 DboMysqlTransaction 类中:

    public function _execute($sql, $retry = 0) {
    $result = parent::_execute($sql);
    $error = $this->lastError();
    if (
    !empty($error) &&
    $this->config['lock']['recover'] &&
    preg_match('/^\b' . preg_quote($this->lockTimeoutErrorCode) . '\b/', $error)
    ) {
    if ($retry == 0) {
    $message = 'Got lock on query [' . $sql . ']';
    $queries = array_reverse(Set::extract($this->_queriesLog, '/query'));
    if (!empty($queries)) {
    $message .= " Query trace (newest to oldest): \n\t";
    $message .= implode("\n\t", array_slice($queries, 0, 5));
    }
    $this->lockLog($message);
    }
    if ($retry < $this->config['lock']['retries']) {
    $result = $this->_execute($sql, $retry + 1);
    } elseif (!empty($this->config['lock']['log'])) {
    $this->lockLog('Failed after ' . number_format($retry) . ' retries');
    }
    } elseif (empty($error) && $retry > 0 && !empty($this->config['lock']['log'])) {
    $this->lockLog('Succeeded after ' . number_format($retry) . ' retries');
    }
    if (empty($error) && !$this->fullDebug && !empty($this->config['lock']['log'])) {
    $this->logQuery($sql);
    }
    return $result;
    }
    protected function lockLog($message) {
    $message = '['.date('d/m/Y H:i:s') . '] ' . $message . "\n";
    $handle = fopen($this->config['lock']['log'], 'a');
    if (!is_resource($handle)) {
    trigger_error(sprintf('Could not open log file %s', $this->config['lock']['log']), E_USER_WARNING);
    return false;
    }
    fwrite($handle, $message);
    fclose($handle);
    return true;
    }
    
    
  5. 为了测试达到锁时会发生什么,编辑您的 app/controllers/profiles_controller.php 文件,并将以下方法添加到 ProfilesController 类中:

    public function index() {
    $this->Profile->setAutoCommit(false);
    if ($this->Profile->lock()) {
    $profile = $this->Profile->find('all');
    debug($profile);
    $this->Profile->unlock();
    }
    exit;
    }
    
    
  6. 打开您的 MySQL 客户端并执行以下 SQL 命令(在执行这些命令后不要关闭客户端,因为您可能需要像后面所示释放锁):

    SET @@autocommit=0;
    LOCK TABLE `profiles` WRITE;
    
    
  7. 如果我们现在浏览到http://localhost/profiles,我们应该得到一个读取的 SQL 错误消息,内容为SQL 错误:1205:锁定等待超时;尝试重新启动事务。应在您的app/tmp/logs文件夹中创建一个名为locks.log的文件,其中包含以下内容(数据库名称cookbook_chapter5_transaction应更改为您使用的数据库名称):

    [23/06/2010 09:14:11] Got lock on query [LOCK TABLES `profiles` AS `Profile` READ] Query trace (newest to oldest):
    SET @@autocommit=0
    DESCRIBE `profiles`
    SHOW TABLES FROM `cookbook_chapter5_transaction`;
    [23/06/2010 09:14:17] Failed after 1 retries
    
    
  8. 要测试锁定查询的恢复,我们可以通过在 MySQL 客户端发出以下命令来释放锁:

    UNLOCK TABLES;
    
    

    并在第一次失败的事务和下一次恢复尝试之间进行。要更改 MySQL 等待锁定可获得的时长,请访问 MySQL 文档中的服务器设置innodb_lock_wait_timeout

它是如何工作的...

由于我们扩展了一个基于 DBO 的数据源,我们使用Dbo前缀(DboMysqlTransaction)命名我们的类,并将其放置在dbo文件夹中,该文件夹位于我们的app/models/datasources文件夹中。

初始实现包括两个类属性:

  • backAutoCommit:由辅助方法_startTransaction()_endTransaction()使用,用于暂时更改自动提交设置。

  • lockTimeoutErrorCode:指定 MySQL 用于识别死锁超时错误的代码号。

我们的第一个方法是类构造函数,它被重写以添加我们自己的连接设置,以及锁定和解锁表以及更改自动提交设置的 SQL 命令。我们添加的连接设置是:

  • lock:它是一组设置,用于指定处理锁定查询时要执行的操作。其设置子集包括:

    • log:它是存储日志信息的文件路径。如果设置为false,则禁用日志记录。默认为在app/tmp/logs目录中创建的名为locks.log的文件。

    • recover:它决定是否尝试从锁定查询中恢复。如果设置为false,则不会尝试恢复。默认为true

    • retries:如果recover设置为true,则决定重试失败(锁定)查询的次数。默认为1

  • autoCommit:它给出初始的自动提交值(启用时为true,禁用时为false)。如果设置为null,它将从数据库服务器获取其值。

然后我们实现lock()unlock()方法。lock()方法允许我们对表进行锁定以执行特定操作。我们可以直接从模型中使用它来锁定其底层表以执行read操作:

$this->Profile->lock();

我们可以将锁定操作更改为write

$this->Profile->lock(array('operation'=>'write'))

我们还可以使用它来锁定特定的表,使用所有使用此数据源的所有模型上的lock()方法,或者直接在数据源中调用该方法:

$this->Profile->getDataSource()->lock(array(
'table' => 'profiles',
'operation'=>'write'
));

unlock()方法的使用方式类似,无论是通过模型还是直接使用数据源,都可以解锁所有锁定表。

注意

当您锁定表时,请确保您使用setAutoCommit()方法禁用自动提交,如下所示:$this->Profile->setAutoCommit(false)

在下一块代码中,我们添加了开始、提交和回滚事务的实现。对于这些方法,除了它们在开始事务时负责禁用自动提交,并在事务完成后重置其状态之外,不需要太多细节。

query() 方法被重写,以便可以直接从我们的模型中执行一些数据源方法。这适用于我们添加的三个方法:lock()unlock()setAutoCommit()

最后,我们重写 _execute() 方法以检测何时抛出锁等待超时错误。在这些情况下,我们使用 lockLog() 方法记录情况,并且如果我们被告知这样做,我们将继续重试查询。

第六章。路由魔法

在本章中,我们将介绍:

  • 使用 namedGET 参数

  • 使用带前缀的路由

  • 与路由元素一起工作

  • 为个人页面添加通配符路由

  • 为通配符路由添加验证

  • 创建自定义 Route

简介

几乎每个基于 Web 的应用程序最终都必须开发一种成功的策略,通过一种称为 搜索引擎优化 的技术来获得更好的搜索引擎排名。

本章首先通过使用路由参数介绍一些基本的路由概念,然后继续构建优化路由以利用我们的搜索引擎排名。

本章的最后部分展示了如何为我们的用户资料创建高度优化的 URL,以及如何构建自定义 Route 类以获得更多灵活性。

使用命名和 GET 参数

CakePHP 已经提供了一套非常有用的默认路由,允许将任何一组 URL 元素作为参数发送到控制器操作。例如,一个如 http://localhost/tags/view/cakephp 的 URL 被解释为调用 TagsController::view() 方法,并将 cakephp 作为其第一个参数。

然而,有时在创建带有参数的 URL 时我们需要更多的灵活性,例如省略某些参数或添加在方法签名中未指定的其他参数。NamedGET 参数允许我们拥有这种灵活性,同时不失让 CakePHP 处理其自动 URL 解析的优势。

准备工作

为了完成这个食谱,我们需要一个用于工作的示例表。使用以下 SQL 语句创建一个名为 categories 的表:

CREATE TABLE `categories`(
`id` INT UNSIGNED AUTO_INCREMENT NOT NULL,
`name` VARCHAR(255) NOT NULL,
PRIMARY KEY(`id`)
);

使用以下 SQL 语句创建一个名为 articles 的表:

CREATE TABLE `articles`(
`id` INT UNSIGNED AUTO_INCREMENT NOT NULL,
`category_id` INT UNSIGNED NOT NULL,
`title` VARCHAR(255) NOT NULL,
`body` TEXT NOT NULL,
PRIMARY KEY(`id`),
KEY `category_id`(`category_id`),
FOREIGN KEY `articles__categories`(`category_id`) REFERENCES `categories`(`id`)
);

使用以下 SQL 语句添加一些示例数据:

INSERT INTO `categories`(`id`, `name`) VALUES
(1, 'Frameworks'),
(2, 'Databases');
INSERT INTO `articles`(`id`, `category_id`, `title`, `body`) VALUES
(1, 1, 'Understanding Containable', 'Body of article'),
(2, 1, 'Creating your first test case', 'Body of article'),
(3, 1, 'Using bake to start an application', 'Body of article'),
(4, 1, 'Creating your first helper', 'Body of article'),
(5, 2, 'Adding indexes', 'Body of article');

我们现在继续创建所需模型。在 app/models 文件夹中创建名为 Article 的模型,文件名为 article.php,内容如下:

<?php
class Article extends AppModel {
public $belongsTo = array(
'Category'
);
}
?>

在名为 articles_controller.php 的文件中创建适当的控制器 ArticlesController,并将其放置在 app/controllers 文件夹中,内容如下:

<?php
class ArticlesController extends AppController {
public function view($id) {
$article = $this->Article->find('first', array(
'conditions' => array('Article.id' => $id)
));
if (empty($article)) {
$this->cakeError('error404');
}
$articles = $this->Article->find('all', array(
'conditions' => array(
'Category.id' => $article['Category']['id'],
'Article.id !=' => $article['Article']['id']
),
'order' => 'RAND()'
));
$this->set(compact('article', 'articles'));
}
}
?>

在你的 app/views 文件夹中创建一个名为 articles 的文件夹,然后在名为 view.ctp 的文件中创建视图,并将其放置在 app/views/articles 文件夹中,内容如下:

<h1><?php echo $article['Article']['title']; ?></h1>
<p><?php echo $article['Article']['body']; ?></p>
<?php if (!empty($articles)) { ?>
<br /><p>Related articles:</p>
<ul>
<?php foreach($articles as $related) { ?>
<li><?php echo $this->Html->link(
$related['Article']['title'],
array(
'action'=>'view',
$related['Article']['id']
)
); ?></li>
<?php } ?>
</ul>
<?php } ?>

如何操作...

  1. 我们首先通过一个 GET 参数添加更改相关文章数量的可能性。编辑你的 app/controllers/articles_controller.php 文件,并对 view() 方法进行以下更改:

    public function view($id) {
    $article = $this->Article->find('first', array(
    'conditions' => array('Article.id' => $id)
    ));
    if (empty($article)) {
    $this->cakeError('error404');
    }
    $limit = !empty($this->params['url']['related']) ?
    $this->params['url']['related'] :
    0;
    $articles = $this->Article->find('all', array(
    'conditions' => array(
    'Category.id' => $article['Category']['id'],
    'Article.id !=' => $article['Article']['id']
    ),
    'order' => 'RAND()',
    'limit' => $limit > 0 ? $limit : null
    ));
    $this->set(compact('article', 'articles', 'limit'));
    }
    
    
  2. 如果我们现在浏览到 http://localhost/articles/view/1?related=2,我们应该看到文章内容,以及最多两篇相关文章,如下面的截图所示:如何操作...

  3. 我们现在将使用命名参数来传递一个对搜索引擎友好的文章标题版本,即使显示文章或其相关内容并不需要。编辑你的ArticlesController类,并在view()方法的末尾添加以下内容:

    $slug = !empty($this->params['named']['title']) ?
    $this->params['named']['title'] :
    null;
    $categorySlug = !empty($this->params['named']['category']) ?
    $this->params['named']['category'] :
    null;
    $this->set(compact('slug', 'categorySlug'));
    
    
  4. 现在编辑app/views/articles/view.ctp文件,并做出以下更改:

    <?php if (!empty($slug)) { ?>
    Slug: <?php echo $this->Html->clean($slug); ?><br />
    <?php } ?>
    <?php if (!empty($categorySlug)) { ?>
    Category slug: <?php echo $this->Html->clean($categorySlug); ?><br />
    <?php } ?>
    <h1><?php echo $article['Article']['title']; ?></h1>
    <p><?php echo $article['Article']['body']; ?></p>
    <?php if (!empty($articles)) { ?>
    <br /><p>Related articles:</p>
    <ul>
    <?php foreach($articles as $related) { ?>
    <li><?php echo $this->Html->link(
    $related['Article']['title'],
    array(
    'action'=>'view',
    $related['Article']['id'],
    '?' => array('related' => $limit),
    'category' => strtolower(Inflector::slug($related['Category']['name'])),
    'title' => strtolower(Inflector::slug($related['Article']['title']))
    )
    ); ?></li>
    <?php } ?>
    </ul>
    <?php } ?>
    
    
  5. 如果我们悬停在相关文章的链接上,我们会注意到它们包括两个新的参数:categorytitle。一个生成的 URL 示例可能是http://localhost/articles/view/4/category:frameworks/title:creating_your_first_helper。点击此链接将带我们到文章页面,该页面也显示了指定的参数。

它是如何工作的...

GETnamed参数以类似的方式工作,它们作为数组自动在我们的应用程序代码中可用。GET参数在$this->params['url']中可用,而命名参数在$this->params['named']中可用。检查参数的存在就像验证这些给定数组中是否包含一个键是所需参数的值一样简单。

通过指定一个参数(键是参数名,值是其值)的索引数组来创建指定namedGET参数(或两者)的链接(或两者)。对于GET参数,此数组设置在特殊的?路由索引键中,而对于命名参数,每个参数都作为实际基于数组的 URL 的一部分指定。

还有更多...

我们学习了如何通过在基于数组的 URL 中设置key => value对来指定命名参数。然而,我们可能还想指定哪些命名参数应该被解析,并确保它们仅在值匹配某个正则表达式时才被解析。

例如,我们可以为articles控制器中的所有操作定义title命名参数,使其仅在遵循某个正则表达式时解析,其中标题只能包含小写字母、数字或下划线符号。为此,我们在app/config/routes.php文件中添加以下句子:

Router::connectNamed(
array('title' => array('match' => '^[a-z0-9_]+$', 'controller' => 'articles')),
array('default' => true)
);

第一个参数是一个数组,按参数名索引,其值包含另一个可能包括以下设置的数组,所有这些设置都是可选的:

设置 目的
action 如果指定,只有对于给定的操作,才会解析命名参数。
controller 如果指定,只有对于给定的控制器,才会解析命名参数。
match 一个正则表达式,用于检查提供的值是否与命名参数匹配。如果指定,只有当值匹配表达式时,才会解析命名参数。

Router::connectNamed()的第二个参数是一个可选的设置数组,可能包括以下任何一项:

设置 目的
default 如果设置为 true,它还将加载使分页工作所需的命名参数。如果您多次调用 Router::connectNamed(),这只需要一次,除非您将 reset 选项设置为 true。默认为 false
greedy 如果设置为 false,它将只解析通过 Router::connectNamed() 调用显式定义的命名参数。默认为 true
reset 如果设置为 true,它将清除在此调用之前定义的任何命名参数。默认为 false

为了进一步理解 greedy 选项,我们仍然可以允许 URL 包含 categorytitle 参数,但可能只想解析 title 的值。为此,我们可以在定义命名参数时将 greedy 设置为 false。这样,$this->params['named'] 将只包含 title 的值,即使请求的 URL 中指定了 category。我们还想只为 articles 控制器的 view 动作执行此操作:

Router::connectNamed(
array('title' => array('match' => '^[a-z0-9_]+$', 'controller'=>'articles', 'action'=>'view')),
array('greedy' => false)
);

注意我们为什么必须再次指定 title 命名参数的正则表达式,尽管我们之前已经指定了它。这是因为我们正在配置一个已存在的名称的命名参数,因此我们的定义将覆盖之前的定义。

参见

  • 与路由元素一起工作

使用带有前缀的路由

经常情况下,我们发现我们需要将应用程序的不同区域分开,不仅是在代码和用户界面方面,而且在功能方面。通过使用 CakePHP 的灵活路由系统,我们可以通过使用前缀来实现这一点,并更多,因为前缀为我们提供了一种以不同方式重新实现某些控制器操作的方法,并且根据使用的任何前缀,达到特定的实现。

准备工作

为了完成这个配方,我们需要一个用于工作的示例表。使用以下 SQL 语句创建一个名为 profiles 的表:

CREATE TABLE `profiles`(
`id` INT UNSIGNED AUTO_INCREMENT NOT NULL,
`name` VARCHAR(255) NOT NULL,
`email` VARCHAR(255) NOT NULL,
`active` TINYINT(1) NOT NULL default 1,
PRIMARY KEY(`id`)
);

使用以下 SQL 语句添加一些示例数据:

INSERT INTO `profiles`(`id`, `name`, `email`, `active`) VALUES
(1, 'John Doe', 'john.doe@email.com', 1),
(2, 'Jane Doe', 'jane.doe@email.com', 1),
(3, 'Mark Doe', 'mark.doe@email.com', 0);

接下来,在名为 profiles_controller.php 的文件中创建所需的 ProfilesController 类,并将其放置在您的 app/controllers 文件夹中,内容如下:

<?php
class ProfilesController extends AppController {
public function index() {
$profiles = $this->paginate();
$this->set(compact('profiles'));
}
public function edit($id) {
if (!empty($this->data)) {
if ($this->Profile->save($this->data)) {
$this->Session->setFlash('Profile saved');
$this->redirect(array('action'=>'index'));
} else {
$this->Session->setFlash('Please correct the errors');
}
} else {
$this->data = $this->Profile->find('first', array(
'conditions' => array('Profile.id' => $id),
'recursive' => -1
));
}
}
}
?>

在您的 app/views 文件夹中创建一个名为 profiles 的文件夹,然后创建一个名为 index.ctp 的视图,并将其放置在 app/views/profiles 文件夹中,内容如下:

<p>
<?php echo $this->Paginator->prev(); ?>&nbsp;
<?php echo $this->Paginator->numbers(); ?>&nbsp;
<?php echo $this->Paginator->next(); ?>
</p>
<table>
<thead><tr><th>Name</th><th>Email</th><th>Actions</th></tr></thead>
<tbody>
<?php foreach($profiles as $profile) { ?>
<tr>
<td><?php echo $profile['Profile']['name']; ?></td>
<td><?php echo $profile['Profile']['email']; ?></td>
<td>
<?php echo $this->Html->link('Edit', array('action'=>'edit', $profile['Profile']['id'])); ?>
</td>
</tr>
<?php } ?>
</tbody></table>

在名为 edit.ctp 的文件中创建 edit 动作的视图,并将其放置在您的 app/views/profiles 文件夹中,内容如下:

<?php echo $this->Form->create('Profile'); ?>
<?php echo $this->Form->input('name'); ?>
<?php echo $this->Form->input('email'); ?>
<?php echo $this->Form->end('Save'); ?>

如何操作...

  1. 我们首先向 CakePHP 添加两个前缀:adminmanager。编辑您的 app/config/core.php 文件,查找定义 Routing.prefixes 设置的行。如果该行被注释,取消注释它。然后将其更改为:

    Configure::write('Routing.prefixes', array('admin', 'manager'));
    
    
  2. 让我们修改ProfilesController类,为两个前缀添加重写的indexedit操作。我们还将添加一个新的操作,以便在通过admin前缀访问时,我们可以添加新的配置文件记录。编辑你的app/controllers/profiles_controller.php文件,并在ProfilesController类的开头添加以下方法:

    public function beforeFilter() {
    parent::beforeFilter();
    $prefixes = Configure::read('Routing.prefixes');
    if (!empty($prefixes)) {
    foreach($prefixes as $prefix) {
    $hasPrefix = false;
    if (!empty($this->params['prefix'])) {
    $hasPrefix = ($this->params['prefix'] == $prefix);
    }
    $prefixName = 'is' . Inflector::classify($prefix);
    $this->$prefixName = $hasPrefix;
    $this->set($prefixName, $hasPrefix);
    }
    }
    }
    public function manager_index() {
    $this->setAction('index');
    }
    public function manager_edit($id) {
    $this->setAction('edit', $id);
    }
    public function admin_index() {
    $this->setAction('index');
    }
    public function admin_edit($id) {
    $this->setAction('edit', $id);
    }
    public function admin_add() {
    $this->setAction('edit');
    }
    public function index() {
    $profiles = $this->paginate();
    $this->set(compact('profiles'));
    }
    
    
  3. 现在,我们需要更改edit操作,使其能够处理新记录的创建。在继续编辑你的app/controllers/profiles_controller.php文件时,对ProfilesController类的edit()方法做出以下更改:

    public function edit($id = null) {
    if (!empty($id) && !$this->isAdmin && !$this->isManager) {
    $this->redirect(array('action' => 'index'));
    }
    if (!empty($this->data)) {
    if (empty($id)) {
    $this->Profile->create();
    }
    if ($this->Profile->save($this->data)) {
    $this->Session->setFlash('Profile saved');
    $this->redirect(array('action'=>'index'));
    } else {
    $this->Session->setFlash('Please correct the errors');
    }
    } elseif (!empty($id)) {
    $this->data = $this->Profile->find('first', array(
    'conditions' => array('Profile.id' => $id),
    'recursive' => -1
    ));
    }
    }
    
    
  4. 下一步是更改视图。编辑你的app/views/profiles/index.ctp视图文件,并在末尾添加以下内容:

    <?php
    if ($isAdmin) {
    echo $this->Html->link('Create Profile', array('admin' => true, 'action'=>'add'));
    }
    ?>
    
    
  5. 最后,编辑你的app/views/profiles/edit.ctp视图文件,并做出以下更改:

    <?php echo $this->Form->create('Profile'); ?>
    <?php echo $this->Form->input('name'); ?>
    <?php echo $this->Form->input('email'); ?>
    <?php
    if ($isManager || $isAdmin) {
    echo $this->Form->input('active', array(
    'options' => array(1 => 'Yes', 0 => 'No')
    ));
    }
    ?>
    <?php echo $this->Form->end('Save'); ?>
    
    

它是如何工作的...

在配置设置Routing.prefixes中指定的任何值都作为路由前缀。在这个例子中,我们添加了两个前缀:adminmanager。每次我们在 URL 中使用前缀(前缀在正常 CakePHP URL 之前)时,CakePHP 都会将当前前缀设置在$this->params['prefix']中,并执行一个操作,其名称与如果不使用前缀,但在同一控制器中使用前缀和下划线符号相同。

在我们的例子中,当我们访问http://localhost/manager/profiles/index时,CakePHP 将通过执行位于ProfilesController中的manager_index操作来处理此请求,并将$this->params['prefix']设置为manager。了解这一点后,我们可以添加控制器和视图变量来告诉操作和视图,我们是以管理员的身份(当manager前缀被设置时)还是以管理员的身份(当admin前缀被设置时)访问应用程序。我们通过在beforeFilter回调中为每个前缀(manager前缀的isManageradmin前缀的isAdmin)创建适当的控制器和视图变量来实现这一点。

参见

  • 在第一章的基于角色的访问控制器前缀使用中,认证

使用路由元素进行操作

即使GETnamed参数在大多数情况下可能很有用,我们可能还需要进一步优化我们的应用程序 URL 以获得更好的搜索引擎排名。

幸运的是,CakePHP 为我们提供了路由元素,这是一个保持GET和命名参数灵活性的解决方案,并改进了应用程序内 URL 的构建方式。

准备就绪

我们需要一些样本数据来工作。按照食谱使用 GET 和命名参数中的准备就绪部分进行操作。

如何操作...

  1. 我们希望我们的文章 URL 能够进一步优化以适应搜索引擎,所以我们首先创建一个新的路由。编辑你的app/config/routes.php文件,并在文件末尾添加以下路由:

    Router::connect('/article/:category/:id-:title',
    array('controller' => 'articles', 'action' => 'view'),
    array(
    'pass' => array('id'),
    'id' => '\d+',
    'category' => '[^-]+',
    'title' => '[^-]+'
    )
    );
    
    
  2. 由于我们的路由定义了三个元素(idcategorytitle),我们需要修改视图来指定这些元素的价值。编辑您的 app/views/articles/index.ctp 视图文件并做出以下更改:

    <h1><?php echo $article['Article']['title']; ?></h1>
    <p><?php echo $article['Article']['body']; ?></p>
    <?php if (!empty($articles)) { ?>
    <br /><p>Related articles:</p>
    <ul>
    <?php foreach($articles as $related) { ?>
    <li><?php echo $this->Html->link(
    $related['Article']['title'],
    array(
    'action'=>'view',
    'id' => $related['Article']['id'],
    'category' => strtolower(Inflector::slug($related['Category']['name'])),
    'title' => strtolower(Inflector::slug($related['Article']['title']))
    )
    ); ?></li>
    <?php } ?>
    </ul>
    <?php } ?>
    
    

它是如何工作的...

CakePHP 使用 routes.php 配置文件中定义的路由来生成 URL,并解析请求的 URL。当我们想要与框架提供的不同 URL 时,我们向此配置文件添加新的路由。

在调用 Router::connect() 方法时,通过指定最多三个参数来创建路由:

  • 第一个参数是路由 URL,是我们路由的字符串表示。它可以包含通配符和路由元素。

  • 第二个参数用于指定默认路由值,这是一个可能包括 plugin, controller, action 和操作参数的数组。您可以省略这些默认值的一部分,例如,为特定控制器中的所有操作定义路由。

  • 第三个参数定义了路由元素,这是一个可选数组,它定义了路由所使用的路由元素。它还可以包括在调用控制器操作时要发送作为参数的元素列表。

使用 Router::connect(),我们定义了一个包含所有这些参数的路由:

  • 我们将 /article/:category/:id-:title 设置为我们的路由 URL。注意我们是如何通过在名称前加冒号来引用路由元素的。

  • 在第二个参数中,我们指定这个路由将匹配指向 articles 控制器 view 动作的任何链接。同样,如果请求的 URL 与第一个参数中指定的路由 URL 匹配,这将是要执行的操作。

  • 在第三个参数中,我们指定了三个路由元素,以及它们各自的正则表达式匹配表达式:id(一个数字)、category(不包含破折号的任何字符串)和 title(也不包含破折号的字符串)。我们使用特殊的 pass 选项来指定哪些路由元素作为常规操作参数传递。

当 CakePHP 发现一个 URL 包含与我们的路由第一个参数指定的默认值相同的值,并且也包含其第三个参数指定的路由元素时,它将把路由转换为我们的提供的字符串表示形式。例如,如果我们使用以下语句创建一个链接:

<?php echo $this->Html->link(
'My article',
array(
'controller' => 'articles',
'action' => 'view',
'id' => 1,
'category' => 'my_category',
'title' => 'my_title'
)
); ?>

我们将匹配所有我们的路由要求,生成的结果 URL 将看起来像 http://localhost/article/my_category/1-my_title

更多...

当我们的控制器操作由使用路由元素的路线触发时,我们可以使用每个控制器都有的 $this->params 数组来获取所有指定元素的价值。

在我们的示例中,我们将 id 路由元素设置为作为常规操作参数传递,但我们没有对剩余的元素(类别和 title)这样做。为了获取 category 的给定值,我们会这样做:

$category = $this->params['category']

使用反向路由

尽管 CakePHP 允许我们在创建链接时指定基于字符串的 URL,但建议我们除非 URL 是外站绝对引用,否则始终使用数组来定义链接 URL。

使用数组定义的 URL 允许反向路由系统工作,这是框架的一部分,允许我们使用自定义路由。

参见

  • 为个人资料页面添加通配符路由

  • 使用 GET 和命名参数

为个人资料页面添加通配符路由

几个网站包括直接 URL 来访问用户个人资料,并且这些地址与其他大量 URL 并存。例如,Twitter 允许twitter.com/mgiglesias列出用户mgiglesias创建的推文,而像twitter.com/about这样的地址将带我们到他们的服务描述。

这个食谱向我们展示了如何为我们的个人资料记录创建直接 URL,允许生成的 URL 与其他我们可能拥有的应用程序路由共存。

准备工作

为了完成这个食谱,我们需要一个示例表来操作。使用以下 SQL 语句创建一个名为profiles的表:

CREATE TABLE `profiles`(
`id` INT UNSIGNED AUTO_INCREMENT NOT NULL,
`username` VARCHAR(255) NOT NULL,
`name` VARCHAR(255) NOT NULL,
PRIMARY KEY(`id`)
);

使用以下 SQL 语句添加一些示例数据:

INSERT INTO `profiles`(`id`, `username`, `name`) VALUES
(1, 'john', 'John Doe'),
(2, 'jane', 'Jane Doe');

现在继续创建所需的模型。创建一个名为profile.php的文件,并将其放置在app/models文件夹中,内容如下:

<?php
class Profile extends AppModel {
}
?>

在名为profiles_controller.php的文件中创建ProfilesController类,并将其放置在app/controllers文件夹中,内容如下:

<?php
class ProfilesController extends AppController {
public function index() {
$profiles = $this->Profile->find('all');
$this->set(compact('profiles'));
}
public function view($username) {
$profile = $this->Profile->find('first', array(
'conditions' => array('Profile.username' => $username)
));
if (empty($profile)) {
$this->cakeError('error404');
}
$this->set(compact('profile'));
}
}
?>

在你的app/views文件夹中创建一个名为profiles的文件夹。在app/views/profiles文件夹中创建index操作的视图,文件名为index.ctp,内容如下:

<ul>
<?php foreach($profiles as $profile) { ?>
<li><?php echo $this->Html->link($profile['Profile']['name'], array(
'action' => 'view',
'userName' => $profile['Profile']['username']
)); ?></li>
<?php } ?>
</ul>

app/views/profiles文件夹中创建view操作的视图,文件名为view.ctp,内容如下:

<h1><?php echo $profile['Profile']['name']; ?></h1>
Username: <?php echo $profile['Profile']['username']; ?>
<p><?php echo $this->Html->link('Profiles', array('action'=>'index')); ?></p>

如何操作...

编辑你的app/config/routes.php文件,并在文件末尾添加以下路由:

Router::connect('/:userName',
array('controller' => 'profiles', 'action' => 'view'),
array(
'userName' => '[A-Za-z0-9\._-]+',
'pass' => array('userName')
)
);
Router::connect('/:controller/index/*', array('action' => 'index'));

如果你现在浏览到http://localhost/profiles/index,你会看到为jane用户账户生成的链接是http://localhost/jane。点击它应该会显示 Jane 的个人资料页面,如下面的截图所示:

如何操作...

它是如何工作的...

我们创建了两个路由。第一个路由使用一个名为userName的路由元素来设置 URL 仅由其值组成。使用正则表达式,我们的路由确保只有在userName的值为字母、数字、点、破折号或下划线时才使用。使用controlleraction设置,我们将路由链接到profiles控制器的view操作。最后,将userName元素设置为作为常规参数传递给ProfilesController::view()方法。

定义此路由后,如果我们使用以下语句创建链接:

<?php echo $this->Html->link('My Profile', array(
'controller' => 'profiles',
'action' => 'view',
'userName' => 'john'
)); ?>

生成的 URL 将是 http://localhost/john。点击此链接将执行与使用 URL http://localhost/profiles/view/john 相同的动作,使用相同的参数。

然而,存在一个明显的问题。CakePHP 为所有我们的控制器提供了索引动作的短 URL。因此,我们可以使用 URL http://localhost/profiles 访问 ProfilesController::index() 方法,这与 URL http://localhost/profiles/index 相当。此默认路由将与我们的自定义路由冲突,因为单词 profiles 与我们的正则表达式匹配。

幸运的是,此功能在从基于数组的路由生成 URL 时不会与我们的路由冲突。因为我们已将路由链接到 profiles 控制器的 view 动作,所以 CakePHP 只会在链接到该动作并指定 userName 元素时使用我们的自定义路由。

我们仍然需要修复解析 URL(如 http://localhost/profiles)时产生的冲突。为此,我们创建另一个路由,以便在生成链接时不会使用 CakePHP 的内置 index 路由。此路由使用特殊的 :controller 路由元素(设置为链接指向的控制器),并强制将 index 动作作为 URL 的一部分。我们将此路由链接到使用 index 动作的所有路由,无论控制器是什么。

注意

要了解关于此问题的另一种更有效的方法,请参阅创建自定义 Route 类

在添加此路由后,如果我们创建了一个链接:

<?php echo $this->Html->link('Profiles', array(
'controller' => 'profiles',
'action' => 'index'
)); ?>

生成的 URL 将是 http://localhost/profiles/index

参见

  • 与路由元素一起工作

  • 为通配符路由添加验证

  • 创建自定义路由类

为通配符路由添加验证

在食谱 为个人资料页面添加通配符路由 中,我们创建了路由,以便可以通过仅指定 URL 中的用户名来访问个人资料页面。

在本食谱中,我们将学习如何实现自定义验证方法,以便这些用户名不会与其他自定义路由冲突。

准备工作

我们需要一些样本数据来工作,并且需要一个通配符路由。遵循整个食谱 为个人资料页面添加通配符路由

我们还需要注册页面,用于创建新的个人资料记录。编辑你的 app/controller/profiles_controller.php 文件,并在 ProfilesController 类定义中放置以下方法:

public function add() {
if (!empty($this->data)) {
$this->Profile->create($this->data);
if ($this->Profile->save()) {
$this->Session->setFlash('Profile created');
$this->redirect(array(
'action'=>'view',
'userName' => $this->data['Profile']['username']
));
} else {
$this->Session->setFlash('Please correct the errors below');
}
}
}

在名为 add.ctp 的文件中创建适当的视图,并将其放置在 app/views/profiles 文件夹中,内容如下:

<?php
echo $this->Form->create();
echo $this->Form->inputs(array(
'username',
'name'
));
echo $this->Form->end('Save');
?>

我们还需要一个自定义路由来尝试验证。编辑你的 app/config/routes.php 文件,并在开头添加以下路由:

Router::connect('/home', array(
'controller' => 'pages', 'action' => 'display', 'home'
));

如何操作...

  1. 编辑你的 app/models/profile.php 文件,并做出以下更改:

    <?php
    class Profile extends AppModel {
    public $validate = array(
    'username' => array(
    'notEmpty',
    'valid' => array(
    'rule' => 'validateUsername',
    'message' => 'This user name is reserved'
    )
    ),
    'name' => 'notEmpty'
    );
    }
    ?>
    
    
  2. 在编辑你的 app/models/profile.php 文件的同时,向 Profile 类添加以下方法:

    public function validateUsername($value, $params) {
    $reserved = Router::prefixes();
    $controllers = array_diff(
    Configure::listObjects('controller'),
    (array) 'App'
    );
    if (!empty($controllers)) {
    $reserved = array_merge($reserved, array_map(array('Inflector', 'underscore'), $controllers));
    }
    $routes = Router::getInstance()->routes;
    if (!empty($routes)) {
    foreach($routes as $route) {
    if (!empty($route->template) && preg_match('/^\/([^\/:]+)/', $route->template, $matches)) {
    $reserved[] = strtolower($matches[1]);
    }
    }
    }
    return !in_array(strtolower(array_shift($value)), $reserved);
    }
    
    

如果您现在浏览到 http://localhost/profiles/add 并将 home 作为用户名,Mark Doe 作为姓名,您将得到一个验证错误消息,告知您用户名已被保留,如下面的截图所示:

如何操作...

工作原理...

首先,我们为两个字段添加验证规则:usernamenameusername 字段的验证由两个规则组成:内置的 notEmpty 规则和一个名为 validateUsername 的自定义验证规则。name 字段只有一个规则:notEmpty

在我们的 validateUsername 规则实现中,我们首先将所有路由前缀存储到保留字列表中。然后我们使用 Configure::listObjects() 方法获取所有控制器列表,排除值 App,它是我们控制器的基础(因此不能直接访问)。然后我们将每个名称转换为小写和下划线形式。

我们通过获取 Router 类的实例并访问其 routes 公共属性来获取所有已定义的路由列表,然后我们查找每个路由的 template 属性。

这个属性存储了路由的字符串表示。对于我们在 准备工作 部分定义的路由,这将是一个 /home。我们只对值的起始部分感兴趣(即第一个斜杠之后,第二个斜杠之前的内容),因此我们使用正则表达式来匹配并提取这个值,然后将其添加到保留字列表中。

在我们的例子中,保留字列表将是:pages, profileshome。前两个来自我们的应用程序控制器列表,最后一个来自我们的自定义路由。

一旦我们有了保留字列表,我们只在该值不在该列表中时将字段设置为有效。

参见

  • 为个人页面添加通配符路由

创建自定义路由类

为个人页面添加通配符路由 的配方中,我们创建了路由,以便可以通过仅指定 URL 中的用户名来访问个人页面。然而,该实现有一个问题:我们必须禁止自动访问 index 动作。

这个配方展示了我们个人 URL 生成的一种不同方法,通过创建一个自定义路由实现,不仅克服了这个问题,而且确保该路由仅用于现有的个人记录。

准备工作

我们需要一些样本数据来工作。遵循 准备工作 部分的 为个人页面添加通配符路由 配方。

如何操作...

  1. 编辑您的 app/config/routes.php 文件,并在文件末尾添加以下路由:

    App::import('Lib', 'ProfileRoute');
    Router::connect('/:userName',
    array('controller' => 'profiles', 'action' => 'view'),
    array(
    'routeClass' => 'ProfileRoute',
    'pass' => array('userName')
    )
    );
    
    
  2. 现在创建一个名为 profile_route.php 的文件,并将其放置在您的 app/libs 文件夹中,内容如下:

    <?php
    App::import('Core', 'Router');
    class ProfileRoute extends CakeRoute {
    public function match($url) {
    if (!empty($url['userName']) && $this->_exists($url['userName'])) {
    return parent::match($url);
    }
    return false;
    }
    public function parse($url) {
    $params = parent::parse($url);
    if (!empty($params) && $this->_exists($params['userName'])) {
    return $params;
    }
    return false;
    }
    protected function _exists($userName) {
    $userNames = Cache::read('usernames');
    if (empty($userNames)) {
    $profiles = ClassRegistry::init('Profile')->find('all', array(
    'fields' => array('username'),
    'recursive' => -1
    ));
    if (!empty($profiles)) {
    $userNames = array_map(
    'strtolower',
    Set::extract('/Profile/username', $profiles)
    );
    Cache::write('usernames', $userNames);
    }
    }
    return in_array($userName, (array) $userNames);
    }
    }
    ?>
    
    
  3. 接下来,编辑您的 app/models/profile.php 文件,并将以下方法添加到 Profile 类中:

    public function afterSave($created) {
    parent::afterSave($created);
    Cache::delete('usernames');
    }
    public function afterDelete() {
    parent::afterDelete();
    Cache::delete('usernames');
    }
    
    

您现在可以浏览到http://localhost/john来查看约翰的个人资料页面。在 URL 中指定无效的名称(例如http://localhost/kate)将产生常规的 CakePHP 错误页面,而浏览到http://localhost/profiles将正确地带我们到个人资料索引页面。

它是如何工作的...

我们首先导入自定义路由类文件,然后使用自定义的ProfileRoute类定义一个用于profiles控制器view操作的通配路由,并将userName路由元素设置为作为常规参数传递。

ProfileRoute实现实现了两个最典型的路由类方法:

  1. match():在反向路由期间使用,将基于数组的 URL 转换为它的字符串表示形式。如果该方法返回false,那么提供的 URL 不适用于此路由。

  2. parse():当解析请求的 URL 为基于数组的 URL 时使用,指定controller, action和其他参数。如果该方法返回false,那么这告诉 CakePHP 给定的 URL 不是由这个路由处理的。

我们创建了一个名为_exists()的辅助方法来帮助我们,该方法在注册的记录中查找给定的用户名。出于明显的性能原因,我们缓存用户名列表,并在创建、修改或删除记录时通过在Profile模型中实现afterSaveafterDelete回调来使此缓存失效。

我们的match()实现首先检查确保提供了userName路由元素。如果提供了,并且指定的用户存在,它将使用父实现来返回字符串表示形式。在任何其他情况下(未提供用户名或用户不存在),它将不会处理给定的 URL。

parse()实现首先调用其父实现将字符串 URL 转换为基于数组的 URL。如果该调用成功(这意味着它包含userName路由元素),并且如果给定的用户名存在,它将返回转换。否则,它返回false以不处理给定的 URL。另一个路由处理程序或 CakePHP 的默认路由处理程序将处理它。

参见

  • 为个人资料页面添加通配路由

  • 自定义路由类

第七章。创建和消费网络服务

在本章中,我们将涵盖:

  • 创建 RSS 源

  • 消费 JSON 服务

  • 使用 JSON 构建 REST 服务

  • 为 REST 服务添加身份验证

  • 为 API 访问实现基于令牌的授权

简介

当我们希望向第三方应用程序公开应用程序功能或希望将外部服务集成到我们自己的应用程序中时,网络服务是必不可少的。它们提供了一套广泛的技术和定义,以便使用不同编程语言编写的系统可以相互通信。

本章介绍了一系列菜谱,用于消费网络服务以及将我们应用程序的某些部分公开为网络服务。

创建 RSS 源

RSS 源是一种网络服务形式,因为它们通过互联网提供一种服务,使用已知格式来公开数据。由于它们的简单性,它们是介绍我们进入网络服务世界的好方法,尤其是 CakePHP 提供了内置方法来创建它们。

在 第五章 的 使用数据源消费 RSS 源 菜谱中,我们学习了如何从外部 RSS 源获取内容。在这个菜谱中,我们将做完全相反的事情:为我们自己的网站生成一个可以被其他应用程序使用的源。

准备工作

为了完成这个菜谱,我们需要一个示例表来操作。使用以下 SQL 语句创建一个名为 posts 的表:

CREATE TABLE `posts`(posts
`id` INT NOT NULL AUTO_INCREMENT,
`title` VARCHAR(255) NOT NULL,
`body` TEXT NOT NULL,
`created` DATETIME NOT NULL,
`modified` DATETIME NOT NULL,
PRIMARY KEY(`id`)
);

使用以下 SQL 语句添加一些示例数据:

INSERT INTO `posts`(`title`,posts `body`, `created`, `modified`) VALUES
('Understanding Containable', 'Post body', NOW(), NOW()),
('Creating your first test case', 'Post body', NOW(), NOW()),
('Using bake to start an application', 'Post body', NOW(), NOW()),
('Creating your first helper', 'Post body', NOW(), NOW()),
('Adding indexes', 'Post body', NOW(), NOW());

我们现在继续创建所需的控制器。在 app/controllers 文件夹中创建一个名为 posts_controller.php 的类 PostsController,内容如下:

<?php
class PostsController extends AppController {
public function index() {
$posts = $this->Post->find('all');
$this->set(compact('posts'));
}
}
?>

在你的 app/views 文件夹中创建一个名为 posts 的文件夹,然后在 app/views/posts 文件夹中创建一个名为 index.ctp 的视图文件,并放置以下内容:

<h1>Posts</h1>
<?php if (!empty($posts)) { ?>
<ul>
<?php foreach($posts as $post) { ?>
<li><?php echo $this->Html->link(
$post['Post']['title'],
array(
'action'=>'view',
$post['Post']['id']
)
); ?></li>
<?php } ?>
</ul>
<?php } ?>

如何操作...

  1. 编辑你的 app/config/routes.php 文件,并在末尾添加以下语句:

    Router::parseExtensions('rss');
    
    
  2. 编辑你的 app/controllers/posts_controller.php 文件,并在 PostsController 类中添加以下属性:

    public $components = array('RequestHandler');
    
    
  3. 在编辑 PostsController 的同时,对 index() 方法进行以下更改:

    public function index() {
    $options = array();
    if ($this->RequestHandler->isRss()) {
    $options = array_merge($options, array(
    'order' => array('Post.created' => 'desc'),
    'limit' => 5
    ));
    }
    $posts = $this->Post->find('all', $options);
    $this->set(compact('posts'));
    }
    
    
  4. 在你的 app/views/posts 文件夹中创建一个名为 rss 的文件夹,并在 rss 文件夹中创建一个名为 index.ctp 的文件,内容如下:

    <?php
    $this->set('channel', array(
    'title' => 'Recent posts',
    'link' => $this->Rss->url('/', true),
    'description' => 'Latest posts in my site'
    ));
    $items = array();
    foreach($posts as $post) {
    $items[] = array(
    'title' => $post['Post']['title'],
    'link' => array('action'=>'view', $post['Post']['id']),
    'description' => array('cdata'=>true, 'value'=>$post['Post']['body']),
    'pubDate' => $post['Post']['created']
    );
    }
    echo $this->Rss->items($items);
    ?>
    
    
  5. 编辑你的 app/views/posts/index.ctp 文件,并在视图末尾添加以下内容:

    <?php echo $this->Html->link('Feed', array('action'=>'index', 'ext'=>'rss')); ?>
    
    

    如果你现在浏览到 http://localhost/posts,你应该会看到一个包含名为 Feed 的链接的帖子列表。点击此链接应该会生成一个有效的 RSS 源,如下面的截图所示:

    如何操作...

如果你查看生成的响应的源代码,你可以看到 RSS 文档中第一个项目的源代码如下:

<item> <title>Understanding Containable</title> <link>http://rss.cookbook7.kramer/posts/view/1</link> <description><![CDATA[Post body]]></description> <pubDate>Fri, 20 Aug 2010 18:55:47 -0300</pubDate> <guid>http://rss.cookbook7.kramer/posts/view/1</guid> </item>

工作原理...

我们通过调用Router::parseExtensions()方法来告诉 CakePHP 我们的应用程序接受rss扩展,这是一个接受任意数量扩展的方法。使用扩展,我们可以创建同一视图的不同版本。例如,如果我们想接受rssxml作为扩展,我们会这样做:

Router::parseExtensions('rss', 'xml');

在我们的配置中,我们将rss添加到了有效扩展列表中。这样,如果通过该扩展访问操作,例如使用 URL http://localhost/posts.rss,那么 CakePHP 会识别rss为有效扩展,并像平常一样执行ArticlesController::index()操作,但使用app/views/posts/rss/index.ctp文件来渲染视图。此过程还会使用文件app/views/layouts/rss/default.ctp作为布局,或者如果没有该文件,则使用 CakePHP 的默认 RSS 布局。

我们随后修改了ArticlesController::index()方法构建帖子列表的方式,并使用RequestHandler组件来检查当前请求是否使用了rss扩展。如果是的话,我们就利用这一信息来改变帖子的数量和顺序。

app/views/posts/rss/index.ctp视图文件中,我们首先设置一些视图变量。由于控制器视图总是在布局之前渲染,因此我们可以从视图文件中添加或更改视图变量,并在布局中使用它们。CakePHP 的默认 RSS 布局使用$channel视图变量来描述 RSS 源。通过使用该变量,我们设置了我们的源标题title、链接link和描述description

我们继续输出实际的条目文件。有几种不同的方法可以做到这一点,第一种是为每个条目调用RssHelper::item()方法,另一种方法只需要调用RssHelper::items(),并传递一个包含条目的数组。我们选择了后者,因为它更简单。

当我们构建要包含在源中的条目数组时,我们只指定titlelinkdescriptionpubDate。查看生成的 XML 源代码,我们可以推断出RssHelper使用了我们为link元素指定的值作为guid(全局唯一标识符)元素的值。

注意,description字段与其他字段在数组中的值指定略有不同。这是因为我们的描述可能包含 HTML 代码,因此我们想要确保生成的文档仍然是一个有效的 XML 文档。

通过使用description字段的数组表示法,使用value索引指定字段的实际值,并通过将cdata设置为true,我们告诉RssHelper(实际上是XmlHelperRssHelper是从它派生出来的)该字段应该被包裹在一个不应作为 XML 文档一部分的区域内,该区域由<![CDATA[前缀和]]>后缀表示。

本食谱中的最后一项任务是向我们的 index.ctp 视图文件中添加一个链接。在创建此链接时,我们将特殊的 ext URL 设置为 rss。这设置了生成链接的扩展名,最终结果是 http://localhost/posts.rss

向 RSS 源添加视图缓存

我们的内容源可能被内容搜索爬虫消费。如果我们很幸运,我们可能会收到大量的请求,寻找我们博客的更新。我们不太可能频繁更新我们的博客,以至于每秒都有新的帖子,因此我们的服务器负载可能迫使我们添加一些缓存。

当试图提高性能时,一些开发者只满足于缓存他们的数据库查询。在我们的食谱中,这意味着缓存从我们的 $this->Post->find('all') 调用中获得的结果。除非我们的数据库引擎在单独的服务器上运行,并且遭受一些相当大的网络延迟,否则这种类型的缓存可能只会带来很少或没有好处。

一个更好的解决方案是使用视图缓存。也就是说,缓存生成的 RSS 源,并在请求我们的源时使用该缓存文档,前提是我们处于缓存时间内。幸运的是,CakePHP 从调度器直接为我们提供了视图缓存实现,大大加快了请求速度。如果找到缓存的视图文件,则该文件将直接渲染到客户端,无需控制器干预,也不需要加载模型、组件或辅助器。

我们只想在通过 rss 扩展访问 PostsController::index() 动作时添加缓存。也就是说,我们不想缓存帖子列表,而是其源。因此,我们将确保只在请求源时指定缓存信息。实际上,我们将使用 rss 扩展时缓存 PostsController 中的所有操作。

我们需要做的第一件事是告诉 CakePHP 考虑视图缓存。编辑你的 app/config/core.php 文件,并取消注释以下行:

Configure::write('Cache.check', true);

接下来,编辑你的 app/controllers/posts_controller.php 文件,并将 Cache 辅助器添加到 PostsController 类中。没有它,视图缓存将无法正常工作:

public $helpers = array('Cache');

在继续编辑 PostsController 类的同时,添加以下方法:

public function beforeFilter() {
parent::beforeFilter();
if ($this->RequestHandler->isRss()) {
$this->cacheAction = array($this->action => '1 hour');
}
}

在这个 beforeFilter() 实现中,我们正在检查当前请求是否使用了 rss 扩展。如果是这样,我们将当前操作(无论是什么)添加到缓存操作列表中,并将缓存时间设置为 1 小时

如果我们在一小时内多次访问该源,我们应该看到我们迄今为止一直获得的相同源,但来自缓存而不是实时构建。

参见

  • 使用数据源消费 RSS 源 在 第五章,数据源

  • 使用 JSON 构建 REST 服务

消费 JSON 服务

JSONJavaScript 对象表示法)可能是目前公开数据格式中最好的之一,因为它易于阅读的语法大大简化了解析。实际上,PHP(自 5.2.0 版本起)提供了内置方法,可以将数据从 JSON 格式的字符串转换为 PHP 原生数据类型,以及从 PHP 类型转换为 JSON。

在这个菜谱中,我们将学习如何使用HttpSocket类从外部网站消费 JSON 服务。这次,我们将使用 YouTube JSON API,允许我们的用户搜索与给定搜索查询匹配的 YouTube 视频。

我们将从 YouTube 消费的 JSON 服务使用一种名为 JSON-C 的 JSON 变体。JSON-C 实际上就是 JSON,但谷歌正在区分 YouTube 过去提供的 JSON 和现在生产的新版本。YouTube 基于 JSON-C 的响应比其 JSON 服务简单得多。因此,谷歌决定在不久的将来弃用 JSON,转而使用 JSON-C。

如何实现...

  1. 首先在名为videos_controller.php的文件中创建主要控制器,并将其放置在app/controllers文件夹中,内容如下:

    <?php
    class VideosController extends AppController {
    public function index() {
    if (!empty($this->data)) {
    $videos = $this->Video->search($this->data);
    $this->set(compact('videos'));
    }
    }
    }
    ?>
    
    
  2. 在名为video.php的文件中创建所需的模型,并将其放置在app/models文件夹中,内容如下:

    <?php
    App::import('Core', 'HttpSocket');
    class Video extends AppModel {
    public $useTable = false;
    protected $_httpSocket;
    public function __construct($id = false, $table = null, $ds = null) {
    parent::__construct($id, $table, $ds);
    $this->_httpSocket = new HttpSocket();
    }
    public function search($data) {
    $query = !empty($data[$this->alias]['q']) ?
    $data[$this->alias]['q'] :
    '';
    $this->_httpSocket->reset();
    $response = $this->_httpSocket->get(
    'http://gdata.youtube.com/feeds/api/videos',
    array(
    'v' => '2',
    'alt' => 'jsonc',
    'q' => $query,
    'orderby' => 'updated'
    )
    );
    $videos = array();
    if (!empty($response)) {
    $response = json_decode($response);
    if (empty($response) || empty($response->data->items)) {
    return $videos;
    }
    foreach($response->data->items as $item) {
    $videos[] = array('Video' => array(
    'url' => $item->player->default,
    'title' => $item->title,
    'uploaded' => strtotime($item->uploaded),
    'category' => $item->category,
    'description' => $item->description,
    'thumbnail' => $item->thumbnail->sqDefault
    ));
    }
    }
    return $videos;
    }
    }
    ?>
    
    
  3. app/views文件夹中创建一个名为videos的视图文件夹。然后,创建一个名为index.ctp的文件,并将其放置在app/views/videos文件夹中,内容如下:

    <?php
    echo $this->Form->create();
    echo $this->Form->input('q', array('label'=>'Search terms:'));
    echo $this->Form->end('Search');
    if (!empty($videos)) {
    ?>
    <h1>Search results</h1>
    <?php foreach($videos as $video) { ?>
    <div style="float: left; clear: both; margin-bottom: 10px;">
    <h4><?php echo $this->Html->link($video['Video']['title'], $video['Video']['url']); ?></h4>
    <?php echo $this->Html->image($video['Video']['thumbnail'], array(
    'url' => $video['Video']['url'],
    'align' => 'left',
    'style' => 'margin-right: 10px;'
    )); ?>
    <p><?php echo $video['Video']['description']; ?></p>
    <br />
    <p><small>
    Uploaded on <?php echo date('F d, Y H:i', $video['Video']['uploaded']); ?>
    in <?php echo $video['Video']['category']; ?>
    -
    <strong><?php echo $this->Html->link('PLAY', $video['Video']['url']); ?></strong>
    </small></p>
    </div>
    <?php
    }
    }
    ?>
    
    

    如果你现在浏览到http://localhost/videos,你会看到一个搜索表单。输入CakePHP并点击搜索按钮应该会给你一组类似于以下截图所示的结果:

    如何实现...

它是如何工作的...

控制器类(ArticlesController)和视图文件(index.ctp)与我们消费的底层 Web 服务没有关联。实际上,如果你仔细查看它们的代码,它们看起来就像一个常规控制器和一个标准视图文件。这是因为我们决定在模型中封装服务逻辑。

这样做允许我们更改与服务提供商的通信方式,而无需修改控制器或视图。这是 MVC(模型-视图-控制器)架构的许多优点之一,它是 CakePHP 的基础。

我们本可以采取更复杂的方法,决定构建一个数据源来与服务器交互。相反,我们选择了一条更简单的路线,通过创建一个模型方法来执行实际的搜索,并以任何 CakePHP 应用程序典型的数据格式返回结果。

这正是Video模型存在的原因。由于我们的视频没有底层表,我们将模型$useTable属性设置为false。我们还导入了HttpSocket类,它是 CakePHP 核心的一部分,因为我们将使用它来与服务器通信。

search() 方法是魔法发生的地方。我们首先从提交的数据中提取搜索词。然后我们创建一个 HttpSocket 实例,并使用其 get 方法执行请求。

HttpSocket::get() 接受三个参数:

  • $uri: 我们要请求的 URL。这可以是一个字符串,也可以是一个包含 URL 不同元素的数组,例如 scheme, host, portpath

  • $query: 要附加到 URL 的参数数组。该数组的索引是参数名称及其相应值。

  • $request: 一个包含要发送到 URL 的任何附加请求信息的数组,例如 method, headerbody

在我们的例子中,我们指定了 YouTube 视频 API 的 URL,并设置了以下查询参数:

  • v: 要使用的 API 版本。

  • alt: 获取结果时要使用的格式。

  • q: 用于搜索的查询。

  • orderby: 获取结果时的排序方式。

一旦我们得到响应,我们使用 PHP 的 json_decode() 函数对其进行解码,该函数将 JSON 字符串转换为 PHP 对象或 null(如果它不是一个有效的 JSON 字符串)。例如,以下 JSON:

{
"name": "Mariano Iglesias",
"profile": {
"url": "http://marianoiglesias.com.ar"
}
}

将被评估为具有两个公共属性:nameprofile 的 PHP 类。profile 属性本身也是一个类,具有一个公共属性:url。如果我们有一个名为 $json 的变量包含上述 JSON 字符串,以下代码将输出 Mariano Iglesias 在 http://marianoiglesias.com.ar 有一个网站

$user = json_decode($json);
echo $user->name . ' has a website in ' . $user->profile->url;

回到 Video::search() 方法。一旦我们解码了 JSON 响应,我们检查 $response->data->items 属性中是否有可用的结果视频。如果有,我们就遍历它们,并将元素添加到我们的响应数组中,只指定我们获得的数据的子集。

一旦我们准备好了数据,我们就将其返回给控制器,控制器将其发送到视图以渲染结果。

参见

  • 第五章, 数据源

  • 使用 JSON 构建 REST 服务

使用 JSON 构建 REST 服务

消费 JSON 服务 的食谱中,我们了解到 JSON 格式在交换数据时是多么轻量级和方便。如果我们不仅想使用 JSON 公开数据,还允许修改它的可能性,会发生什么?这是 REST 架构存在的原因之一。REST 代表 表征状态转移,它不过是一组原则,指导描述其正确实现的各个概念。

其中一个主要原则是,REST 请求中作为其一部分的客户端-服务器通信应该是无状态的。这意味着在服务器中不存在特定客户端请求之间的任何上下文。执行操作所需的所有信息都是请求的一部分。

在这个食谱中,我们将学习如何使用 JSON 作为交换格式将 REST 服务添加到应用程序中。这些服务将允许任何外部应用程序从帖子中获取数据,创建新的帖子或删除现有的帖子。

准备工作

为了完成这个食谱,我们需要一些样本数据来工作。遵循“创建 RSS 源”食谱中的“准备工作”部分。

在名为post.php的文件中创建Post模型,并将其放置在你的app/models文件夹中,内容如下。通过验证选项required,我们告诉 CakePHP 这些字段在创建或修改记录时始终存在:

<?php
class Post extends AppModel {
public $validate = array(
'title' => array('required'=>true, 'rule'=>'notEmpty'),
'body' => array('required'=>true, 'rule'=>'notEmpty')
);
}
?>

让我们添加创建、编辑和删除帖子的操作。编辑你的app/controllers/posts_controller.php文件,并在PostsController类中添加以下方法:

public function add() {
$this->setAction('edit');
}
public function edit($id=null) {
if (!empty($this->data)) {
if (!empty($id)) {
$this->Post->id = $id;
} else {
$this->Post->create();
}
if ($this->Post->save($this->data)) {
$this->Session->setFlash('Post created successfully');
$this->redirect(array('action'=>'index'));
} else {
$this->Session->setFlash('Please correct the errors marked below');
}
} elseif (!empty($id)) {
$this->data = $this->Post->find('first', array(
'conditions' => array('Post.id' => $id)
));
if (empty($this->data)) {
$this->cakeError('error404');
}
}
$this->set(compact('id'));
}
public function delete($id) {
$post = $this->Post->find('first', array(
'conditions' => array('Post.id' => $id)
));
if (empty($post)) {
$this->cakeError('error404');
}
if (!empty($this->data)) {
if ($this->Post->delete($id)) {
$this->Session->setFlash('Post deleted successfully');
$this->redirect(array('action'=>'index'));
} else {
$this->Session->setFlash('Could not delete post');
}
}
$this->set(compact('post'));
}

我们现在需要添加它们相应的视图。创建一个名为edit.ctp的文件,并将其放置在你的app/views/posts文件夹中,内容如下:

<?php
echo $this->Form->create();
echo $this->Form->inputs(array(
'title',
'body'
));
echo $this->Form->end('Save');
?>

创建一个名为delete.ctp的文件,并将其放置在你的app/views/posts文件夹中,内容如下:

<p>Click the <strong>Delete</strong> button to delete
the post <?php echo $post['Post']['title']; ?></p>
<?php
echo $this->Form->create(array('url'=>array('action'=>'delete', $post['Post']['id'])));
echo $this->Form->hidden('Post.id', array('value'=>$post['Post']['id']));
echo $this->Form->end('Delete');
?>

修改app/views/posts/index.ctp,通过更改整个视图来添加这些操作的链接:

<h1>Posts</h1>
<?php if (!empty($posts)) { ?>
<ul>
<?php foreach($posts as $post) { ?>
<li>
<?php echo $this->Html->link($post['Post']['title'], array(
'action'=>'view',
$post['Post']['id']
)); ?>
:
<?php echo $this->Html->link('Edit', array(
'action'=>'edit',
$post['Post']['id']
)); ?>
-
<?php echo $this->Html->link('Delete', array(
'action'=>'delete',
$post['Post']['id']
)); ?>
</li>
<?php } ?>
</ul>
<?php } ?>
<?php echo $this->Html->link('Create new Post', array('action'=>'add')); ?>

如何操作...

  1. 编辑你的app/config/routes.php文件,并在文件末尾添加以下语句:

    Router::parseExtensions('json');
    
    
  2. 编辑你的app/controllers/posts_controller.php文件,并在PostsController类中添加以下属性:

    public $components = array('RequestHandler');
    
    
  3. 在你的app/views/layouts文件夹中创建一个名为json的文件夹,然后在json文件夹内创建一个名为default.ctp的文件,内容如下:

    <?php
    echo $content_for_layout;
    ?>
    
    
  4. 在你的app/views/posts文件夹中创建一个名为json的文件夹,然后在json文件夹内创建一个名为index.ctp的文件,内容如下:

    <?php
    foreach($posts as $i => $post) {
    $post['Post']['url'] = $this->Html->url(array(
    'action'=>'view',
    $post['Post']['id']
    ), true);
    $posts[$i] = $post;
    }
    echo json_encode($posts);
    ?>
    
    
  5. 编辑你的app/controllers/posts_controller.php文件,并在PostsController类的末尾添加以下方法:

    protected function _isJSON() {
    return $this->RequestHandler->ext == 'json';
    }
    
    
  6. 编辑PostsController::index()方法,并做出以下更改:

    public function index() {
    if ($this->_isJSON() && !$this->RequestHandler->isGet()) {
    $this->redirect(null, 400);
    }
    $posts = $this->Post->find('all');
    $this->set(compact('posts'));
    }
    
    
  7. PostsController类的components属性声明下方添加以下方法:

    public function beforeFilter() {
    parent::beforeFilter();
    if (
    $this->_isJSON() &&
    !$this->RequestHandler->isGet()
    ) {
    if (empty($this->data) && !empty($_POST)) {
    $this->data[$this->modelClass] = $_POST;
    }
    }
    }
    public function beforeRender() {
    parent::beforeRender();
    if ($this->_isJSON()) {
    Configure::write('debug', 0);
    $this->disableCache();
    }
    }
    
    
  8. 编辑PostsController::edit()方法,并做出以下更改:

    public function edit($id=null) {
    if ($this->_isJSON() && !$this->RequestHandler->isPost()) {
    $this->redirect(null, 400);
    }
    if (!empty($this->data)) {
    if (!empty($id)) {
    $this->Post->id = $id;
    } else {
    $this->Post->create();
    }
    if ($this->Post->save($this->data)) {
    $this->Session->setFlash('Post created successfully');
    if ($this->_isJSON()) {
    $this->redirect(null, 200);
    } else {
    $this->redirect(array('action'=>'index'));
    }
    } else {
    if ($this->_isJSON()) {
    $this->redirect(null, 403);
    } else {
    $this->Session->setFlash('Please correct the errors marked below');
    }
    }
    } elseif (!empty($id)) {
    $this->data = $this->Post->find('first', array(
    'conditions' => array('Post.id' => $id)
    ));
    if (empty($this->data)) {
    if ($this->_isJSON()) {
    $this->redirect(null, 404);
    }
    $this->cakeError('error404');
    }
    }
    $this->set(compact('id'));
    }
    
    
  9. 编辑PostsController::delete()方法,并做出以下更改:

    public function delete($id) {
    if ($this->_isJSON() && !$this->RequestHandler->isDelete()) {
    $this->redirect(null, 400);
    }
    $post = $this->Post->find('first', array(
    'conditions' => array('Post.id' => $id)
    ));
    if (empty($post)) {
    if ($this->_isJSON()) {
    $this->redirect(null, 404);
    }
    $this->cakeError('error404');
    }
    if (!empty($this->data) || $this->RequestHandler->isDelete()) {
    if ($this->Post->delete($id)) {
    $this->Session->setFlash('Post deleted successfully');
    if ($this->_isJSON()) {
    $this->redirect(null, 200);
    } else {
    $this->redirect(array('action'=>'index'));
    }
    } else {
    if ($this->_isJSON()) {
    $this->redirect(null, 403);
    } else {
    $this->Session->setFlash('Could not delete post');
    }
    }
    }
    $this->set(compact('post'));
    }
    
    

为了测试这些服务,我们将创建一个小的 CakePHP shell,它将创建一个新的帖子,编辑已创建的帖子,删除它,并在整个过程中显示帖子列表。创建一个名为consume.php的文件,并将其放置在你的app/vendors/shells文件夹中,内容如下:

<?php
App::import('Core', 'HttpSocket');
class ConsumeShell extends Shell {
protected static $baseUrl;
protected static $httpSocket;
public function main() {
if (empty($this->args) || count($this->args) != 1) {
$this->err('USAGE: cake consume <baseUrl>');
$this->_stop();
}
self::$baseUrl = $this->args[0];
$this->test();
}
protected function test() {
$this->request('/posts/add.json', 'POST', array(
'title' => 'New Post',
'body' => 'Body for my new post'
));
$lastId = $this->listPosts();
$this->hr();
$this->request('/posts/edit/'.$lastId.'.json', 'POST', array(
'title' => 'New Post Title',
'body' => 'New body for my new post'
));
$this->listPosts();
$this->hr();
$this->request('/posts/delete/'.$lastId.'.json', 'DELETE');
$this->listPosts();
}
protected function request($url, $method='GET', $data=null) {
if (!isset(self::$httpSocket)) {
self::$httpSocket = new HttpSocket();
} else {
self::$httpSocket->reset();
}
$body = self::$httpSocket->request(array(
'method' => $method,
'uri' => self::$baseUrl . '/' . $url,
'body' => $data
));
if ($body === false || self::$httpSocket->response['status']['code'] != 200) {
$error = 'ERROR while performing '.$method.' to '.$url;
if ($body !== false) {
$error = '[' . self::$httpSocket->response['status']['code'] . '] ' . $error;
}
$this->err($error);
$this->_stop();
}
return $body;
}
protected function listPosts() {
$response = json_decode($this->request('/posts.json'));
$lastId = null;
foreach($response as $item) {
$lastId = $item->Post->id;
$this->out($item->Post->title . ': ' . $item->Post->url);
}
return $lastId;
}
}
?>

要运行此 shell 脚本,使用一个参数调用它:你应用程序的基本 URL。所以将下面的http://localhost更改为适合你应用程序的 URL:

  • 如果你使用的是 GNU Linux / Mac / Unix 系统:

    ../cake/console/cake consume http://localhost
    
    
  • 如果你使用的是 Microsoft Windows:

    ..\cake\console\cake.bat consume http://localhost
    
    

输出应类似于以下截图所示:

如何操作...

我们可以看到,第一列帖子显示了我们的新创建的帖子,标题为新帖子。第二列显示了如何成功将其标题更改为新帖子标题,第三列显示了如何删除该帖子。

它是如何工作的...

与在创建一个 RSS 源菜谱中描述的类似,我们首先指定json作为有效扩展,并将RequestHandler组件添加到我们的组件列表中。

rssxml扩展不同,CakePHP 没有为json提供默认布局,因此我们需要创建一个。通过beforeRender回调,我们关闭调试,并在发送 JSON 请求时禁用缓存,以避免任何会破坏 JSON 语法并阻止客户端浏览器缓存 JSON 请求的信息。

注意

当向使用RequestHandler组件的控制器发送 JSON 请求时,该组件将自动将响应的内容类型设置为application/json

一旦我们有了布局,我们就可以开始实现我们的 JSON 视图了。在这个菜谱中,我们只实现了index()作为一个返回 JSON 数据的 JSON 动作。所有其他动作——add()edit()delete()——将简单地使用 HTTP 状态码与客户端通信。JSON index.ctp视图将简单地为每个帖子添加完整的 URL,并使用json_encode()将整个数据结构作为 JSON 格式的字符串回显。

由于我们将根据访问类型(JSON 与正常访问)更改一些控制器逻辑,我们在控制器中添加了一个名为_isJSON()的方法。此方法使用RequestHandler组件的ext属性,该属性设置为请求动作的扩展名。如果没有使用扩展名,则默认为html。使用此属性,我们可以检查是否使用json扩展名发送了请求。

使用_isJSON(),我们还可以在我们的方法中添加一些额外的检查,以确保它们以正确的方式请求。对于我们的index动作,我们确保如果请求是使用 JSON 发送的,我们只允许 GET 请求通过。如果请求是使用任何其他方法发送的,例如 POST,则我们返回 HTTP 状态400(错误请求),并退出应用程序。

注意

当不需要向客户端发送数据时,HTTP 状态码是通知 REST 请求是否成功或失败的好方法。

为了帮助我们的 REST 请求的用户,我们应该允许他们在不了解数据需要如何格式化以便 CakePHP 自动处理的情况下发送数据。因此,我们覆盖了beforeFilter回调,如果发送了 JSON 请求(不是 GET 请求),并且 CakePHP 没有找到任何正确格式化的数据(当确实发送了数据时),则我们将发送的内容设置为控制器数据。这样,在创建或修改帖子时,客户端代码可以简单地使用title来引用帖子title字段,而无需使用data[Post][title]作为字段的名称。

然后,我们继续对edit()方法进行必要的修改。我们首先确保我们使用的是正确的方法(POST),并更改我们报告成功或失败的方式:当帖子保存时,使用 HTTP 状态200(OK),如果帖子无法保存,则使用403(禁止),如果尝试编辑一个不存在的帖子,则使用404(未找到)。

delete()方法的修改几乎与对edit()方法的修改相同。两个主要区别是期望的方法是 DELETED,并且当通过 JSON 访问时,我们不强制提交数据。

为了测试这个配方中的代码,我们构建了一个 shell 脚本来消费我们的 REST 服务。这个脚本使用HttpSocket类来获取内容。在这个 shell 脚本中,我们构建了一个通用的request()函数,该函数接受一个 URL、一个方法(我们使用 GET、POST 和 DELETE),以及一个可选的数据数组来提交。

我们使用request()方法创建一个新的帖子(注意我们如何指定titlebody字段的值),获取应包括我们新创建的帖子的帖子列表,修改创建的帖子,最后删除它。

参见

  • 创建一个 RSS 源

  • 将身份验证添加到 REST 服务

将身份验证添加到 REST 服务

在之前的配方使用 JSON 构建 REST 服务中,我们学习了如何启用对我们的动作的 JSON 访问,包括使用简单的 JSON 请求创建、修改或删除帖子的能力。

如果我们不添加某种形式的身份验证,通过 REST 请求修改数据可能会导致敏感数据丢失。这个配方展示了如何通过 HTTP 基本身份验证强制我们的数据更改 REST 服务只被有效用户使用。

准备工作

为了完成这个配方,我们需要一些基于 JSON 的 REST 服务实现。请按照使用 JSON 构建 REST 服务的整个配方进行操作。

我们还需要为我们的应用程序提供一个有效的身份验证。请按照身份验证章节中的整个配方设置基本身份验证系统进行操作。

如何操作...

编辑你的app/controller/posts_controller.php文件,并对beforeFilter回调进行以下更改:

public function beforeFilter() {
parent::beforeFilter();
if ($this->_isJSON()) {
$this->Auth->allow($this->action);
$this->Security->loginOptions = array(
'type' => 'basic',
'realm' => 'My REST services,services
'login' => '_restLogin'
);
$this->Security->requireLogin($this->action);
$this->Security->validatePost = false;
}
if (
$this->_isJSON() &&
!$this->RequestHandler->isGet()
) {
if (empty($this->data) && !empty($_POST)) {
$this->data[$this->modelClass] = $_POST;
}
}
}

当我们仍在编辑PostsController类时,在beforeFilter()方法下方添加以下方法:

public function _restLogin($credentials) {
$login = array();
foreach(array('username', 'password') as $field) {
$value = $credentials[$field];
if ($field == 'password' && !empty($value)) {
$value = $this->Auth->password($value);
}
$login[$this->Auth->fields[$field]] = $value;
}
if (!$this->Auth->login($login)) {
$this->Security->blackhole($this, 'login');
}
}

如果我们现在浏览到http://localhost/posts,我们将看到一个登录界面。由于系统中没有用户,我们需要通过浏览到http://localhost/users/add并指定所需的用户名和密码来创建一个用户。

让我们运行测试 shell 脚本(记得将http://localhost更改为适合你应用程序的基本 URL)。

  • 如果你使用的是 GNU Linux / Mac / Unix 系统:

    ../cake/console/cake consume http://localhost
    
    
  • 如果你使用的是 Microsoft Windows:

    ..\cake\console\cake.bat consume http://localhost
    
    

其输出将告诉我们帖子创建失败,状态码为401(未授权),如下面的截图所示:

如何操作...

如果在遵循设置基本认证系统食谱的过程中还没有这样做,请通过浏览到 http://localhost/users/add 并指定所需的用户名和密码来创建用户帐户。

我们需要修改脚本以指定我们创建的用户和密码。

编辑 app/vendors/shells/consume.php 脚本,并将以下两个属性添加到 ConsumeShell 类中:

protected static $user;
protected static $password;

在继续编辑脚本的同时,对 main() 方法进行以下更改:

public function main() {
if (empty($this->args) || count($this->args) != 3) {
$this->err('USAGE: cake consume <baseUrl> <user> <password>');
$this->_stop();
}
list(self::$baseUrl, self::$user, self::$password) = $this->args;
$this->test();
}

request() 方法进行以下更改:

protected function request($url, $method='GET', $data=null) {
if (!isset(self::$httpSocket)) {
self::$httpSocket = new HttpSocket();
} else {
self::$httpSocket->reset();
}
$body = self::$httpSocket->request(array(
'method' => $method,
'uri' => self::$baseUrl . '/' . $url,
'body' => $data,
'auth' => array(
'user' => self::$user,
'pass' => self::$password
)
));
if ($body === false || self::$httpSocket->response['status']['code'] != 200) {
$error = 'ERROR while performing '.$method.' to '.$url;
if ($body !== false) {
$error = '[' . self::$httpSocket->response['status']['code'] . '] ' . $error;
}
$this->err($error);
$this->_stop();
}
return $body;
}

我们现在可以运行脚本,指定我们创建的用户名和密码。将 http://localhost 更改为匹配您的应用程序的 URL,将 user 更改为匹配用户名,将 password 更改为匹配创建的密码:

  • 如果您使用的是 GNU Linux / Mac / Unix 系统:

    ../cake/console/cake consume http://localhost user password
    
    
  • 如果您使用的是 Microsoft Windows:

    ..\cake\console\cake.bat consume http://localhost user password
    
    

运行脚本应该给出与食谱使用 JSON 构建 REST 服务中所示相同的成功输出。

它是如何工作的...

我们首先在通过 JSON 请求时添加了一些特殊的逻辑到 beforeFilter 回调中。在其中,我们首先告诉 Auth 组件所请求的操作是公开的。如果我们不这样做,Auth 组件会向客户端渲染登录表单,这显然不是一个有效的 JSON 响应。

注意

此食谱使用基于数据库的认证方法。可以通过实现基本的 HTTP 认证来采取更简单的方法,这是一个在 book.cakephp.org/view/1309/Basic-HTTP-Authentication 中介绍的概念。

一旦我们确定 Auth 组件不会处理通过 JSON 请求的任何授权操作,我们需要添加对 HTTP Basic 认证的支撑。我们通过首先配置 Security 组件的 loginOptions 属性并使用以下设置来实现这一点:

  • type: 要使用的 HTTP 认证类型,可以是 basicdigest。我们选择了 basic

  • realm: 访问系统的描述性名称。

  • login: 当客户端尝试通过 HTTP 认证登录时调用的可选函数。由于我们将使用 Auth 组件来验证登录,我们指定自己的自定义函数,命名为 _restLogin,以验证用户。

一旦我们配置了 Security,我们就使用它的 requireLogin() 方法将当前操作标记为需要 HTTP 认证的。

我们还需要考虑 Security 组件对某些请求执行的特殊检查。当数据被提交时,组件将寻找一个特殊令牌,该令牌应该保存在会话中,并作为请求的一部分提交。这是一个很棒的功能,因为它可以防止对隐藏字段的操纵,因为令牌包含所有已知表单值的哈希。

自然地,这不应该适用于 REST 请求,因为我们在《使用 JSON 构建 REST 服务》的引言中描述 REST 架构时了解到,REST 请求是无状态的。因此,我们通过将Security组件的validatePost属性设置为false来禁用此功能。

最后一步是实现当Security组件尝试进行 HTTP 身份验证登录时被调用的方法。我们将其命名为_restLogin(),通过在前面加下划线来防止直接访问它。此方法只接受一个参数,一个包含两个必填键的索引数组:usernamepassword

由于Auth组件可以配置为使用任何字段名作为usernamepassword字段,我们需要确保在尝试登录之前使用配置的字段名。Auth组件的fields属性包含此配置,以数组形式索引,索引为usernamepassword

当我们收到对_restLogin()的调用时,password字段的值是纯文本,因为这是 HTTP 基本身份验证的标准方式。然而,Auth组件只接受散列密码,因此我们需要通过使用Auth组件的password()方法来散列给定的密码。

一旦使用了正确的字段名,并且密码被散列,我们就准备好尝试登录。我们调用Auth组件的login()方法,如果登录成功则返回true,否则返回false。如果登录失败,我们使用Security组件的blackHole()方法,指定失败原因(登录,这对应于 401 HTTP 状态码),这将阻止客户端请求。

实现基于令牌的 API 访问授权

在之前的食谱《向 REST 服务添加认证》中,我们为PostsController操作构建了一个使用 JSON 的 REST API。有了它,利用我们的 REST 服务的客户端使用用户账户来验证他们的请求。

在不忽视授权所有请求需求的同时,一些公司在发布他们的 API 时采取不同的方法:使用 API 令牌。使用 API 令牌的优势在于我们的用户账户不会在客户端脚本中暴露,因此授权信息不能用来登录网站。

在本食谱中,我们将使用我们的认证 REST 服务系统并启用使用令牌来使用公开的 API。我们还将添加使用限制,以便客户端 API 的使用仅限于一定的时间和次数阈值内。

准备工作

为了完成这个食谱,我们需要一些实现了认证的基于 JSON 的 REST 服务,所以请遵循之前的食谱。

如何操作...

  1. 我们首先向我们的users表添加一些字段。执行以下 SQL 语句:

    ALTER TABLE `users`users
    ADD COLUMN `token` CHAR(40) default NULL,
    ADD COLUMN `token_used` DATETIME default NULL,
    ADD COLUMN `token_uses` INT NOT NULL default 0,
    ADD UNIQUE KEY `token`(`token`);
    
    
  2. 编辑你的app/controllers/users_controller.php文件,并将以下方法添加到UsersController类中:

    public function token() {
    $token = sha1(String::uuid());
    $this->User->id = $this->Auth->user('id');
    if (!$this->User->saveField('token', $token)) {
    $token = null;
    $this->Session->setFlash('There was an error generating this token');
    }
    $this->set(compact('token'));
    }
    
    
  3. 在名为 token.ctp 的文件中创建其视图,并将其放置在 app/views/users 文件夹中,内容如下:

    <h1>API access token</h1>
    <?php if (!empty($token)) { ?>
    <p>Your new API access token is: <strong><?php echo $token; ?></strong></p>
    <?php } ?>
    
    
  4. 让我们添加定义 API 访问限制的参数。编辑你的 app/config/bootstrap.php 文件,并在末尾添加以下内容:

    Configure::write('API', array(
    'maximum' => 6,
    'time' => '2 minutes'
    ));
    
    
  5. 编辑你的 app/controllers/posts_controller.php 文件,并更改 _restLogin() 方法,用以下内容替换:

    public function _restLogin($credentials) {
    $model = $this->Auth->getModel();
    try {
    $id = $model->useToken($credentials['username']);
    if (empty($id)) {
    $this->redirect(null, 503);
    }
    } catch(Exception $e) {
    $id = null;
    }
    if (empty($id) || !$this->Auth->login(strval($id))) {
    $this->Security->blackhole($this, 'login');
    }
    }
    
    
  6. 在名为 user.php 的文件中创建 User 模型,并将其放置在 app/models 文件夹中,内容如下:

    <?php
    class User extends AppModel {
    public function useToken($token) {
    $user = $this->find('first', array(
    'conditions' => array($this->alias.'.token' => $token),
    'recursive' => -1
    ));
    if (empty($user)) {
    throw new Exception('Token is not valid');
    }
    $apiSettings = Configure::read('API');
    $tokenUsed = !empty($user[$this->alias]['token_used']) ? $user[$this->alias]['token_used'] : null;
    $tokenUses = $user[$this->alias]['token_uses'];
    if (!empty($tokenUsed)) {
    $tokenTimeThreshold = strtotime('+' . $apiSettings['time'], strtotime($tokenUsed));
    }
    $now = time();
    if (!empty($tokenUsed) && $now <= $tokenTimeThreshold && $tokenUses >= $apiSettings['maximum']) {
    return false;
    }
    $id = $user[$this->alias][$this->primaryKey];
    if (!empty($tokenUsed) && $now <= $tokenTimeThreshold) {
    $this->id = $id;
    $this->saveField('token_uses', $tokenUses + 1);
    } else {
    $this->id = $id;
    $this->save(
    array('token_used'=>date('Y-m-d H:i:s'), 'token_uses'=>1),
    false,
    array('token_used', 'token_uses')
    );
    }
    return $id;
    }
    }
    ?>
    
    
  7. 编辑你的 app/vendors/shells/consume.php 测试脚本,删除 $user$password 属性,然后添加以下属性:

    protected $token;
    
    
  8. 在编辑 shell 脚本的同时,对其 main() 方法进行以下修改:

    public function main() {
    if (empty($this->args) || count($this->args) != 2) {
    $this->err('USAGE: cake consume <baseUrl> <token>');
    $this->_stop();
    }
    list(self::$baseUrl, self::$token) = $this->args;
    $this->test();
    }
    
    
  9. 最后,对 request() 方法进行以下修改:

    protected function request($url, $method='GET', $data=null) {
    if (!isset(self::$httpSocket)) {
    self::$httpSocket = new HttpSocket();
    } else {
    self::$httpSocket->reset();
    }
    $body = self::$httpSocket->request(array(
    'method' => $method,
    'uri' => self::$baseUrl . '/' . $url,
    'body' => $data,
    'auth' => array(
    'user' => self::$token,
    'pass' => ''
    )
    ));
    if ($body === false || self::$httpSocket->response['status']['code'] != 200) {
    $error = 'ERROR while performing '.$method.' to '.$url;
    if ($body !== false) {
    $error = '[' . self::$httpSocket->response['status']['code'] . '] ' . $error;
    }
    $this->err($error);
    $this->_stop();
    }
    return $body;
    }
    
    

如果你现在浏览到 http://localhost/users/token,你将被要求登录。使用你在 入门 部分创建的用户账户登录,然后你将获得一个 API 令牌。

现在我们使用以下命令运行测试脚本。将 http://localhost 更改为匹配你的应用程序的 URL,并将令牌更改为你刚刚生成的 API 令牌:

  • 如果你使用的是 GNU Linux / Mac / Unix 系统:

    ../cake/console/cake consume http://localhost token
    
    
  • 如果你使用的是 Microsoft Windows:

    ..\cake\console\cake.bat consume http://localhost token
    
    

如果我们指定了正确的令牌,我们将得到与 构建 JSON REST 服务 脚本中显示的相同成功输出。

如果你再次在距离上次运行 2 分钟内运行脚本,你将得到一个 503(服务不可用)HTTP 状态错误,这表明我们过度使用了我们的 API 令牌。我们将不得不等待两分钟才能再次成功运行脚本,因为每次运行都会向 API 发送六次请求,而六次是两分钟内允许的最大请求次数,如 app/config/bootstrap.php 中配置的那样。

它是如何工作的...

我们首先向 users 表添加三个字段:

  • token:API 访问令牌,每个用户都是唯一的。这是用户将用来使用我们的 API 服务的东西。

  • token_used:API 使用计数器 (token_uses) 上次重置的时间。

  • token_uses:自 token_used 中指定的日期和时间以来的 API 使用次数。

我们然后在 UsersController 类中创建一个名为 token 的操作,允许用户获取新的 API 访问令牌。此操作将简单地通过散列一个 UUID全球唯一标识符)来创建一个新的令牌,并将其保存到 users 表记录中。

我们继续在 bootstrap.php 中设置应用程序配置,通过定义两个设置来定义 API 访问限制:

  • maximum:在给定时帧内允许的最大 API 请求次数。

  • time:用于检查 API 过度使用的时帧。允许使用 PHP 函数 strtotime() 的任何字符串。

我们将 time 设置为 2 分钟,将 maximum 设置为 6 次请求,这意味着我们将允许每个用户每两分钟最多进行六次 API 请求。

由于我们不再使用真实账户来验证我们的 API 用户,我们将ProfilesController中的_restLogin()方法更改为仅使用提供的username字段值。实际上,这个值是一个用户的 API 令牌。因此,password字段被忽略,这使得我们的测试客户端脚本可以简单地传递一个空值作为密码。

我们使用User模型的useToken()方法来检查令牌的有效性。如果该方法抛出Exception异常,则表示给定的令牌不存在,因此我们通过调用Security组件的blackhole()方法,以401状态(未授权)结束请求。如果useToken()方法返回false,则表示令牌被过度使用,因此我们发送回503(服务不可用)状态。如果我们得到一个有效的用户 ID,我们将此值转换为字符串,并将其传递给Auth组件的login()方法,如果指定的参数是字符串,则该方法将使用给定的 ID 登录用户。

如我们所见,整个令牌使用逻辑依赖于User::useToken()方法。该方法首先寻找带有给定令牌的用户记录。如果没有找到,它将抛出Exception异常。如果正在使用有效的令牌,它会检查该令牌是否已被使用。如果是,我们在$tokenTimeThreshold局部变量中设置自令牌使用首次更新以来的时间限制。如果我们处于这个时间范围内,并且令牌的使用次数超过了配置的设置,我们返回false

如果上述条件都不满足,则令牌使用有效,因此我们要么在$tokenTimeThreshold在当前时间范围内时增加使用次数,要么将其重置。

第八章。与 Shell 一起工作

在本章中,我们将介绍以下内容:

  • 构建 和运行一个 Shell

  • 解析命令行参数

  • 创建可重用 Shell 任务

  • 从 Shell 发送电子邮件

  • 使用机器人插件创建非交互式任务

简介

CakePHP 最强大且鲜为人知的功能之一是其 Shell 框架。它为应用程序提供了构建命令行工具所需的一切,这些工具可以用于执行密集型任务和其他类型的非交互式处理。

本章通过介绍构建基本 Shell 的过程来向读者介绍 CakePHP Shell,然后继续介绍更高级的功能,例如从 Shell 发送电子邮件和运行控制器操作。最后,它通过介绍机器人插件来结束,该插件提供了一套完整的解决方案,用于调度和运行任务。

构建 和运行一个 Shell

在这个食谱中,我们将学习如何构建和运行一个自定义 Shell,该 Shell 将要求输入用户名和密码,并将指定的账户添加到用户账户列表中。基于来自 第一章 的食谱 设置基本认证系统,这个 Shell 在创建测试账户时非常有帮助。

准备工作

为了完成这个食谱,我们需要一个认证系统。遵循来自 认证 章节的整个食谱 设置基本认证系统

如何做...

创建一个名为 user.php 的文件,并将其放置在你的 app/vendors/shells 文件夹中,内容如下:

<?php
App::import('Core', 'Security');
class UserShell extends Shell {
public $uses = array('User');
public function main() {
$user = $this->in('Enter the username (ENTER to abort):');
if (empty($user)) {
$this->_stop();
}
$defaultPassword = $this->_randomPassword();;
$password = $this->in('Enter the password (ENTER to use generated):', null, $defaultPassword);
$this->out();
$this->out('USER: '.$user);
$this->out('PASSWORD: '.$password);
$this->out();
if (strtoupper($this->in('Proceed?', array('Y', 'N'), 'N')) != 'Y') {
$this->_stop();
}
$user = array('User' => array(
'username' => $user,
'password' => Security::hash($password, null, true)
));
$this->User->create();
if ($this->User->save($user)) {
$this->out('User created.');
} else {
$this->error('Error while creating user.');
}
}
protected function _randomPassword($size=10) {
$chars = '@!#$_';
foreach(array('A'=>'Z', 'a'=>'z', '0'=>'9') as $start => $end) {
for ($i=ord($start), $limiti=ord($end); $i <= $limiti; $i++) {
$chars .= chr($i);
}
}
$totalChars = strlen($chars);
$password = '';
for($i=0; $i < $size; $i++) {
$password .= $chars[rand(0, $totalChars-1)];
}
return $password;
}
}
?>

我们现在可以运行我们的 Shell 了。打开一个终端窗口,访问你的应用程序所在的目录。在这个目录中,你应该有你的 app/cake/ 文件夹。例如,如果你的应用程序安装在 /var/www/myapp,那么 /var/www/myapp/app 应该是你的 app/ 文件夹,而 /var/www/myapp/cake 应该是你的 cake/ 文件夹。在你应用程序的主要目录(例如,在这个例子中是 /var/www/myapp)中,运行:

注意

要了解更多关于运行 Shell 时设置正确路径的信息,或者如何将 cake shell 脚本添加到你的 PATH 环境变量中,请参阅book.cakephp.org/view/1106/The-CakePHP-Console

如果你使用的是 GNU Linux / Mac / Unix 系统:

../cake/console/cake user

如果你使用的是 Microsoft Windows:

..\cake\console\cake.bat user

注意

如果你收到一个错误信息,例如错误:无法加载类 UserShell,这意味着 CakePHP 无法找到你的 app/ 文件夹,这可能是由于你的 app/ 文件夹名称不同。在这种情况下,你可以使用 app 参数指定文件夹,如下所示:$ cake/console/cake -app /var/www/myapp/app user

一旦 Shell 运行,它将要求我们输入所需的用户名和密码,并在创建账户之前等待最终确认,如下面的截图所示:

如何做...

现在,我们能够通过应用程序的登录页面登录时使用这个账户。

它是如何工作的...

我们首先导入了Security类,该类用于在保存用户记录之前对密码进行散列。然后我们创建了一个名为UserShell的类,它扩展了 CakePHP 的Shell类,这为我们提供了一套在构建 shell 时非常有用的方法和属性。其中之一是uses属性,它的工作方式与控制器的uses属性相同——通过定义一个列表,列出应该实例化并准备好在 shell 中的任何方法中使用的应用程序模型。

我们 shell 的入口点是main()方法。如果你有任何开发 C、C++或 Java 应用程序的经验,这并不会让你感到惊讶,因为main()也是它们的入口函数。如果你没有这样的经验,那么你需要知道的是,main()将在我们的 shell 通过命令行调用时自动由 CakePHP 执行。

我们的main()方法首先请求用户输入他们想要的用户名。为了请求用户输入,我们使用in()方法(通过Shell父类提供),它最多可以接受三个参数:

  • prompt:在请求用户输入之前显示给用户的消息。

  • options:一个可选的值集,用户在输入时应该被限制在这些值中。

  • default:一个可选的默认值,如果用户在提示符下点击Enter而没有输入,则使用此值。

如果用户没有指定用户名,我们将通过调用所有从Object, Shell派生的 CakePHP 类都有的_stop()方法来退出应用程序。

一旦我们有了用户名,我们需要请求一个密码。作为一个有用的替代方案,我们希望向用户提供一个自动生成的密码。为了生成这个密码,我们实现了一个名为_randomPassword()的方法。

此方法接受一个参数,即生成的密码的大小,并通过从定义的字符集中随机选择一个元素来构建它。这个集合是通过包括字母AZaz09之间的所有字符来构建的。为了生成更安全的密码,我们还包括了符号@ ! # $_作为有效字符。

当我们使用in()方法请求用户输入密码时,我们使用这个默认生成的密码作为它的第三个参数(default.)。在请求密码后,我们向用户展示用户名和密码的选择,并请求确认,利用我们在in()调用中的options参数。

如果用户确认操作,我们将继续创建用户记录,使用Security::hash()方法对输入的密码进行散列,该方法最多可以接受三个参数:

  • string:要散列的字符串。

  • method:用于哈希的方法,可以是以下之一:sha1sha256md5hash() PHP 函数支持的任何其他方法。默认情况下,取决于它们的可用性,使用以下 PHP 函数:sha1()(如果选择sha1作为方法,也会使用),mhash()(如果选择sha256作为方法,也会使用),hash(),最后是md5()

  • salt:如果为true,则在字符串前添加应用程序的盐(可在Configure设置Security.salt中找到)。如果指定了一个字符串,则将其作为应用程序的Security.salt设置的前缀添加到要哈希的密码中。如果为false,则不带前缀对给定的字符串进行哈希处理。

如果创建了记录,我们通知用户操作成功。否则,我们使用error()方法(通过Shell父类提供)通过标准错误流发送错误消息并退出应用程序。

使用 Auth 组件进行密码哈希处理

在这个菜谱中,我们通过指定与Auth组件中使用的相同精确参数调用了Security::hash()方法来哈希密码。如果我们没有这样做,相同的密码将会有不同的哈希值,这将使我们的 shell 变得无用,因为使用它创建的任何用户账户都无法登录。

这种方法的缺点是,如果Auth组件用于哈希密码的方法被更改,我们需要在我们的 shell 中反映这些更改。因此,我们可能想使用Auth组件来进行哈希处理。这个解决方案需要一些额外的努力,因为组件在 shell 中不是原生可用的。编辑你的app/vendors/shells/user.php文件,并移除对Security类的导入,然后在文件开始处添加以下导入语句:

App::import('Component', 'Auth');

我们现在需要实例化AuthComponent类。将以下代码添加到main()方法的开始部分:

$this->Auth = new AuthComponent();

最后更改用于创建User记录的数据定义,使其password字段使用Auth组件进行哈希处理:

$user = array('User' => array(
'username' => $user,
'password' => $this->Auth->password($password)
));

参见

  • 解析命令行参数

解析命令行参数

菜谱构建和运行 shell展示了如何创建一个基于用户提供的信息的记录添加 shell。这个菜谱增加了从 CSV 文件导入账户的支持,同时允许用户通过使用命令行参数来配置不同的设置。

准备工作

要完成这个菜谱,我们需要实现用户 shell。遵循整个菜谱构建和运行 shell

我们还需要一个样本 CSV 文件来导入记录。创建一个名为users.csv的文件,并将其放置在你选择的目录中(例如,在应用程序的app/tmp目录中),内容如下:

"john","John","Doe"
"jane","Jane","Doe"
"mark","Mark","Doe"
"mathew","Mathew","Doe"
"peter","Peter","Doe"
"roland","Roland","Doe"

如何操作...

  1. 编辑你的app/vendors/shells/user.php文件,并将main()方法的名字改为add()

  2. add()方法下面添加以下方法:

    public function help() {
    $this->out('USAGE: $ cake '.$this->shell.' <import <path/to/file> [-limit N | -size N | -verbose] | add>');
    $this->out('where:');
    $this->out();
    $this->out('-limit N: import up to N records');
    $this->out('-size N: size of generated password');
    $this->out('-verbose: Verbose output');
    }
    
    
  3. 现在在 _randomPassword() 方法之上添加以下方法:

    protected function _parseCSV($path) {
    $file = fopen($path, 'r');
    if (!is_resource($file)) {
    $this->error('Can\'t open '.$file);
    }
    $rows = array();
    while($row = fgetcsv($file)) {
    $rows[] = $row;
    }
    fclose($file);
    return $rows;
    }
    
    
  4. 最后,在 help() 方法之下添加以下内容:

    public function import() {
    $this->_checkArgs(1);
    $defaults = array(
    'limit' => null,
    'size' => 10,
    'verbose' => false
    );
    $options = array_merge(
    $defaults,
    array_intersect_key($this->params, $defaults)
    );
    $path = $this->args[0];
    if (!is_file($path) || !is_readable($path)) {
    $this->error('File '.$path.' cannot be read');
    }
    $users = array();
    foreach($this->_parseCSV($path) as $i => $row) {
    $users[$row[0]] = $this->_randomPassword($options['size']);
    if (!empty($options['limit']) && $i + 1 == $options['limit']) {
    break;
    }
    }
    if ($options['verbose']) {
    $this->out('Will create '.number_format(count($users)).' accounts');
    }
    foreach($users as $userName => $password) {
    if ($options['verbose']) {
    $this->out('Creating user '.$userName.'... ', false);
    }
    $user = array('User' => array(
    'username' => $userName,
    'password' => Security::hash($password, null, true)
    ));
    $this->User->create();
    $saved = ($this->User->save($user) !== false);
    if (!$saved) {
    unset($users[$userName]);
    }
    if ($options['verbose']) {
    $this->out($saved ? 'SUCCESS' : 'FAIL');
    }
    }
    $this->out('Created accounts:');
    foreach($users as $userName => $password) {
    $this->out($userName.' : '.$password);
    }
    }
    
    

如果我们不带参数运行 shell,CakePHP 会说没有已知的命令,并建议我们通过将 help 作为 shell 的参数来获取帮助。这样做将显示我们的帮助信息,如下面的截图所示:

如何操作...

如果我们使用 add 参数运行我们的 shell,我们将看到与在 构建和运行 shell 脚本中实现的功能完全相同。

使用 import 参数和 verbose 参数执行 shell,并使用如下命令指定我们的 CSV 文件的路径:

$ cake/console/cake user import app/tmp/users.csv -verbose

将导入 CSV 文件中列出的用户,生成类似于以下截图所示的输出:

如何操作...

工作原理...

我们首先将入口方法的名字改为 add()。这样做意味着我们不再有入口方法,那么当我们的 shell 被调用时,CakePHP 如何找到要运行的内容呢?通过使用命令。

如果在 shell 中没有定义入口方法,CakePHP 将假设在执行 shell 时使用的第一个参数是一个命令。命令不过是一个不以下划线符号开头的公共方法。因此,当 shell 使用 add 参数调用时,会执行名为 add() 的方法。如果没有指定参数,CakePHP 会抱怨因为没有可运行的命令,并建议用户使用 help 参数,这实际上是通过调用我们的 shell 中的 help() 方法(因为 help 是一个常规命令)来实现的。

我们使用 help() 方法来显示我们 shell 的使用说明,列出可用的命令(添加和 import),以及每个命令的参数。虽然 add 命令没有可用的参数,但我们为我们的 import 命令支持以下参数:

设置 目的
limit 从 CSV 文件中处理的最大记录数。如果省略,将处理所有记录。
size 生成的密码的最大长度。默认为 10
verbose 如果指定,shell 将在创建用户记录时输出信息。

_parseCSV() 方法是我们解析 CSV 文件的帮助方法,返回文件中找到的行数组,其中每一行本身也是一个值数组。此方法使用 PHP 的 fgetcsv() 函数从文件句柄中解析记录,该文件句柄是通过使用 PHP 的 fopen() 函数获得的,并在解析完成后使用 fclose() 关闭。

我们继续实现 import() 方法,即我们的 import 命令的主体。该方法使用 _checkArgs() 方法(通过 Shell 类提供)来确保命令至少接收了指定的参数数量,在我们的例子中是 1。如果该方法发现用户没有指定最小数量的参数,它将抛出一个错误消息并终止执行。这是我们确保至少提供了 CSV 文件路径的一种方式。

如果参数数量正确,我们继续处理可选参数。为此,我们使用 params 属性。即使没有提供参数,该属性对所有壳子都可用,并包括以下值:

设置 目的
app app/ 目录的名称。
root 我们应用程序根目录的完整路径,其中包含 app/cake/ 目录。
webroot webroot/ 目录的名称,它位于 app/ 目录内部。
working app/ 目录的完整路径。

然而,我们只对用户通过命令行提供的参数感兴趣。因此,我们定义了具有默认值的有效参数集,并将 params 属性中可用的那些参数的值合并在一起。我们将这些合并后的值存储在一个名为 options 的数组中。

使用 is_file()is_readable() PHP 函数,我们确保我们得到了一个有效的文件。如果没有,我们使用 error() 方法打印出错误消息并终止应用程序。

我们接着使用 _importCSV() 函数来获取解析后的行列表,并为这些行中的每一行分配一个随机密码,使用 size 选项。如果提供了 limit 选项,一旦达到该值,我们就停止生成密码。在这个循环结束时,我们将有一个名为 users 的数组,其中索引是用户名,值是给定用户的密码。

对于 users 数组中的每个值,我们创建与 add 命令中类似的用户记录,如果设置了 verbose 选项,则输出每个创建的状态。如果在创建特定记录时遇到错误,我们将有问题的用户从 users 数组中删除。

一旦创建过程完成,我们输出成功创建的用户名列表,以及它们生成的密码。

参见

  • 在第五章Parsing CSV files with a datasource数据源*

  • 创建可重用的壳子任务

创建可重用的壳子任务

正如我们有组件可以在控制器之间共享功能一样,我们也有模型的行为和视图的帮助器。那么壳子呢?CakePHP 提供了任务的概念,这些是扩展自 Shell 类的类,但可以从其他壳子中重用。

在这个配方中,我们将学习如何构建一个处理 shell 的参数和参数处理的任务,可以自动生成帮助信息,并检查必填参数和可选参数的定义。我们将以最通用的方式实现这个任务,以便我们可以将其用于我们可能决定构建的任何未来的 shell。

准备工作

为了完成这个配方,我们需要一个接受参数并且有不同命令可用的 shell。遵循整个配方中的解析命令行参数

如何做到这一点...

  1. 编辑你的app/vendors/shells/user.php文件,并在uses属性的声明下方添加以下内容:

    public $tasks = array('Help');
    public static $commands = array(
    'add',
    'import' => array(
    'help' => 'Import user records from a CSV file',
    'args' => array(
    'path' => array(
    'help' => 'Path to CSV file',
    'mandatory' => true
    )
    ),
    'params' => array(
    'limit' => array(
    'type' => 'int',
    'help' => 'import up to N records'
    ),
    'size' => array(
    'value' => 10,
    'type' => 'int',
    'help' => 'size of generated password'
    ),
    'verbose' => array(
    'value' => false,
    'type' => 'bool',
    'help' => 'Verbose output'
    )
    )
    )
    );
    
    
  2. 在仍然编辑 shell 的同时,删除help()方法,并从import()方法的开始处删除以下几行:

    $this->_checkArgs(1);
    $defaults = array(
    'limit' => null,
    'size' => 10,
    'verbose' => false
    );
    $options = array_merge(
    $defaults,
    array_intersect_key($this->params, $defaults)
    );
    $path = $this->args[0];
    
    
  3. import()方法的开始处添加以下几行:

    $options = $this->Help->parameters;
    extract($this->Help->arguments);
    
    
  4. 创建一个名为help.php的文件,并将其放置在app/vendors/shells/tasks中,内容如下:

    <?php
    class HelpTask extends Shell {
    public $parameters = array();
    public $arguments = array();
    protected $commands = array();
    public function initialize() {
    $shellClass = Inflector::camelize($this->shell).'Shell';
    $vars = get_class_vars($shellClass);
    if (!empty($vars['commands'])) {
    foreach($vars['commands'] as $command => $settings) {
    if (is_numeric($command)) {
    $command = $settings;
    $settings = array();
    }
    if (!empty($settings['args'])) {
    $args = array();
    foreach($settings['args'] as $argName => $arg) {
    if (is_numeric($argName)) {
    $argName = $arg;
    $arg = array();
    }
    $args[$argName] = array_merge(array(
    'help' => null,
    'mandatory' => false
    ), $arg);
    }
    $settings['args'] = $args;
    }
    if (!empty($settings['params'])) {
    $params = array();
    foreach($settings['params'] as $paramName => $param) {
    if (is_numeric($paramName)) {
    $paramName = $param;
    $param = array();
    }
    $params[$paramName] = array_merge(array(
    'help' => null,
    'type' => 'string'
    ), $param);
    }
    }
    $this->commands[$command] = array_merge(array(
    'help' => null,
    'args' => array(),
    'params' => array()
    ), $settings);
    }
    }
    if (empty($this->command) && !in_array('main', get_class_methods($shellClass))) {
    $this->_welcome();
    $this->_help();
    } elseif (!empty($this->command) && array_key_exists($this->command, $this->commands)) {
    $command = $this->commands[$this->command];
    $number = count(array_filter(Set::extract(array_values($command['args']), '/mandatory')));
    if ($number > 0 && (count($this->args) - 1) < $number) {
    $this->err('WRONG number of parameters');
    $this->out();
    $this->_help($this->command);
    } elseif ($number > 0) {
    $i = 0;
    foreach($command['args'] as $argName => $arg) {
    if ($number >= $i && isset($this->args[$i+1])) {
    $this->arguments[$argName] = $this->args[$i+1];
    }
    $i++;
    }
    }
    $values = array_intersect_key($this->params, $command['params']);
    foreach($command['params'] as $settingName => $setting) {
    if (!array_key_exists($settingName, $values)) {
    $this->parameters[$settingName] = array_key_exists('value', $setting) ?
    $setting['value'] :
    null;
    } elseif ($setting['type'] == 'int' && !is_numeric($values[$settingName])) {
    $this->err('ERROR: wrong value for '.$settingName);
    $this->out();
    $this->_help($this->command);
    } else {
    if ($setting['type'] == 'bool') {
    $values[$settingName] = !empty($values[$settingName]);
    }
    $this->parameters[$settingName] = $values[$settingName];
    }
    }
    }
    }
    }
    
    
  5. 向创建的HelpTask类添加以下方法:

    public function execute() {
    $this->_help(!empty($this->args) ? $this->args[0] : null);
    }
    protected function _help($command = null) {
    $usage = 'cake '.$this->shell;
    if (empty($this->commands)) {
    $this->out($usage);
    return;
    }
    $lines = array();
    $usages = array();
    if (empty($command) || !array_key_exists($command, $this->commands)) {
    foreach(array_keys($this->commands) as $currentCommand) {
    $usages[] = $this->_usageCommand($currentCommand);
    if (!empty($lines)) {
    $lines[] = null;
    }
    $lines = array_merge($lines, $this->_helpCommand($currentCommand));
    }
    } else {
    $usages = (array) $this->_usageCommand($command);
    $lines = $this->_helpCommand($command);
    }
    if (!empty($usages)) {
    $usage .= ' ';
    if (empty($command)) {
    $usage .= '<';
    }
    $usage .= implode(' | ', $usages);
    if (empty($command)) {
    $usage .= '>';
    }
    }
    $this->out($usage);
    if (!empty($lines)) {
    $this->out();
    foreach($lines as $line) {
    $this->out($line);
    }
    }
    $this->_stop();
    }
    
    
  6. 在仍然编辑HelpTask类的同时,向该类添加以下辅助方法:

    protected function _usageCommand($command) {
    $usage = $command;
    if (!empty($this->commands[$command]['args'])) {
    foreach($this->commands[$command]['args'] as $argName => $arg) {
    $usage .= ' ' . ($arg['mandatory'] ? '<' : '[');
    $usage .= $argName;
    $usage .= ($arg['mandatory'] ? '>' : ']');
    }
    }
    if (!empty($this->commands[$command]['params'])) {
    $usages = array();
    foreach(array_keys($this->commands[$command]['params']) as $setting) {
    $usages[] = $this->_helpSetting($command, $setting);
    }
    $usage .= ' ['.implode(' | ', $usages).']';
    }
    return $usage;
    }
    protected function _helpCommand($command) {
    if (
    empty($this->commands[$command]['args']) &&
    empty($this->commands[$command]['params'])
    ) {
    return array();
    }
    $lines = array('Options for '.$command.':');
    foreach($this->commands[$command]['args'] as $argName => $arg) {
    $lines[] = "\t".$argName . (!empty($arg['help']) ? "\t\t".$arg['help'] : '');
    }
    foreach(array_keys($this->commands[$command]['params']) as $setting) {
    $lines[] = "\t".$this->_helpSetting($command, $setting, true);
    }
    return $lines;
    }
    protected function _helpSetting($command, $settingName, $useHelp = false) {
    $types = array('int' => 'N', 'string' => 'S', 'bool' => null);
    $setting = $this->commands[$command]['params'][$settingName];
    $type = array_key_exists($setting['type'], $types) ? $types[$setting['type']] : null;
    $help = '-'.$settingName . (!empty($type) ? ' '.$type : '');
    if ($useHelp && !empty($setting['help'])) {
    $help .= "\t\t".$setting['help'];
    if (array_key_exists('value', $setting) && !is_null($setting['value'])) {
    $help .= '. DEFAULTS TO: ';
    if (empty($type)) {
    $help .= $setting['value'] ? 'Enabled' : 'Disabled';
    } else {
    $help .= $setting['value'];
    }
    }
    }
    return $help;
    }
    
    

如果你现在运行不带任何参数的 shell,使用如下命令:

$ cake/console/cake user

我们将得到以下截图所示的详细帮助信息:

如何做到这一点...

我们还可以为特定命令获取详细帮助。使用如下命令运行 shell:

$ cake/console/cake user help import

将显示import命令的帮助信息,如下截图所示:

如何做到这一点...

使用与配方解析命令行参数中使用的相同参数运行 shell 以导入 CSV 文件应该按预期工作。

它是如何工作的...

当一个 shell 在其声明中包含tasks属性时,它被认为是使用了指定的任务。任务存储在app/vendors/shells/tasks文件夹中,并且可以在 shell 中以实例的形式访问。在我们的情况下,我们添加一个名为Help的任务,它应该在名为HelpTask的类中实现,并放置在tasks文件夹中的名为help.php的文件中,并且我们在 shell 中通过$this->Help来引用它。

在继续之前,必须指出关于这个特定任务的命名问题。因为我们希望我们的任务能够自动为我们的 shell 生成帮助信息,所以我们必须以某种方式捕获对help()命令的调用。这只有在首先理解 shell 分发过程如何工作的情况下才能实现。让我们假设以下调用:

$ cake/console/cake user import

实现于文件cake/console/cake.php中的 shell 分发器将经过以下步骤:

  1. 实例化 shell 类UserShell

  2. 调用它的initialize()方法。

  3. 加载 shell 中定义在tasks属性下的所有任务。

  4. 对于这些任务中的每一个,调用它们的initialize()方法,并加载它们可能使用的任何任务。

如果给定的命令(在这种情况下为 import)是包含的任务之一,则调用任务 startup() 方法,然后调用其 execute() 方法。

如果给定的命令不是任务名称,则调用 shell 的 startup() 方法,如果存在,则执行命令的方法,或者如果命令未实现,则执行入口方法 main()

这意味着如果我们有一个名为 Help 的任务包含在我们的 shell 中,并且用户使用以下命令启动 shell:

$ cake/console/cake user help

然后,shell 分发器将调用 HelpTask 类的 execute() 方法,因为命令 help 实际上是 shell 任务之一的名字。了解这一点后,我们可以移除 User shell 的 help() 实现,让 Help 任务处理帮助信息的显示。

此外,我们的 Help 任务需要足够通用,以便不与特定 shell 绑定。因此,我们需要一种方法来告知它我们的可用命令、预期参数和可选参数。这就是 commands 属性的作用:一个命令数组,其中键是命令名称,值是以下设置中的任何一个:

设置 目的
帮助 描述命令目的的帮助信息。默认没有信息。
args 命令接受的强制性参数和可选参数列表。默认没有参数。
params 命令接受的可选参数列表。默认没有参数。

注意,然而,add 命令的定义方式不同:它不是在键中定义,而是简单地作为添加到 commands 数组中的命令名称。这意味着该命令没有帮助信息,没有参数,也没有参数。

args 命令设置是一个通过参数名称索引的参数数组。每个参数可以定义以下设置中的任何一个:

设置 目的
帮助 描述参数的帮助信息。默认没有信息。
强制性 如果 true,则此参数必须存在。如果 false,则可以省略此参数。默认为 false

类似地,params 命令设置也是一个数组,通过参数名称索引,其中每个参数可以定义以下设置中的任何一个:

设置 目的
帮助 描述参数的帮助信息。默认没有信息。
type 此参数持有的数据类型。可以是 int, boolstring。任何其他类型都被解释为 string。默认为 string
value 如果未指定参数,则使用默认值。默认没有默认值。

UserShell类中使用commands属性,我们定义了import命令可用的参数和参数集,然后修改了import()方法,使其选项从Help任务的parameters属性中获取。我们还使用extract()PHP 函数将Help任务的arguments属性中定义的任何参数转换为局部变量。这样,path参数将以变量$path的形式对方法可用。

UserShell类中所需的修改都已经完成。注意我们不仅移除了help()方法的实现,还去除了import()方法中对参数的处理以及正确参数数量的检查。现在这一切都由Help任务自动完成,基于我们在commands属性中定义的内容。

这意味着我们的Help任务确实是我们的 shell 的瑞士军刀,其大部分工作都是在其initialize()方法中完成的。该方法首先利用 PHP 方法get_class_vars()来获取 shell 中定义的commands属性,因为我们的任务没有方法获取UserShell类的实例。然后继续遍历命令列表,并规范化所有定义的参数和参数,将结果数组分配给HelpTask类的commands属性。

一旦我们准备好所有要检查的命令,我们检查用户是否确实通过command属性选择了要执行的命令,该属性对所有从Shell扩展的类都可用,并设置为当前命令。如果用户没有选择,并且 shell 中没有实现main()方法,我们使用_help()方法来显示帮助信息。

如果用户确实指定了一个在可用命令列表中的命令,我们确保指定的参数与最小数量的必需参数匹配(如果有),如果检查失败,则通过适当的错误消息终止执行。如果参数数量正确,我们将每个给定参数的值存储在任务的arguments属性中。

一旦处理完参数,我们就继续处理参数。遍历指定的参数,我们将提供的值与数据类型进行比较(如果有),如果值类型不正确,则通过适当的错误消息终止 shell。如果没有提供值,则使用默认值(如果有)。参数和值的数组结果存储在任务的parameters属性中。

execute()方法是在调用Help任务时被调用的方法,也就是在调用 shell 时使用help命令时。因此,这个方法将简单地通过调用_help()方法来显示帮助信息,可以可选地传递第一个参数,这样可以为用户提供给定命令的帮助信息。

_help()方法构建整个 shell 或特定命令的帮助信息。它使用存储在commands属性中的命令信息,并调用_usageCommand()辅助方法来获取给定命令的使用信息,以及调用_helpCommand()方法来获取所有可用参数和命令中所有参数的帮助信息。

从 shell 发送电子邮件

电子邮件发送不是需要我们的 Web 应用程序访客进行任何交互的任务,因此让他们等待邮件的送达是没有意义的,这正是如果我们从控制器动作发送电子邮件时会发生的情况。

将电子邮件发送推迟到 shell 中,从性能和管理员的角度来看都非常有意义,因为我们还可以添加重新发送失败电子邮件的能力。

此配方使用 CakePHP 提供的Email组件来发送虚构的通讯稿,并添加了通过 shell 参数测试发送过程的能力。

准备工作

为了完成这个配方,我们需要一些数据来工作。使用以下 SQL 语句创建一个subscribers表:

CREATE TABLE `subscribers`(
`id` INT UNSIGNED AUTO_INCREMENT NOT NULL,
`name` VARCHAR(255) NOT NULL,
`email` VARCHAR(255) NOT NULL,
PRIMARY KEY(`id`)
);

使用以下语句创建一个newsletters表:

CREATE TABLE `newsletters`(
`id` INT UNSIGNED AUTO_INCREMENT NOT NULL,
`title` VARCHAR(255) NOT NULL,
`body` TEXT NOT NULL,
`sent` TINYINT(1) UNSIGNED NOT NULL default 0,
PRIMARY KEY(`id`)
);

使用以下语句创建一个newsletters_subscribers表:

CREATE TABLE `newsletters_subscribers`(
`id` INT UNSIGNED AUTO_INCREMENT NOT NULL,
`newsletter_id` INT UNSIGNED NOT NULL,
`subscriber_id` INT UNSIGNED NOT NULL,
`sent` TINYINT(1) UNSIGNED NOT NULL default 0,
PRIMARY KEY(`id`)
);

现在用以下语句向这些表添加一些示例数据:

INSERT INTO `subscribers`(`name`, `email`) VALUES
('John Doe', 'john.doe@email.com'),
('Jane Doe', 'jane.doe@email.com');
INSERT INTO `newsletters`(`title`, `body`) VALUES
('My first newsletter', 'This is the body for <strong>my first newsletter</strong>');

创建一个名为newsletter.php的文件,并将其放置在您的app/models文件夹中,内容如下:

<?php
class Newsletter extends AppModel {
public $hasMany = array('NewslettersSubscriber');
}
?>

如何操作...

创建一个名为email.php的文件,并将其放置在您的app/vendors/shells中,内容如下:

<?php
App::import('Component', 'Email');
class EmailShell extends Shell {
public $uses = array('Newsletter', 'Subscriber');
public function startup() {
$this->Email = new EmailComponent();
$this->Email->delivery = 'smtp';
$this->Email->smtpOptions = array(
'host' => 'smtp.email.com',
'username' => 'smtpUser',
'password' => 'smtpPassword'
);
}
public function main() {
$email = !empty($this->params['to']) ? $this->params['to'] : array();
$newsletter = $this->Newsletter->find('first', array(
'conditions' => array('sent' => false),
'recursive' => -1
));
if (empty($newsletter)) {
$this->out('All newsletters have been sent');
$this->_stop();
}
$this->out('Sending newsletter "'.$newsletter['Newsletter']['title'].'"');
$subscribers = $this->Subscriber->find('all');
foreach($subscribers as $subscriber) {
$this->out('Sending to '.$subscriber['Subscriber']['email'].'... ', false);
$currentEmail = !empty($email) ? $email : $subscriber['Subscriber']['email'];
if (!empty($email)) {
$this->Email->headers['Destination'] = $subscriber['Subscriber']['email'];
}
$this->Email->sendAs = 'html';
$this->Email->subject = $newsletter['Newsletter']['title'];
$this->Email->from = 'My Application <info@email.com>';
$this->Email->to = $subscriber['Subscriber']['name'] . ' <'.$currentEmail.'>';
$sent = $this->Email->send($newsletter['Newsletter']['body']));
if ($sent) {
$this->out('DONE');
} else {
$error = !empty($this->Email->smtpError) ? $this->Email->smtpError : '';
$this->out('ERROR' . (!empty($error) ? ': '.$error : ''));
}
$this->Newsletter->NewslettersSubscriber->create(array(
'newsletter_id' => $newsletter['Newsletter']['id'],
'subscriber_id' => $subscriber['Subscriber']['id'],
'sent' => $sent
));
$this->Newsletter->NewslettersSubscriber->save();
$this->Email->reset();
}
$this->Newsletter->id = $newsletter['Newsletter']['id'];
$this->Newsletter->saveField('sent', true);
}
}
?>

确保更改startup()函数中的以下行以匹配您的设置:

$this->Email->delivery = 'smtp';
$this->Email->smtpOptions = array(
'host' => 'smtp.email.com',
'username' => 'smtpUser',
'password' => 'smtpPassword'
);

如果您想使用 PHP 的mail()函数而不是 SMTP,将Email组件的delivery属性更改为mail。一旦配置完成,您可以使用以下命令运行 shell,以强制所有电子邮件发送到您的特定地址(在这种情况下,my@email.com):

$ cake/console/cake email -to my@email.com

由于所有电子邮件都通过使用 shell 参数强制发送到my@email.com,我们需要一种方法来告诉每个电子邮件是否会发送到真实的电子邮件地址。使用您的电子邮件程序查看电子邮件的标题,您将注意到以下标题行:

To: John Doe <my@email.com>
From: My Application <info@email.com>
Subject: My first newsletter
X-Mailer: CakePHP Email Component
X-Destination: john.doe@email.com

从这些标题中我们可以看出,X-Destination标题被设置为电子邮件最初打算发送的地址。

它是如何工作的...

EmailShell首先通过实现startup()方法开始,该方法在执行任何 shell 命令或其入口方法之前被调用。在这个方法中,我们创建了一个Email组件的实例。一旦我们有了实例,我们就通过deliverysmtpOptions属性来配置其交付设置。

入口方法main()检查是否提供了to参数。如果是,这将是要发送所有电子邮件的电子邮件地址,这是一种基本的测试发送过程的方法。然后它继续获取尚未发送的第一份通讯稿和应该接收通讯稿的订阅者列表。

对于每个订阅者,我们设置 Email 组件的适当属性:

属性 目的
sendAs 要发送的电子邮件类型。可以是文本、htmlboth。我们将其设置为 html 以指定我们正在发送纯 HTML 电子邮件。
subject 电子邮件的主题。
from 发送电子邮件的地址。
to 目标地址。如果提供了参数,这是要发送的电子邮件 to,否则我们使用订阅者的电子邮件。

最后,我们通过组件的 send() 方法发送实际电子邮件,通知用户操作的结果,并在 for 操作的下一个循环之前使用组件的 reset() 方法重置电子邮件内容。我们通过标记新闻通讯已发送来结束 shell。

参见

  • 在 第十一章 实用类和工具 中发送电子邮件

使用机器人插件的非交互式任务

随着我们的应用程序在规模和复杂性上的增长,我们将发现自己需要创建和自动化某些任务,将非交互式任务的执行推迟到稍后。虽然我们可以创建 shell 来执行这些操作,但我们的某些需求可能可以通过机器人插件得到满足。

注意

虽然这个配方展示了纯 CakePHP 方法,但还有更多复杂和可扩展的替代方案。最常用的工具之一是 Gearman,可在 gearman.org/ 获取。

机器人插件允许我们安排任务以供稍后执行,并由 shell 运行这些任务。这些任务本身实际上是 CakePHP 控制器操作,由 shell 在指定的时间运行。

这个配方展示了如何使用机器人插件在用户注册我们的新闻通讯后发送电子邮件,以及如何让机器人插件中的 shell 定期检查待处理任务并在它们可用时运行它们。

准备工作

为了完成这个配方,我们需要一些数据来工作。遵循前一个配方中的 准备工作 部分。

我们需要下载机器人插件。访问 github.com/mariano/robot/downloads 并下载最新版本。将下载的文件解压缩到您的 app/plugins 文件夹中。现在您应该在 app/plugins 内有一个名为 robot 的目录。

运行 app/plugins/robot/config/sql/robot.sql 文件中找到的 SQL 语句以创建机器人插件所需的表。

如何操作...

  1. 创建一个名为 subscribers_controller.php 的文件并将其放置在您的 app/controllers 文件夹中,内容如下:

    <?php
    class SubscribersController extends AppController {
    public function add() {
    if (!empty($this->data)) {
    $this->Subscriber->create();
    if ($this->Subscriber->save($this->data)) {
    $this->Session->setFlash('You have been subscribed!');
    $this->redirect('/');
    } else {
    $this->Session->setFlash('Please correct the errors');
    }
    }
    }
    public function welcome() {
    }
    }
    ?>
    
    
  2. 在您的 app/views 文件夹中创建一个名为 subscribers 的文件夹。创建一个名为 add.ctp 的文件并将其放置在 app/views/subscribers 文件夹中,内容如下:

    <?php
    echo $this->Form->create();
    echo $this->Form->inputs(array(
    'legend' => 'Subscribe',
    'name',
    'email'
    ));
    echo $this->Form->end('Submit');
    ?>
    
    
  3. 创建一个名为 welcome.ctp 的文件并将其放置在 app/views/subscribers 文件夹中,内容如下:

    <h1>Welcome to my site!</h1>
    
    
  4. 将以下属性添加到SubscribersController类的开头(将Email组件的投递设置更改为满足你的需求):

    public $components = array(
    'Email' => array(
    'delivery' => 'smtp',
    'smtpOptions' => array(
    'host' => 'smtp.email.com',
    'username' => 'smtpUser',
    'password' => 'smtpPassword'
    )
    )
    );
    
    
  5. 编辑SubscribersController类的add()方法并做出以下更改:

    public function add() {
    if (!empty($this->data)) {
    $this->Subscriber->create();
    if ($this->Subscriber->save($this->data)) {
    ClassRegistry::init('Robot.RobotTask')->schedule(
    array('action'=>'welcome'),
    array(
    'name' => $this->data['Subscriber']['email'],
    'email' => $this->data['Subscriber']['email']
    )
    );
    $this->Session->setFlash('You have been subscribed!');
    $this->redirect('/');
    } else {
    $this->Session->setFlash('Please correct the errors');
    }
    }
    }
    
    
  6. 在编辑SubscribersController类时,将welcome()方法替换为以下内容:

    public function welcome() {
    if (isset($this->params['robot'])) {
    $subscriber = $this->params['robot'];
    $this->Email->sendAs = 'html';
    $this->Email->subject = 'Welcome to my site!';
    $this->Email->from = 'My Application <info@email.com>';
    $this->Email->to = $subscriber['name'] . ' <'.$subscriber['email'].'>';
    return ($this->Email->send('Hi, and <strong>welcome</strong> to my site!') !== false);
    }
    }
    
    

现在,你可以浏览到http://localhost/subscribers/add并输入你的姓名和电子邮件地址。现在使用以下命令运行机器人外壳:

如果你使用的是 GNU Linux / Mac / Unix 系统:

../cake/console/cake robot.robot run

如果你使用的是 Microsoft Windows:

..\cake\console\cake.bat robot.robot run

你应该得到一个类似于以下截图的输出:

如何操作...

机器人正在通知我们,任务已成功执行了 CakePHP URL /subscribers/welcome,之后我们应该收到欢迎电子邮件。

它是如何工作的...

我们从一个基本的控制器开始,该控制器处理新的订阅并将它们保存,在记录创建后重定向到欢迎屏幕。然后我们在控制器中添加了Email组件,因为它将被用于发送电子邮件。

我们继续修改add()方法以创建计划任务。我们使用位于 Robot 插件中的RobotTask模型的schedule()方法来安排任务。此方法最多接受三个参数:

参数 目的
action CakePHP 操作的 URL,可以是字符串或数组。
parameters 发送到action中指定的控制器操作的可选参数。默认为无参数。
scheduled 操作应执行的时间。这可以是特定的时间戳(自 Unix 纪元(1970 年 1 月 1 日 00:00:00 GMT)以来的秒数),或任何可以由 PHP 函数strtotime()使用的字符串。默认为null,表示任务应尽快执行。

在我们的add()方法中,我们将action参数设置为当前控制器的welcome操作,并发送两个参数:nameemail。这些参数可以通过$this->params['robot']数组在调用操作中使用。

事实上,每当通过机器人外壳调用控制器操作时,$this->params['robot']都将可用。如果在安排任务时未指定参数,则此数组将为空,因此在welcome()方法中使用isset()而不是!empty()进行检查。

当通过机器人外壳调用时,welcome()方法使用给定的参数构建并发送电子邮件。它返回一个布尔值以指示执行任务的成败。如果没有返回值,则假定任务已成功执行。

为了测试机器人插件,我们在注册为订阅者后结束了配方,然后运行了机器人。自然地,应用程序不应该需要我们手动运行机器人 shell,以便发送电子邮件。我们需要将 shell 添加到我们的自动化任务列表中,这在大多数操作系统中通常被称为 CRON 任务。

假设你的应用程序位于/var/www/myapp,并且你的 PHP 二进制文件的路径是/usr/bin/php,以下将是应包含在操作系统中的自动化任务中的命令:

/usr/bin/php -q /var/www/myapp/cake/console/cake.php -app /var/www/myapp/app robot.robot run -silent

注意到silent选项。这告诉机器人插件除非发现错误,否则不输出任何消息。当我们将此命令添加到我们的自动化任务列表中时,这一点尤为重要,因为它可能被配置为发送任何执行命令的输出电子邮件。

当我们将此命令添加到我们的自动化任务列表中时,我们必须决定我们希望机器人多久检查一次任务。如果我们对即时结果感兴趣,我们应该将机器人设置为每分钟运行一次。然而,如果在给定分钟的秒数为 0 时机器人找不到任务,会发生什么?我们将有 59 秒的空闲时间。

幸运的是,插件提供了一个有趣的解决方案来解决这个问题。使用daemon参数,我们告诉机器人插件即使在没有可用任务的情况下也要等待任务。如果我们尝试使用以下命令手动运行它并使用此选项:

$ cake/console/cake robot.robot run -daemon

我们会注意到 shell 会抱怨说没有指定限制。这是因为机器人不应该被设置为无限期地等待任务,因为由调用动作可能引发的任何 PHP 致命错误都可能使机器人失效。

相反,我们可以使用time参数来限制机器人等待任务的最大秒数。如果我们想每分钟运行机器人一次,这个限制应该设置为 59 秒:

$ cake/console/cake robot.robot run -daemon -time 59

这意味着机器人将等待多达 59 秒的任务,之后将触发下一次机器人运行。

第九章。国际化应用程序

在本章中,我们将涵盖:

  • 国际化控制器和视图文本

  • 国际化模型验证消息

  • 翻译包含动态内容的字符串

  • 提取和翻译文本

  • 使用“翻译行为”翻译数据库记录

  • 设置和记住语言

简介

本章包含一系列食谱,允许读者国际化他们 CakePHP 应用程序的所有方面,包括静态内容,如视图中的内容,以及动态内容,如数据库记录。

前两个食谱展示了如何允许任何 CakePHP 视图或模型验证消息中的文本准备好翻译。第三个食谱展示了如何翻译更复杂的表达式。第四个食谱展示了如何运行 CakePHP 内置工具提取所有需要翻译的静态内容,然后将该内容翻译成不同的语言。第五个食谱展示了如何翻译数据库记录。最后,最后一个食谱展示了如何允许用户更改当前应用程序的语言。

国际化控制器和视图文本

在这个食谱中,我们将学习如何国际化位于我们应用程序视图中的文本,并使该内容准备好翻译。

准备工作

为了完成这个食谱,我们需要一些数据来操作。使用以下 SQL 语句创建一个名为 articles 的表:

CREATE TABLE `articles`(
`id` INT UNSIGNED AUTO_INCREMENT NOT NULL,
`title` VARCHAR(255) NOT NULL,
`body` TEXT NOT NULL,
`created` DATETIME NOT NULL,
`modified` DATETIME NOT NULL,
PRIMARY KEY(`id`)
);

现在用以下语句向此表添加一些示例数据:

INSERT INTO `articles`(`title`, `body`, `created`, `modified`) VALUES
('First Article', 'Body for first article', NOW(), NOW()),
('Second Article', 'Body for second article', NOW(), NOW()),
('Third Article', 'Body for third article', NOW(), NOW());

在您的 app/controllers 文件夹中创建一个名为 articles_controller.php 的控制器文件,内容如下:

<?php
class ArticlesController extends AppController {
public function index() {
$this->paginate['limit'] = 2;
$articles = $this->paginate();
$this->set(compact('articles'));
}
public function add() {
if (!empty($this->data)) {
$this->Article->create();
if ($this->Article->save($this->data)) {
$this->Session->setFlash('Article saved');
$this->redirect(array('action'=>'index'));
} else {
$this->Session->setFlash('Please correct the errors');
}
}
}
public function view($id) {
$article = $this->Article->find('first', array(
'conditions' => array('Article.id' => $id)
));
if (empty($article)) {
$this->cakeError('error404');
}
$this->set(compact('article'));
}
}
?>

在您的 app/models 文件夹中创建一个名为 article.php 的文件,内容如下:

<?php
class Article extends AppModel {
public $validate = array(
'title' => 'notEmpty',
'body' => 'notEmpty'
);
}
?>

在您的 app/views 文件夹中创建一个名为 articles 的文件夹,并在该文件夹内创建一个名为 index.ctp 的文件,内容如下:

<h1>Articles</h1>
<p>
<?php echo $this->Paginator->counter(); ?>
&nbsp;-&nbsp;
<?php echo $this->Paginator->prev(); ?>
&nbsp;
<?php echo $this->Paginator->numbers(); ?>
&nbsp;
<?php echo $this->Paginator->next(); ?>
</p>
<p>
<?php echo count($articles) . ' articles: '; ?>
</p>
<ul>
<?php foreach($articles as $article) { ?>
<li><?php echo $this->Html->link(
$article['Article']['title'],
array('action'=>'view', $article['Article']['id'])
); ?></li>
<?php } ?>
</ul>
<p><?php echo $this->Html->link('Create article', array('action'=>'add')); ?></p>

在您的 app/views/articles 文件夹中创建一个名为 add.ctp 的文件,内容如下:

<?php
echo $this->Form->create();
echo $this->Form->inputs(array(
'title',
'body'
));
echo $this->Form->end('Save');
?>

在您的 app/views/articles 文件夹中创建一个名为 view.ctp 的文件,内容如下:

<h1><?php echo $article['Article']['title']; ?></h1>
<?php echo $article['Article']['body']; ?>

如何做...

  1. 编辑位于您的 app/controllers 文件夹中的 articles_controller.php 文件,并对 add() 方法做出以下更改:

    public function add() {
    if (!empty($this->data)) {
    $this->Article->create();
    if ($this->Article->save($this->data)) {
    $this->Session->setFlash(__('Article saved', true));
    $this->redirect(array('action'=>'index'));
    } else {
    $this->Session->setFlash(__('Please correct the errors', true));
    }
    }
    }
    
    
  2. 编辑位于您的 app/views/articles 文件夹中的 add.ctp 文件,并做出以下更改:

    <?php
    echo $this->Form->create();
    echo $this->Form->inputs(array(
    'legend' => __('New Article', true),
    'title' => array('label' => __('Title:', true)),
    'body' => array('label' => __('Body:', true))
    ));
    echo $this->Form->end(__('Save', true));
    ?>
    
    
  3. 最后,编辑位于您的 app/views/articles 文件夹中的 index.ctp 文件,并做出以下更改:

    <h1><?php __('Articles'); ?></h1>
    <p>
    <?php echo $this->Paginator->counter(__('Showing records %start%-%end% in page %page% out of %pages%', true)); ?>
    &nbsp;-&nbsp;
    <?php echo $this->Paginator->prev(__('<< Previous', true)); ?>
    &nbsp;
    <?php echo $this->Paginator->numbers(); ?>
    &nbsp;
    <?php echo $this->Paginator->next(__('Next >>', true)); ?>
    </p>
    <p>
    <?php
    $count = count($articles);
    echo $count . ' ' . __n('article', 'articles', $count, true) . ': ';
    ?>
    </p>
    <ul>
    <?php foreach($articles as $article) { ?>
    <li><?php echo $this->Html->link(
    $article['Article']['title'],
    array('action'=>'view', $article['Article']['id'])
    ); ?></li>
    <?php } ?>
    </ul>
    <p><?php echo $this->Html->link(__('Create article', true), array('action'=>'add')); ?></p>
    
    

如果您现在浏览到 http://localhost/articles,您应该会看到一个分页的文章列表,如下面的截图所示:

如何做...

它是如何工作的...

CakePHP 提供了两种主要方法(以及其他方法)来允许开发者指定可翻译的内容:__()__n()。这些方法的命名可能看起来有点奇怪,但它们在很大程度上受到了 Perl 的 gettext 实现的影响,gettext 是 GNU 翻译项目的一部分。

__() 方法用于翻译静态文本,并接受最多两个参数:

参数 目的
singular 应翻译为当前语言的文本。
return 如果设置为 true,则翻译的文本将被返回而不是输出到客户端。默认为 false

__n() 方法用于翻译可能因某个值是单数还是复数而改变的静态文本,并接受最多四个参数:

参数 目的
singular 如果 count 中的给定值为单数时,应使用的文本,并且在使用时将翻译为当前语言。
plural 如果 count 中的给定值为复数时,应使用的文本,并且在使用时将翻译为当前语言。
count 一个变量或数值,它包含用于确定是否使用 singularplural 文本的值。
return 如果设置为 true,则翻译的文本将被返回而不是输出到客户端。默认为 false

我们首先将 ArticlesController 类中的闪存消息更改为使用 __() 方法,指定应返回翻译字符串而不是输出到客户端。然后,我们修改 add.ctp 视图,以便所有标签和表单标题都可以进行翻译。

类似地,我们在 index.ctp 视图中使用翻译函数包裹标题。然后,我们使用 PaginatorHelper 类中 counter()next()prev() 方法的第一个参数来传递适当的分页文本的翻译版本。最后,我们使用 __n() 函数根据 count 变量的值选择正确的翻译文本。

注意

当使用 __n() 函数时,您应仅将其第三个参数用作变量。当运行 CakePHP 的提取器外壳时,使用表达式(包括数组索引)可能会产生意外的结果,这在配方 提取和翻译文本 中有介绍。

域和类别

本配方中使用的翻译函数实际上是 CakePHP 内置 I18n 类的 translate() 方法的包装器。此方法不仅允许简单的翻译,还允许开发者指定获取翻译文本的域,以及要翻译的文本所属的类别。

域允许您将翻译文本分组到单独的文件中。默认情况下,当未指定域时,CakePHP 假设一个名为 default 的域。如果您想指定翻译文本应查找的域,请使用 __d()__dn() 翻译函数。例如,要在 my_plugin 域中查找翻译文本,您将执行以下操作:

$translated = __d('my_plugin', 'Hello World', true);

类别允许通过将翻译文件分组到单独的目录中来进一步对翻译文本进行分组,并为翻译文本提供更多的意义。默认情况下,CakePHP 将假设翻译文本属于LC_MESSAGES类别。如果你希望更改类别,请使用__dc()__dcn()翻译函数,通过设置其倒数第二个参数return为所需的类别,这可以是以下定义的任何常量,具有相应的固定值:

  • LC_ALL: 0

  • LC_COLLATE: 1

  • LC_CTYPE: 2

  • LC_MONETARY: 3

  • LC_NUMERIC: 4

  • LC_TIME: 5

  • LC_MESSAGES: 6

例如,为了在default域和LC_MESSAGES类别中查找翻译文本,你会这样做:

$translated = __dc('default', 'Hello World', 6, true);

注意

当展望使用类别时,始终使用列表中给出的先前给定的类别值,而不是常量名称,因为这个常量是平台相关的。

参见

  • 国际化模型验证消息

  • 提取和翻译文本

国际化模型验证消息

在这个菜谱中,我们将学习不同的方法来实现相同的需求:翻译模型验证消息。

准备工作

为了完成这个菜谱,我们需要一个基本的应用骨架来工作。请参阅之前的菜谱。

如何操作...

编辑位于你的app/models文件夹中的article.php文件,并对validate属性进行以下更改:

public $validate = array(
'title' => array(
'required' => 'notEmpty'
),
'body' => array(
'required' => 'notEmpty'
)
);

有两种方式可以将验证消息翻译成其他语言。第一种方式需要你通过在app/models/article.php文件中定义的Article类中添加以下实现来覆盖模型构造函数:

public function __construct($id = false, $table = null, $ds = null) {
foreach($this->validate as $field => $rules) {
if (!is_array($rules)) {
$rules = (array) $rules;
}
foreach($rules as $key => $rule) {
if (!is_array($rule)) {
$rules[$key] = compact('rule');
}
}
$this->validate[$field] = $rules;
}
$this->validate = Set::merge($this->validate, array(
'title' => array(
'required' => array('message' => __('A title must be specified', true))
),
'body' => array(
'required' => array('message' => __('You must define the body', true))
)
));
parent::__construct($id, $table, $ds);
}

翻译验证消息的另一种方式是将这些消息移动到视图中。而不是在模型构造函数中覆盖并定义消息,编辑你的app/views/articles/add.ctp视图文件,并对其进行以下更改:

<?php
echo $this->Form->create();
echo $this->Form->inputs(array(
'title' => array(
'label' => __('Title:', true),
'error' => array(
'required' => __('A title must be specified', true)
)
),
'body' => array(
'label' => __('Body:', true),
'error' => array(
'required' => __('You must define the body', true)
)
)
));
echo $this->Form->end(__('Save', true));
?>

这两种方式应该产生相同的结果。如果你现在浏览到http://localhost/articles/add并提交表单而不输入任何值,你应该会看到以下截图所示的验证消息:

如何操作...

它是如何工作的...

在尝试为每个验证规则提供错误消息之前,我们需要为每个规则命名。我们通过修改Article模型来实现这一点,使得每个定义的规则都通过名称索引。在我们的例子中,我们选择required作为基于 CakePHP 内置的notEmpty规则的验证名称。

我们之前用来指定验证消息的第一种方法,在我们想要在模型中集中所有验证消息时,展示了一种实用的方法。我们覆盖了模型构造函数,以便在这个构造函数内部指定应该翻译的错误消息。我们需要实现构造函数,因为类属性值不能使用除静态赋值之外的任何表达式,所以以下代码块会产生 PHP 语法错误:

public $validate = array(
'title' => array(
'required' => array(
'rule' => 'notEmpty',
'message' => __('Nothing defined!', true) // SYNTAX ERROR
)
)
);

在此构造函数实现中,我们首先确保validate属性是一个按字段名称索引的规则数组,并且每个规则集本身也是一个按名称索引的数组,其值是另一个数组,其中至少定义了rule设置。

一旦我们确认validate属性具有正确的格式,我们就使用__()翻译函数合并每个规则的验证消息。最后,我们调用父构造函数以确保模型正确构建。

本食谱中描述的第二种方法通过FormHelperinput()方法的error设置将声明每个验证错误消息的责任转移到视图。此设置设置为按验证名称索引的数组,其值设置为在相应的验证失败时显示的错误消息。

参见

  • 提取和翻译文本

翻译包含动态内容的字符串

在本食谱中,我们将学习如何允许由非静态部分组成的字符串(如变量值)可翻译。

准备工作

为了完成这个食谱,我们需要一个基本的应用程序骨架来工作。完成整个食谱国际化控制器和视图文本

如何操作...

  1. 编辑位于您的app/controllers文件夹中的文件articles_controller.php并对add()方法做出以下更改:

    public function add() {
    if (!empty($this->data)) {
    $this->Article->create();
    if ($this->Article->save($this->data)) {
    $this->Session->setFlash(
    sprintf(__('Article "%s" saved', true), $this->Article->field('title'))
    );
    $this->redirect(array('action'=>'index'));
    } else {
    $this->Session->setFlash('Please correct the errors');
    }
    }
    }
    
    
  2. 编辑位于您的app/views/articles文件夹中的视图文件index.ctp并做出以下更改:

    <h1><?php __('Articles'); ?></h1>
    <p>
    <?php echo $this->Paginator->counter(__('Showing records %start%-%end% in page %page% out of %pages%', true)); ?>
    &nbsp;-&nbsp;
    <?php echo $this->Paginator->prev(__('<< Previous', true)); ?>
    &nbsp;
    <?php echo $this->Paginator->numbers(); ?>
    &nbsp;
    <?php echo $this->Paginator->next(__('Next >>', true)); ?>
    </p>
    <p>
    <?php
    $count = count($articles);
    printf(__n('%d article', '%d articles', $count, true), $count);
    ?>
    </p>
    <ul>
    <?php foreach($articles as $article) { ?>
    <li><?php echo $this->Html->link(
    $article['Article']['title'],
    array('action'=>'view', $article['Article']['id'])
    ); ?></li>
    <?php } ?>
    </ul>
    <p><?php echo $this->Html->link(__('Create article', true), array('action'=>'add')); ?></p>
    
    

它是如何工作的...

当期待包含动态信息,如变量的值,或者在这种情况下,数据库中表字段的值时,人们可能会简单地诱惑将变量附加到发送给翻译函数的字符串中:

$translated = __('Hello ' . $name, true); // This is wrong

这不是一个有效的表达式,因为本食谱中显示的 CakePHP 提取器期望翻译函数的参数仅为静态字符串,并且其他语言可能需要重新排序句子。因此,我们需要使用某种字符串插值方法,所以我们选择了 PHP 提供的最常见的方法:printf()sprintf()函数。

这两个函数接受相同数量和类型的参数。第一个参数是必需的,用于指定用于插值的字符串,而任何后续参数都用于生成最终的字符串。printf()sprintf()之间的唯一区别是,前者将输出结果字符串,而后者仅返回它。

我们首先更改ArticlesController类在创建文章时给出的成功消息。我们使用sprintf(),因为我们需要将其发送到Session组件的setFlash()方法。在这种情况下,我们使用表达式%s来插值新创建的文章的title字段的值。

类似地,我们最新的更改使用 %d 来插值变量 count 的十进制值,并使用 printf() 输出结果字符串。

重新排序和重用插值参数

当使用 %s%d 等表达式来告诉 printf()sprintf() 如何放置参数的值时,我们在值定位方面没有灵活性,也没有重用值的实际方法,因为每个这样的表达式都需要匹配一个特定的参数。让我们假设以下表达式:

printf('Your name is %s and your country is %s', $name, $country);

第一个 %s 表达式被替换为 name 变量的值,而最后一个 %s 表达式被替换为 country 变量的值。如果我们想在不改变传递给 printf() 的参数顺序的情况下改变这些值在字符串中的顺序,怎么办?

我们可以通过引用参数编号(name 是参数编号 1country 参数编号 2)来指定插值表达式使用的参数:

printf('You are from %2$s and your name is %1$s', $name, $country);

这也允许我们重用参数,而无需将其作为额外参数添加到 printf() 中:

printf('You are from %2$s and your name is %1$s . Welcome %1$s!', $name, $country);

参见

  • 提取和翻译文本

提取和翻译文本

在本食谱中,我们将学习如何从我们的 CakePHP 应用程序中提取所有需要翻译的字符串,然后使用免费软件执行实际的翻译。

准备工作

为了完成这个食谱,我们需要一个基本的应用程序骨架来工作。完成整个食谱 国际化控制器和视图文本

我们还需要在我们的系统中安装 Poedit。访问 www.poedit.net/download.php 并下载适合你的操作系统的文件。

如何做...

从命令行,并在你的 app/ 目录中,执行以下命令:

如果你在一个 GNU Linux / Mac / Unix 系统上:

../cake/console/cake i18n extract

如果你在 Microsoft Windows 上:

..\cake\console\cake.bat i18n extract

你应该接受默认选项,如下面的截图所示:

如何做...

在回答最后一个问题后,shell 应该遍历你的应用程序文件,并在你的 app/locale 文件夹中生成一个名为 default.pot 的翻译模板。

打开 Poedit,然后点击菜单 文件,选择 从 POT 文件新建目录。你现在应该看到一个打开文件对话框。浏览到你的 app/locale 文件夹,选择 default.pot 文件,然后点击 打开 按钮。应该出现一个设置窗口,如下面的截图所示:

如何做...

设置 窗口中,输入所需的项目名称和项目信息。在 复数形式 字段中,你应该输入一个表达式,告诉 Poedit 如何识别复数翻译。对于大多数语言,如英语、西班牙语、德语和葡萄牙语,你应该输入以下表达式:

nplurals=2; plural=(n != 1);

注意

关于复数形式以及根据你翻译到的语言应提供哪个值,更多信息可在drupal.org/node/17564找到。

一旦你输入了所有所需的详细信息,点击确定按钮。现在你将被询问要存储翻译文件的位置。创建一个名为spa的文件夹,并将其放置在app/locale文件夹中。在spa文件夹内,创建一个名为LC_MESSAGES的文件夹。然后,在 Poedit 的对话框中,选择文件夹app/locale/spa/LC_MESSAGES,点击按钮保存,不要更改文件名,文件名应该是default.po

Poedit 现在将显示所有原始字符串,并允许你通过在底部文本区域输入所需的翻译来翻译每个字符串。在你输入翻译后,Poedit 可能看起来像以下截图:

如何做...

点击菜单文件,然后选择保存以保存翻译后的文件。现在你的app/locale/spa/LC_MESSAGES文件夹中应该有两个文件:default.podefault.mo

它是如何工作的...

CakePHP 的提取器首先会询问要处理哪些路径。当所有路径都已指定后,它将递归地浏览其目录并查找 PHP 和视图文件中任何翻译函数(__()__n()__d()__dn()__dc()__dcn()__c())的使用。对于每个找到的使用情况,它将提取需要翻译的字符串(在调用__()__c()时的第一个参数;在调用__d()__dc()时的第二个参数;在调用__n()时的第一个和第二个参数;以及在调用__dn()__dcn()时的第二个和第三个参数)。

注意

在提取器查找的参数上,只应使用静态字符串,避免任何 PHP 表达式。如果你想了解如何在需要翻译的字符串中插入变量值,请参阅食谱使用动态内容翻译字符串

一旦 CakePHP 的提取器获取了所有需要翻译的字符串,它将创建适当的翻译模板文件。如果你使用了指定域的任何翻译函数(__d()__dn()__dc()__dcn()),你可以选择将所有字符串合并到一个模板文件中,或者让每个域创建一个单独的模板文件。模板文件具有pot扩展名,并使用域名作为其文件名(default.pot是默认模板文件)。

如果你用文本编辑器打开default.pot,你会注意到它从一个包含几个设置的标题开始,然后为每个需要翻译的字符串包含两行:一行定义msgid(要翻译的字符串),另一行对于msgstr(翻译的字符串)为空字符串。

我们随后使用 Poedit 打开这个模板文件,翻译字符串,并将其保存到适当的目录(app/locale/spa/LC_MESSAGES),在那里 Poedit 将创建两个文件:default.podefault.pot。如果你用文本编辑器打开 default.po,你会注意到它几乎与模板文件完全相同,除了头部设置已更改为我们定义的,而 msgid 行则填充了我们的翻译。default.mo 文件是 default.po 文件的二进制版本,也是由 Poedit 生成的,并由 CakePHP 用于加速翻译文件的处理。

使用 Translate 行为翻译数据库记录

在这个菜谱中,我们将学习如何通过 CakePHP 的 Translate 行为允许翻译数据库记录。

准备工作

为了完成这个菜谱,我们需要一个基本的应用程序骨架来工作。完成整个菜谱 国际化控制器和视图文本

如何操作...

在命令行中,并在你的 app/ 目录下,执行以下命令:

如果你使用的是 GNU Linux / Mac / Unix 系统:

../cake/console/cake i18n initdb

如果你使用的是 Microsoft Windows:

..\cake\console\cake.bat i18n initdb

接受所有默认答案。shell 应该通过创建一个名为 i18n 的表来完成,如下截图所示:

如何操作...

编辑你的 app/models/article.php 文件,并添加以下属性:

<?php
class Article extends AppModel {
public $validate = array(
'title' => 'notEmpty',
'body' => 'notEmpty'
);
public $actsAs = array(
'Translate' => array('title', 'body')
);
}
?>

我们现在需要将 titlebody 字段的值从 articles 表移动到 i18n 表中,然后从 articles 表中删除这些字段。执行以下 SQL 语句:

INSERT INTO `i18n`(`locale`, `model`, `foreign_key`, `field`, `content`)
SELECT 'eng', 'Article', `articles`.`id`, 'title', `articles`.`title`
FROM `articles`;
INSERT INTO `i18n`(`locale`, `model`, `foreign_key`, `field`, `content`)
SELECT 'eng', 'Article', `articles`.`id`, 'body', `articles`.`body`
FROM `articles`;
ALTER TABLE `articles`
DROP COLUMN `title`,
DROP COLUMN `body`;

通过执行以下 SQL 语句添加我们文章的西班牙语翻译:

INSERT INTO `i18n`(`locale`, `model`, `foreign_key`, `field`, `content`) VALUES
('spa', 'Article', 1, 'title', 'Primer Artículo'),
('spa', 'Article', 1, 'body', 'Cuerpo para el primer Artículo'),
('spa', 'Article', 2, 'title', 'Segundo Artículo'),
('spa', 'Article', 2, 'body', 'Cuerpo para el segundo Artículo'),
('spa', 'Article', 3, 'title', 'Tercer Artículo'),
('spa', 'Article', 3, 'body', 'Cuerpo para el tercer Artículo');

最后,编辑你的 app/config/bootstrap.php 文件,并在 PHP 结束标签上方添加以下内容:

Configure::write('Config.language', 'eng');

如果你现在浏览到 http://localhost/articles,你应该看到与第一个截图(菜谱 国际化控制器和视图文本)中显示的相同的文章列表。

它是如何工作的...

我们首先使用 shell 创建 Translate 行为所需的表。默认情况下,此表名为 i18n,包含(除了其主键外)以下字段:

字段 目的
locale 正在翻译的特定记录字段所翻译的区域设置(语言)。
model 包含正在翻译的记录的模型。
foreign_key model 中标识正在翻译的记录的 ID(主键)。
field 正在翻译的字段。
content 记录字段的翻译值。

我们随后将 Translate 行为添加到我们的 Article 模型中,并将其设置为翻译 titlebody 字段。这意味着这些字段将不再是 articles 表的一部分,而是存储在 i18n 表中。使用 i18n 表中的 modelforeign_key 值,Translate 行为将在获取到匹配应用程序语言的 Article 记录时,为这些字段获取适当的值。

我们将 titlebody 字段的值复制到 i18n 表中,然后从 articles 表中删除这些字段。在我们的 ArticlesController 类中使用的 find() 调用不需要任何更改。此外,文章的创建将继续透明地工作,因为 Translate 行为将在通过 Article 模型保存记录时使用当前语言。

最后一步是告诉 CakePHP 默认应用程序语言,通过设置 Config.language 配置设置。如果省略此步骤,CakePHP 将通过查看客户端浏览器发送的 HTTP_ACCEPT_LANGUAGE 标头来获取当前语言。

使用单独的翻译表

任何使用 Translate 行为的模型默认将使用此 i18n 表来存储其每个翻译字段的翻译。如果我们有大量记录或大量翻译模型,这可能会很麻烦。幸运的是,Translate 行为允许我们配置不同的翻译模型。

例如,假设我们想要将所有文章翻译存储在一个名为 article_translations 的表中。创建该表,然后通过以下 SQL 语句从 i18n 表中复制翻译记录:

CREATE TABLE `article_translations`(
`id` INT UNSIGNED AUTO_INCREMENT NOT NULL,
`model` VARCHAR(255) NOT NULL,
`foreign_key` INT UNSIGNED NOT NULL,
`locale` VARCHAR(6) NOT NULL,
`field` VARCHAR(255) NOT NULL,
`content` TEXT default NULL,
KEY `model__foreign_key`(`model`, `foreign_key`),
KEY `model__foreign_key__locale`(`model`, `foreign_key`, `locale`),
PRIMARY KEY(`id`)
);
INSERT INTO `article_translations`
SELECT `id`, `model`, `foreign_key`, `locale`, `field`, `content`
FROM `i18n`;

创建一个名为 article_translation.php 的文件,并将其放置在您的 app/models 文件夹中,内容如下:

<?php
class ArticleTranslation extends AppModel {
public $displayField = 'field';
}
?>

翻译模型中的 displayField 属性告诉 Translate 行为在表中哪个字段持有正在翻译的字段名称。

最后,编辑您的 app/models/article.php 文件,并做出以下更改:

<?php
class Article extends AppModel {
public $validate = array(
'title' => 'notEmpty',
'body' => 'notEmpty'
);
public $actsAs = array(
'Translate' => array('title', 'body')
);
public $translateModel = 'ArticleTranslation';
}
?>

参见

  • 设置和记住语言

设置和记住语言

在这个配方中,我们将学习如何允许用户更改当前语言,并通过使用 cookies 记住他们的语言选择。

准备工作

要完成这个配方,我们需要一个完全国际化的应用程序来工作。完成整个配方 使用 Translate 行为翻译数据库记录

我们还需要一个可以修改的应用程序布局。将 cake/libs/view/layouts 中的 default.ctp 文件复制到您的 app/views/layouts 目录。

如何做...

  1. 编辑您的 app/config/bootstrap.php 文件,并在 PHP 结束标签上方添加以下内容:

    Configure::write('Config.languages', array(
    'eng' => __('English', true),
    'spa' => __('Spanish', true)
    ));
    
    
  2. 编辑位于您的 app/views/layouts 文件夹中的 default.ctp 布局文件,并在您希望包含语言列表的位置(例如在调用 Session 组件的 flash() 方法之前)添加以下内容:

    <div style="float: right">
    <?php
    $links = array();
    $currentLanguage = Configure::read('Config.language');
    foreach(Configure::read('Config.languages') as $code => $language) {
    if ($code == $currentLanguage) {
    $links[] = $language;
    } else {
    $links[] = $this->Html->link($language, array('lang' => $code));
    }
    }
    echo implode(' - ', $links);
    ?>
    </div>
    
    

    注意

    之前使用的 Config.language 设置是在通过 使用 Translate 行为翻译数据库记录 时在 app/config/bootstrap.php 文件中指定的。

  3. 创建一个名为 app_controller.php 的文件,并将其放置在您的 app/ 文件夹中,内容如下:

    <?php
    class AppController extends Controller {
    public $components = array('Language', 'Session');
    }
    ?>
    
    
  4. 最后,创建一个名为 language.php 的文件,并将其放置在 app/controller/components 文件夹中,内容如下:

    <?php
    class LanguageComponent extends Object {
    public $controller = null;
    public $components = array('Cookie');
    public $languages = array();
    public function initialize($controller) {
    $this->controller = $controller;
    if (empty($languages)) {
    $this->languages = Configure::read('Config.languages');
    }
    $this->set();
    }
    public function set($language = null) {
    $saveCookie = false;
    if (empty($language) && isset($this->controller)) {
    if (!empty($this->controller->params['named']['lang'])) {
    $language = $this->controller->params['named']['lang'];
    } elseif (!empty($this->controller->params['url']['lang'])) {
    $language = $this->controller->params['url']['lang'];
    }
    if (!empty($language)) {
    $saveCookie = true;
    }
    }
    if (empty($language)) {
    $language = $this->Cookie->read('language');
    if (empty($language)) {
    $saveCookie = true;
    }
    }
    if (empty($language) && !array_key_exists($language, $this->languages)) {
    $language = Configure::read('Config.language');
    }
    Configure::write('Config.language', $language);
    if ($saveCookie) {
    $this->Cookie->write('language', $language, false, '1 year');
    }
    }
    }
    ?>
    
    

如果你现在浏览到 http://localhost/articles,你应该能看到文章列表,在右上角,有一个切换当前语言到西班牙语的链接。点击它应该显示文章的西班牙语版本,并将所有可用的文本更改为所选语言,如下面的截图所示:

如何操作...

它是如何工作的...

我们首先定义所有可用的语言,这样我们就可以轻松地包含一个切换当前语言的链接。我们使用这个列表来构建链接列表,并将其放置在 default.ctp 布局文件中,只允许点击除当前应用程序语言之外的语言。

当前语言在 CakePHP 的配置变量 Config.language 中设置,该变量在配置文件 bootstrap.php 中被设置为默认语言(在我们的例子中是 eng)。当需要更改语言时,应在第一次使用翻译函数之前更改此设置。

为了保持控制器整洁,我们决定创建一个名为 Language 的组件来处理语言变更。这个组件将寻找一个名为 lang 的命名参数或 URL 参数。如果没有指定语言,组件将通过查看 cookie 来寻找当前语言。

如果没有设置 cookie,或者请求更改语言,组件将把当前语言保存在名为 language 的 cookie 中,该 cookie 的有效期为一年。

第十章。测试

在本章中,我们将涵盖以下内容:

  • 设置测试框架

  • 创建测试数据( fixtures)并测试模型方法

  • 测试控制器操作及其视图

  • 使用模拟来测试控制器

  • 从命令行运行测试

简介

本章涵盖了应用编程中最有趣的一个领域:通过 CakePHP 内置工具进行单元测试,它提供了一个完整且强大的单元测试框架。

第一个菜谱展示了如何设置测试框架,以便我们可以创建自己的测试用例。第二个菜谱展示了如何创建测试数据( fixtures)并使用这些数据来测试模型方法。第三和第四个菜谱展示了如何测试控制器操作,以及如何测试我们的视图是否显示了我们期望的内容。最后一个菜谱展示了如何以非普通方式运行测试。

设置测试框架

在这个菜谱中,我们将学习如何准备我们的 CakePHP 应用程序,使其包含创建我们自己的单元测试所需的所有元素,为本章其余菜谱的设置打下基础。

准备工作

为了完成本章包含的菜谱,我们需要一些数据来工作。通过发出以下 SQL 语句创建以下表:

CREATE TABLE `articles`(
`id`INT UNSIGNED NOT NULL AUTO_INCREMENT,
`title` VARCHAR(255) NOT NULL,
`body` TEXT NOT NULL,
PRIMARY KEY(`id`)
);
CREATE TABLE `users`(
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`username` VARCHAR(255) NOT NULL,
PRIMARY KEY(`id`)
);
CREATE TABLE `votes`(
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`article_id` INT NOT NULL,
`user_id` INT NOT NULL,
`vote` INT UNSIGNED NOT NULL,
PRIMARY KEY(`id`),
FOREIGN KEY `votes__articles`(`article_id`) REFERENCES `articles`(`id`),
FOREIGN KEY `votes__users`(`user_id`) REFERENCES `users`(`id`)
);

在名为 articles_controller.php 的文件中创建一个控制器,并将其放置在您的 app/controllers 文件夹中,内容如下:

<?php
class ArticlesController extends AppController {
public function vote($id) {
if (!empty($this->data)) {
if ($this->Article->vote($id, $this->data)) {
$this->Session->setFlash('Vote placed');
return $this->redirect(array('action'=>'index'));
} else {
$this->Session->setFlash('Please correct the errors');
}
}
}
public function view($id) {
$article = $this->Article->get($id);
if (empty($article)) {
$this->Session->setFlash('Article not found');
return $this->redirect(array('action' => 'index'));
}
$this->set(compact('article'));
}
}
?>

创建一个名为 article.php 的文件,并将其放置在您的 app/models 文件夹中,内容如下:

<?php
class Article extends AppModel
{
public $hasMany = array('Vote');
public function get($id)
{
return $this->find('first', array( 'fields' => array( 'Article.*', 'AVG(Vote.vote) AS vote' ),
'joins' => array(
array(
'type' => 'LEFT',
'table' => $this->Vote->getDataSource()- >fullTableName($this->Vote->table),
'alias' => 'Vote',
'conditions' => array(
'Vote.article_id = Article.id'
)
)
),
'conditions' => array('Article.id' => $id),
'group' => array(
'Article.id'
),
'recursive' => -1
));
}
public function vote($id, $data = array()) {
if (empty($data) || empty($data['Vote'])) {
throw new Exception("No data specified");
}
$data['Vote']['article_id'] = $id;
$this->Vote->create($data);
if (!$this->Vote->validates()) {
return false;
}
$conditions = array(
'Vote.user_id' => $data['Vote']['user_id'],
'Vote.article_id' => $data['Vote']['article_id']
);
if ($this->Vote->hasAny($conditions)) {
return false;
}
return ($this->Vote->save($data) !== false);
}
}
?>

创建一个名为 vote.php 的文件,并将其放置在您的 app/models 文件夹中,内容如下:

<?php
class Vote extends AppModel {
public $belongsTo = array('Article', 'User');
public $validate = array(
'article_id' => array('required' => true, 'rule' => 'notEmpty'),
'user_id' => array('required' => true, 'rule' => 'notEmpty'),
'vote' => array(
'required' => array('required' => true, 'rule' => 'notEmpty'),
'range' => array(
'rule' => array('range', 0, 6),
'allowEmpty' => true
)
)
);
}
?>

创建一个名为 articles 的文件夹,并将其放置在您的 app/views 文件夹中。创建一个名为 view.ctp 的文件,并将其放置在您的 app/views/articles 文件夹中,内容如下:

<h1><?php echo $article['Article']['title']; ?></h1>
Vote: <span id="vote"><?php echo number_format($article[0]['vote'], 1); ?></span>
<p><?php echo $article['Article']['body']; ?></p>

如何操作...

  1. sourceforge.net/projects/simpletest/files/simpletest/simpletest_1.0.1/simpletest_1.0.1.tar.gz/download 下载 1.0.1 SimpleTest 版本,并将其解压缩到您的 app/vendors 文件夹中。现在您应该在 app/vendors 中有一个名为 simpletest 的文件夹。

  2. 如果您现在浏览到 http://localhost/test.php,您应该看到 CakePHP 中可用的测试组列表,如图所示:如何操作...

  3. 点击任何这些组都会执行相应的单元测试。例如,如果您点击 acl 测试组,您应该看到一个绿色的条形表示所选组的所有测试都通过了,如图所示:如何操作...

它是如何工作的...

CakePHP 使用 SimpleTest 库作为其单元测试框架的核心。除非我们在应用程序中安装了 SimpleTest,否则我们将无法运行任何单元测试。安装库就像下载适当的版本并将其内容提取到我们的 app/vendors 文件夹中一样简单。

框架包括一组广泛的单元测试,几乎涵盖了核心中实现的每个功能。这些单元测试允许开发者报告针对核心功能的错误,解决这些问题,并确保这些错误不会在未来版本中再次出现。

创建固定装置和测试模型方法

在这个配方中,我们将学习如何创建测试数据,我们可以使用这些数据来测试我们的应用程序而不更改真实数据,以及如何创建我们自己的单元测试来覆盖模型功能。

准备工作

为了完成这个配方,我们需要一个基本的应用程序骨架来工作,并且需要安装 SimpleTest 库。完成整个配方,设置测试框架

如何做...

  1. 创建一个名为article_fixture.php的文件,并将其放置在您的app/tests/fixtures文件夹中,内容如下:

    <?php
    class ArticleFixture extends CakeTestFixture {
    public $import = 'Article';
    public $records = array(
    array(
    'id' => 1,
    'title' => 'Article 1',
    'body' => 'Body for Article 1'
    ),
    array(
    'id' => 2,
    'title' => 'Article 2',
    'body' => 'Body for Article 2'
    )
    );
    }
    ?>
    
    
  2. 创建一个名为user_fixture.php的文件,并将其放置在您的app/tests/fixtures文件夹中,内容如下:

    <?php
    class UserFixture extends CakeTestFixture {
    public $table = 'users';
    public $import = array('table' => 'users');
    public $records = array(
    array(
    'id' => 1,
    'username' => 'john.doe'
    ),
    array(
    'id' => 2,
    'username' => 'jane.doe'
    ),
    array(
    'id' => 3,
    'username' => 'mark.doe'
    )
    );
    }
    ?>
    
    
  3. 创建一个名为vote_fixture.php的文件,并将其放置在您的app/tests/fixtures文件夹中,内容如下:

    <?php
    class VoteFixture extends CakeTestFixture {
    public $import = 'Vote';
    public $records = array(
    array(
    'article_id' => 1,
    'user_id' => 1,
    'vote' => 4
    ),
    array(
    'article_id' => 1,
    'user_id' => 3,
    'vote' => 5
    ),
    array(
    'article_id' => 1,
    'user_id' => 2,
    'vote' => 4
    ),
    array(
    'article_id' => 2,
    'user_id' => 2,
    'vote' => 3
    ),
    array(
    'article_id' => 2,
    'user_id' => 3,
    'vote' => 4
    )
    );
    }
    ?>
    
    
  4. 创建一个名为article.test.php的文件,并将其放置在您的app/tests/cases/models文件夹中,内容如下:

    <?php
    class ArticleTestCase extends CakeTestCase {
    public $fixtures = array('app.article', 'app.user', 'app.vote');
    public function startTest($method) {
    parent::startTest($method);
    $this->Article = ClassRegistry::init('Article');
    }
    public function endTest($method) {
    parent::endTest($method);
    ClassRegistry::flush();
    }
    public function testGet() {
    $article = $this->Article->get(1);
    $this->assertTrue(!empty($article) && !empty($article['Article']));
    $this->assertTrue(!empty($article[0]) && !empty($article[0]['vote']));
    $this->assertEqual(number_format($article[0]['vote'], 1), 4.3);
    $article = $this->Article->get(2);
    $this->assertTrue(!empty($article) && !empty($article['Article']));
    $this->assertTrue(!empty($article[0]) && !empty($article[0]['vote']));
    $this->assertEqual(number_format($article[0]['vote'], 1), 3.5);
    }
    public function testVote() {
    $result = $this->Article->vote(2, array('Vote' => array(
    'user_id' => 2
    )));
    $this->assertFalse($result);
    $this->assertTrue(!empty($this->Article->Vote->validationErrors['vote']));
    $result = $this->Article->vote(2, array('Vote' => array(
    'user_id' => 2,
    'vote' => 6
    )));
    $this->assertFalse($result);
    $this->assertEqual($this->Article->Vote->validationErrors['vote'], 'range');
    $result = $this->Article->vote(2, array('Vote' => array(
    'user_id' => 2,
    'vote' => 1
    )));
    $this->assertFalse($result);
    $result = $this->Article->vote(2, array('Vote' => array(
    $result = $this->Article->vote(2, array('Vote' => array(
    'user_id' => 1,
    'vote' => 1
    )));
    $this->assertTrue($result);
    $article = $this->Article->get(2);
    $this->assertTrue(!empty($article[0]) && !empty($article[0]['vote']));
    $this->assertEqual(number_format($article[0]['vote'], 1), 2.7);
    $this->expectException();
    $this->Article->vote(2);
    }
    }
    ?>
    
    

它是如何工作的...

当测试模型方法时,了解测试期间使用的数据非常重要。即使完全有可能使用真实应用程序数据来测试模型,通常更安全(因此推荐)是指定用于测试的数据。这样,对真实数据的任何修改都不应影响我们的测试,因此运行这些测试不应影响真实数据。

为了这个目的,CakePHP 提供了固定装置的概念,这不过是一些定义用于测试模型的表结构和数据的 PHP 类。这些固定装置应该与它们提供数据的模型同名,应该扩展基本类CakeTestFixture,并且应该以Fixture结尾。文件名应该是类名的下划线版本,并且应该放置在app/tests/fixtures目录中。一个固定装置可以定义以下属性:

  • name: 固定装置的名称,用于确定此固定装置创建的表名称。如果可以通过其他方式确定表名称,例如通过设置table属性,或者从模型导入结构,那么这个属性是可选的。

  • table: 这个固定装置创建的表。如果固定装置从现有模型导入结构,或者指定了name属性,那么这个属性是可选的。

  • import: 这个属性是可选的,允许从现有源导入结构,和/或数据。如果这个属性被设置为字符串,那么它是一个模型名称,从中导入结构(不是记录。)否则,它应该是一个包含以下设置的数组:

    • records: 一个可选的布尔设置。如果设置为 true,则将从指定的来源导入所有记录。默认为 false

    • model: 从哪里导入结构,以及/或数据。如果指定,此模型必须存在。

    • table: 从哪里导入结构,以及/或数据。如果指定了 model 设置,则此设置被忽略,因此是可选的。

    • fields: 如果未定义 import,则此属性是必需的。它应该是一个数组,其中每个键是字段名,每个值是字段的定义,包含如下设置:type, length, null, defaultkey。有关这些设置的更多信息,请参阅 book.cakephp.org/view/1203/Creating-fixtures

    • records: 记录的数组,每个记录本身也是一个数组,其中键是字段名,值是相应的值。

我们首先创建以下 fixtures:

  • ArticleFixture: 它从 Article 模型导入其结构,并定义了两个记录。

  • UserFixture: 它从 users 表导入其结构,并定义了三个记录(注意我们是如何从表而不是从模型导入的,因为我们没有创建 User 模型)。

  • VoteFixture: 它从 Vote 模型导入其结构,并定义了五个记录。

在创建完 fixtures 后,我们继续构建测试用例。测试用例是一个没有命名限制的 PHP 类,其中包含单元测试。它扩展自 CakeTestCase,并保存为以 .test.php 为后缀的文件,并放置在 app/tests/cases 文件夹的适当子目录中。单元测试是测试用例类的一个方法,但只有以单词 test 开头的方法被视为单元测试,因此当执行测试用例时才会运行。

我们的测试用例命名为 ArticleTestCase,并定义了 fixtures 属性以指定测试用例使用的 fixtures。这些名称应与 fixture 文件名匹配,但不包括 _fixture.php 后缀。通过这些 fixtures,我们为整个测试用例中使用的模型提供测试数据。

在任何情况下,当你从单元测试实例化模型,并且除非你通过发送到 ClassRegistry::init() 方法的设置指定其他设置,否则 CakePHP 将自动将模型的数据库配置设置为 test_suite,这不仅适用于直接实例化的模型,还适用于由于绑定定义而实例化的任何模型。

test_suite 数据库配置,除非开发人员明确更改,将使用在 default 配置中定义的相同数据库配置,并将 test_suite_ 设置为表前缀以避免覆盖现有表。这意味着任何实例化的模型及其绑定(包括绑定绑定等)都应该有一个匹配的固定值,并且这些固定值应该添加到测试用例中。如果您想避免为不打算测试的模型定义固定值,请参阅本食谱中的 扩展模型以避免测试不必要的绑定 部分。

ArticleTestCase 中的前两种方法是父类 CakeTestCase 提供的回调的实现。有四个回调可用:

  • startCase():在第一个单元测试方法运行前执行。此方法在每个测试用例中执行一次。

  • endCase():在最后一个单元测试方法运行后执行。此方法在每个测试用例中执行一次。

  • startTest():在每个单元测试方法运行前执行。它接收一个参数,即即将执行的测试方法名称。

  • endTest():在每个单元测试方法运行后执行。它接收一个参数,即测试方法名称。

我们使用 startTest() 回调来实例化我们打算测试的模型(在这个例子中是 Article),并使用 endTest() 回调来清理注册表,这一步对于这个特定的测试用例不是必需的,但在许多其他场景中很有用。

我们定义了两个单元测试方法:testGet()testVote()。第一个方法旨在为 Article::get() 方法提供测试,而后者则通过 Article::vote() 方法测试投票的创建。在这些测试中,我们向正在测试的模型方法发出不同的调用,然后使用一些测试用例断言方法来评估这些调用:

  • assertTrue():断言提供的参数评估为 true

  • assertFalse():断言提供的参数评估为 false

  • assertEqual():断言第一个参数等于第二个参数。

  • expectException():期望下一次调用会产生异常。由于异常的处理方式,这个断言应该在测试方法中最后进行,因为任何在单元测试方法中抛出异常后应该执行的代码都将被忽略。避免这种限制的另一种方法是使用 try-catch 块,并手动调用 fail()pass() 方法作为结果。

还有其他在其他场景中很有用的断言方法,例如:

  • assertIsA():断言第一个参数是第二个参数中提供的类型的对象。

  • assertNull():断言提供的参数是 null

  • assertPattern():断言第二个参数与第一个参数中定义的正则表达式模式匹配。

  • assertTags(): 断言第一个参数与第二个参数提供的 HTML 标签匹配,不考虑标签属性的顺序。请参阅测试视图配方以了解此断言方法的示例用法。

更多内容...

这个配方向我们展示了如何轻松地创建固定装置。然而,当我们的应用程序中有许多模型时,这可以变成一项相当繁琐的任务。幸运的是,CakePHP 的bake命令提供了一个任务来自动创建固定装置:fixture

它可以以交互模式运行,其中它的问题引导我们完成所需的步骤,或者通过使用命令行参数。如果我们想为我们的Article模型创建一个包含多达两个记录的固定装置,我们会这样做:

在 GNU Linux / Mac / Unix 系统上:

../cake/console/cake bake fixture article -count 2

在 Microsoft Windows 上:

..\cake\console\cake.bat fixture article -count 2

这将在正确的位置生成article_fixture.php文件,并包含两个准备使用的示例记录。

扩展模型以避免测试不必要的绑定

在这个配方中,我们测试了影响ArticleVote模型的代码,但没有任何由这些单元测试覆盖的功能需要与User模型交互。那么我们为什么还需要添加user固定装置呢?简单地从fixtures属性中移除这个固定装置将使 CakePHP 抱怨缺少一个表(具体来说,是test_suite_users)。

为了避免为不测试的模型创建固定装置,我们可以通过扩展它们并重新定义它们的绑定来创建我们模型类的修改版本,只留下我们打算测试的部分。让我们修改我们的测试用例以避免使用user固定装置。

将以下内容添加到app/tests/cases/models/article.test.php文件的开始部分:

App::import('Model', array('Article', 'Vote'));
class TestArticle extends Article {
public $belongsTo = array();
public $hasOne = array();
public $hasMany = array(
'Vote' => array('className' => 'TestVote')
);
public $hasAndBelongsToMany = array();
public $alias = 'Article';
public $useTable = 'articles';
public $useDbConfig = 'test_suite';
}
class TestVote extends Vote {
public $belongsTo = array();
public $hasOne = array();
public $hasMany = array();
public $hasAndBelongsToMany = array();
public $alias = 'Vote';
public $useTable = 'votes';
public $useDbConfig = 'test_suite';
}

在继续编辑article.test.php文件时,修改ArticleTestCase类的fixtures属性,以便不再加载用户固定装置:

public $fixtures = array('app.article', 'app.vote');

最后,通过修改ArticleTestCase类的startTest()方法,将Article模型的实例化改为使用TestArticle

public function startTest($method)
{
parent::startTest($method);
$this->Article = ClassRegistry::init('TestArticle');
}

分析代码覆盖率

如果你已经安装了Xdebug(有关信息可在xdebug.org找到),你可以找出你的应用程序代码中有多少被单元测试覆盖。这个信息是理解应用程序哪些部分需要更多测试的极好工具。

一旦运行了测试用例,你会注意到一个名为分析代码覆盖率的链接。运行我们的测试用例后,点击此链接。CakePHP 会告诉我们我们已经完全覆盖了(100%覆盖率)我们的代码。如果你现在注释掉名为testVote()的单元测试方法,然后运行代码覆盖率分析,你会注意到这个数字下降到47.62%,CakePHP 也会显示哪些代码部分没有被单元测试覆盖,如下一张截图所示:

分析代码覆盖率

当你达到 100% 代码覆盖率时,你并不能保证你的代码没有错误,但可以保证你的应用程序代码的所有行至少被一个单元测试访问过。

单元测试无法触及的代码越多,你的应用程序出现错误的可能性就越大。

参见

  • 测试控制器操作及其视图

测试控制器操作及其视图

在这个配方中,我们将学习如何测试控制器操作并确保它们的视图产生我们预期的结果。

准备工作

为了完成这个配方,我们需要一个基本的应用程序骨架来工作,并且需要安装 SimpleTest 库。请参阅整个配方 设置测试框架

我们还需要测试数据。请参阅配方 创建固定数据和测试模型方法 中描述的固定数据的创建过程。

如何操作...

创建一个名为 articles_controller.test.php 的文件,并将其放置在 app/tests/cases/controllers 文件夹中,内容如下:

<?php
class ArticlesControllerTestCase extends CakeTestCase {
public $fixtures = array('app.article', 'app.user', 'app.vote');
public function testView() {
$result = $this->testAction('/articles/view/1', array('return'=>'vars'));
$expected = array(
'Article' => array(
'id' => 1,
'title' => 'Article 1',
'body' => 'Body for Article 1'
),
0 => array(
'vote' => 4.3333
)
);
$this->assertTrue(!empty($result['article']));
$this->assertEqual($result['article'], $expected);
$result = $this->testAction('/articles/view/1', array('return'=>'view'));
$this->assertTags($result, array(
array('h1' => array()),
'Article 1',
'/h1',
'Vote:',
array('span' => array('id'=>'vote')),
'4.3',
'/span',
array('p' => array()),
'Body for Article 1',
'/p'
));
}
?>

如果你现在浏览到 http://localhost/test.php,点击左侧菜单中的 App 部分的 测试用例 选项,然后点击 controllers / ArticlesController 测试用例,你应该会看到我们的单元测试成功,如下一张截图所示:

如何操作...

它是如何工作的...

我们首先在一个名为 ArticlesControllerTestCase 的类中创建测试用例,并将其保存在正确的位置(app/tests/cases/controllers),使用正确的文件名(articles_controller.test.php)。在这个类中,我们指定需要加载哪些固定数据,正如在配方 创建固定数据和测试模型方法 中所展示的,它包括所有加载的模型的数据。

我们的测试用例包含一个单独的单元测试方法:testView(),它旨在对 ArticlesController::view() 操作进行单元测试。在这个单元测试中,我们使用对所有测试用例都可用 testAction() 方法。此方法接受两个参数:

  • url:这是一个字符串或包含我们打算测试的控制器操作的 URL 的数组。如果它是一个数组,它应该与 CakePHP 解析基于字符串的 URL 使用的格式相同。

  • parameters:这是一组可选参数,可以是以下任何一种:

    • connection:如果 fixturize 设置为 true,则定义从哪里导入数据。

    • data:这是要提交给控制器的数据。

    • fixturize:如果设置为 true,则 connection 设置中定义的连接的所有数据都将导入到所有使用的模型的固定数据中。默认为 false

  • method:这是在 data 设置中指定数据时使用的提交方法。可以是 getpost。默认为 post

  • return: 这指定了 testAction() 调用后应返回的结果类型。如果设置为 result,这是默认值,它将返回控制器动作返回的任何内容。如果设置为 vars,它将返回从动作分配的视图变量。如果设置为 view,它将返回不带布局的渲染视图。最后,如果设置为 contents,它将返回包含其布局的渲染视图。

  • testView(): testView() 方法调用带有适当 ID 的 view() 动作,并告诉 testAction() 方法返回控制器动作中创建的视图变量。我们确保这个变量被设置为正确的文章信息。然后,我们通过调用 testAction() 方法来最终化,使用相同的 URL,但指定我们想要获取渲染的视图。

为了断言视图具有适当的内容,我们使用 assertTags() 方法,它提供了一种灵活的方式来检查 HTML 标签。此方法接受一个元素数组,每个元素要么是一个表示静态字符串的字符串,要么是一个以正斜杠开头的闭合标签,或者是一个数组,其中键是 HTML 标签名,值是自身也是一个数组,该数组包含属性(键是属性名称,值是相应的值)。

还有更多...

我们已经看到,通过使用 testAction(),我们可以轻松地测试我们的控制器动作并对动作的返回值、视图变量或视图内容进行断言。然而,我们还没有涵盖如何测试可能会将用户从当前动作重定向走的动作,或者如何测试会话操作。下一个菜谱将展示如何向刚刚构建的单元测试中添加更复杂的测试。

参见

  • 使用模拟测试控制器

使用模拟测试控制器

在这个菜谱中,我们将学习如何通过使用模拟(mocks),这个构建强大测试用例不可或缺的工具,来扩展我们在上一个菜谱中覆盖的内容。

准备工作

为了完成这个菜谱,我们需要已经设置好的单元测试。查看上一个菜谱。

如何做...

  1. 编辑你的 app/tests/cases/controllers/articles_controller.test.php 文件,并在类 ArticlesControllerTestCase 声明之前放置以下代码:

    App::import('Controller', 'Articles');
    class TestArticlesController extends ArticlesController {
    public $name = 'Articles';
    public $testRedirect = false;
    public function __construct() {
    parent::__construct();
    Configure::write('controllers.'.$this->name, $this);
    }
    public function beforeFilter() {
    if (isset($this->Session)) {
    App::import('Component', 'Session');
    Mock::generate('SessionComponent');
    $this->Session = new MockSessionComponent();
    }
    parent::beforeFilter();
    }
    public function redirect($url, $status = null, $exit = true) {
    $this->testRedirect = compact('url', 'status', 'exit');
    if ($exit) {
    $this->autoRender = false;
    }
    }
    }
    
    
  2. 在编辑 articles_controller.test.php 文件的同时,在 ArticlesControllerTestCase 类的声明之后,添加以下代码:

    public function testAction($url, $params = array()) {
    $url = preg_replace('/^\/articles\//', '/test_articles/', $url);
    $result = parent::testAction($url, $params);
    $this->Articles = Configure::read('controllers.Articles');
    return $result;
    }
    
    
  3. testView() 方法的开头添加以下代码:

    $result = $this->testAction('/articles/view/0');
    $this->assertTrue(!empty($this->Articles->testRedirect));
    $this->assertEqual($this->Articles->testRedirect['url'], array('action' => 'index'));
    
    
  4. 最后,将以下方法添加到 ArticlesControllerTestCase 类的末尾:

    public function testVote() {
    $result = $this->testAction('/articles/vote/2', array(
    'data' => array(
    'Vote' => array(
    'user_id' => 1,
    'vote' => 1
    )
    )
    ));
    $this->assertTrue(!empty($this->Articles->testRedirect));
    $this->assertEqual($this->Articles->testRedirect['url'], array('action' => 'index'));
    $this->Articles->Session->expectOnce('setFlash', array('Vote placed'));
    $article = $this->Articles->Article->get(2);
    $this->assertTrue(!empty($article) && !empty($article['Article']));
    $this->assertTrue(!empty($article[0]) && !empty($article[0]['vote']));
    $this->assertEqual(number_format($article[0]['vote'], 1), 2.7);
    }
    
    

如果你现在浏览到 http://localhost/test.php,点击左侧菜单中的 App 部分的 Test Cases 选项,然后点击 controllers / ArticlesController 测试用例,你应该会看到我们的单元测试成功,如下一张截图所示:

如何做...

它是如何工作的...

我们首先扩展我们要测试的控制器,以便我们可以覆盖其 redirect() 方法,这样当该方法作为我们的单元测试的一部分执行时,浏览器不会被重定向,我们可以使用重定向信息来做出断言。

如果调用 redirect(),我们将目标存储在一个名为 testRedirect 的属性中,并且避免终止执行(这会终止测试用例),而是避免渲染视图。这之所以有效,是因为每次我们从 ArticlesController 类调用 redirect() 时,我们通过发出返回语句来停止动作执行。

由于没有直接的方法可以从我们的测试用例中获取已执行的控制器实例(请参阅本食谱中的 还有更多 部分,以获取替代方法),我们需要保留控制器实例的引用。我们使用 CakePHP 的 Configure 类来存储引用,这样就可以轻松获取。

我们还希望避免在单元测试中使用真实的会话数据。这意味着我们需要找到一种方法让 CakePHP 认为,当控制器与其 Session 组件交互时,一切行为都如预期,同时实际上并不与浏览器会话交互。我们还想能够断言该组件中特定方法的执行情况。

模拟提供了一种方法,让我们在不实际执行对象底层逻辑的情况下模仿真实对象的行为。在控制器 beforeFilter 回调中的以下代码行:

if (isset($this->Session)) {
App::import('Component', 'Session');
Mock::generate('SessionComponent');
$this->Session = new MockSessionComponent();
}

我们正在用模拟版本替换 CakePHP 的 Session 组件的实例。这个模拟版本将允许控制器使用该组件的所有可用方法(例如 setFlash()),而无需实际执行底层调用。Mock::generate() 默认会生成一个完全模拟的对象(其所有底层功能都将被忽略)。如果我们只想模拟对象的一部分,我们需要生成一个部分模拟。例如,如果我们只想模拟 Session 组件的 setFlash() 方法,同时保持其其他原始方法,我们会这样做:

Mock::generatePartial('SessionComponent', false, array('setFlash'));

一旦我们有一个模拟对象以及从我们的单元测试中访问它的方法,我们就可以使用以下任何模拟断言方法来测试模拟对象的某个方法是否按预期调用:

  • expectAtLeastOnce(): 其第一个参数是我们期望执行的方法的名称,第二个可选参数是我们期望该方法接收的参数数组。当期望的方法至少被调用一次,但还可以执行更多次时,使用此方法。

  • expectNever(): 其第一个强制参数是我们打算确保在模拟对象上未执行的方法的名称。

  • expectOnce(): 它的行为与 expectAtLeastOnce() 完全相同,但确保该方法只执行一次。

我们通过覆盖 CakeTestCasetestAction() 方法来继续,这样每当请求 ArticlesController 类的 URL 时,我们就将那个 URL 更改为使用我们的扩展 TestArticlesController 类。一旦执行了适当的动作,我们就获取控制器类的实例,并将其保存在单元测试的 Articles 属性中,这样我们就可以引用它。

我们现在准备测试。我们首先修改 testView() 方法,以便我们可以测试一个 redirect() 调用,通过构建一个测试来强制无效的记录 ID,并断言控制器的 testRedirect 属性被设置为 index 动作。

我们通过实现 testVote() 方法来完成这个食谱,这个方法给我们一个机会来测试提交数据(使用前一个食谱中描述的 testAction() 方法的第二个参数),并断言模拟的 Session 类收到了对其 setFlash() 方法的调用,并带有正确的参数。

这个单元测试的最后部分使用我们控制器的主体模型来获取创建的文章,并确保它与我们的提交数据匹配。

还有更多...

虽然这个食谱中展示的方法非常强大,但它绝对不是测试控制器的唯一方法。我们也可以通过实例化控制器类并对我们打算测试的控制动作进行直接调用来执行测试。

然而,这并不是一个简单的操作,因为它需要按照 CakePHP 的 Dispatcher 类定义的相同步骤正确初始化我们的控制器。Mark Story 在 mark-story.com/posts/view/testing-cakephp-controllers-the-hard-way 上发表了一篇详细描述此方法的文章。

Mark Story 还发布了一篇关于控制器手动测试的后续文章,其中他介绍了模拟。这绝对是一篇值得一读的文章,可在mark-story.com/posts/view/testing-cakephp-controllers-mock-objects-edition找到。

从命令行运行测试

在这个食谱中,我们将学习如何从命令行运行我们的单元测试,这为自动测试报告打开了可能性。

准备工作

为了完成这个食谱,我们需要一个基本的应用程序骨架来与之一起工作,它应该有一套自己的单元测试。请阅读整个食谱“创建固定值和测试模型方法”。

如何操作...

使用您的操作系统控制台,切换到您的应用程序目录,并运行:

如果你使用的是 GNU Linux / Mac / Unix 系统:

../cake/console/cake testsuite app case models/article

如果你使用的是 Microsoft Windows:

..\cake\console\cake.bat testsuite app case models/article

现在 shell 应该运行指定的单元测试,并通知我们所有单元测试都成功了,如下一张截图所示:

如何操作...

它是如何工作的...

CakePHP 的testsuite shell 允许我们从命令行执行任何测试用例,或一组测试用例。它提供了几种方式来指定要执行哪个单元测试,只需指定至少两个参数。

第一个参数可以是appcore或插件名称。当你打算从你的应用程序目录中执行单元测试或一组测试时,使用app。如果你希望运行 CakePHP 的核心测试,使用core。最后,如果你希望从插件中运行测试,将插件名称作为testsuite shell 的第一个参数。

第二个参数应指定要运行哪种类型的单元测试。它可以设置为all,表示运行所有测试;group,表示运行第三参数中指定的测试组;或者case,表示运行第三参数中定义的测试用例。

第十一章。实用类和工具

在本章中,我们将涵盖:

  • 使用 Set 类

  • 使用 String 类操作字符串

  • 发送电子邮件

  • 使用 MagicDb 检测文件类型

  • 抛出和处理异常

简介

本章介绍了一组实用类和有用的技术,这些技术可以改善 CakePHP 应用的架构。

第一道菜谱展示了如何使用 CakePHP 类优化数组操作。第二道菜谱展示了如何使用 CakePHP 的 String 类操作字符串。第三道菜谱展示了如何使用 Email 组件发送电子邮件。第四道菜谱展示了如何使用 MagicDb 类检测文件的类型。

使用 Set 类

CakePHP 做过的最具争议的决定之一是将模型 find 操作的结果返回为数组。虽然 ORM 纯粹主义者可能会争论每个返回项都应该是模型类的一个实例,但数组证明自己在操作那些用纯对象方法难以实现的特征时非常实用、快速且灵活。

Set 类被引入,以便在处理基于数组的复杂数据结构时,开发者能够拥有更多的权力。通过简单的调用方法,我们可以轻松地操作数组,避免了编写长而复杂的代码块的需要。

这个菜谱展示了如何使用这个类提供的最有用的方法,同时介绍了在其他不同场景下可能有用的其他方法。

准备工作

为了完成这个菜谱,我们需要一些数据来操作。创建以下表,并通过发出以下 SQL 语句填充它们:

CREATE TABLE `students`(
`id` INT UNSIGNED AUTO_INCREMENT NOT NULL,
`name` VARCHAR(255) NOT NULL,
PRIMARY KEY(`id`)
);
CREATE TABLE `categories`(
`id` INT UNSIGNED AUTO_INCREMENT NOT NULL,
`name` VARCHAR(255) NOT NULL,
PRIMARY KEY(`id`)
);
CREATE TABLE `exams`(
`id` INT UNSIGNED AUTO_INCREMENT NOT NULL,
`category_id` INT UNSIGNED NOT NULL,
`name` VARCHAR(255) NOT NULL,
PRIMARY KEY(`id`),
FOREIGN KEY `exams__categories`(`category_id`) REFERENCES `categories`(`id`)
);
CREATE TABLE `grades`(
`id` INT UNSIGNED AUTO_INCREMENT NOT NULL,
`student_id` INT UNSIGNED NOT NULL,
`exam_id` INT UNSIGNED NOT NULL,
`grade` FLOAT UNSIGNED NOT NULL,
PRIMARY KEY(`id`),
FOREIGN KEY `grades__students`(`student_id`) REFERENCES `students`(`id`),
FOREIGN KEY `grades__exams`(`exam_id`) REFERENCES `exams`(`id`)
);
INSERT INTO `students`(`id`, `name`) VALUES
(1, 'John Doe'),
(2, 'Jane Doe');
INSERT INTO `categories`(`id`, `name`) VALUES
(1, 'Programming Language'),
(2, 'Databases');
INSERT INTO `exams`(`id`, `category_id`, `name`) VALUES
(1, 1, 'PHP 5.3'),
(2, 1, 'C++'),
(3, 1, 'Haskell'),
(4, 2, 'MySQL'),
(5, 2, 'MongoDB');
INSERT INTO `grades`(`student_id`, `exam_id`, `grade`) VALUES
(1, 1, 10),
(1, 2, 8),
(1, 3, 7.5),
(1, 4, 9),
(1, 5, 6),
(2, 1, 7),
(2, 2, 9.5),
(2, 3, 6),
(2, 4, 10),
(2, 5, 9);

在名为 exams_controller.php 的文件中创建一个控制器,并将其放置在您的 app/controllers 文件夹中,内容如下:

<?php
class ExamsController extends AppController {
public function index() {
}
}
?>

创建一个名为 exam.php 的文件,并将其放置在您的 app/models 文件夹中,内容如下:

<?php
class Exam extends AppModel {
public $belongsTo = array('Category');
public $hasMany = array('Grade');
}
?>

创建一个名为 grade.php 的文件,并将其放置在您的 app/models 文件夹中,内容如下:

<?php
class Grade extends AppModel {
public $belongsTo = array(
'Exam',
'Student'
);
}
?>

如何做...

  1. 编辑您的 app/controllers/exams_controller.php 文件,并在其 index() 方法中插入以下内容:

    $gradeValues = Set::extract(
    $this->Exam->find('all'),
    '/Grade/grade'
    );
    $average = array_sum($gradeValues) / count($gradeValues);
    $categories = $this->Exam->Category->find('all');
    $mappedCategories = Set::combine(
    $categories,
    '/Category/id',
    '/Category/name'
    );
    $gradeRows = $this->Exam->Grade->find('all', array(
    'recursive' => 2
    ));
    $grades = Set::format(
    $gradeRows,
    '%s got a %-.1f in %s (%s)',
    array(
    '/Student/name',
    '/Grade/grade',
    '/Exam/name',
    '/Exam/Category/name'
    )
    );
    $categories = Set::map($categories);
    $this->set(compact('average', 'grades', 'categories'));
    
    
  2. 创建一个名为 exams 的文件夹,并将其放置在您的 app/views 文件夹中。创建一个名为 index.ctp 的文件,并将其放置在 app/views/exams 文件夹中,内容如下:

    <h2>Average: <strong><?php echo $average; ?></strong></h2>
    <ul>
    <?php foreach($grades as $string) { ?>
    <li><?php echo $string; ?></li>
    <?php } ?>
    </ul>
    <h2>Categories:</h2>
    <ul>
    <?php foreach($categories as $category) { ?>
    <li><?php echo $category->id; ?>: <?php echo $category->name; ?></li>
    <?php } ?>
    </ul>
    
    

如果您现在浏览到 http://localhost/exams,您应该看到所有考试的平均成绩,每个学生在每个考试中的详细成绩列表,以及所有类别的列表,如下面的截图所示:

如何做...

它是如何工作的...

我们首先使用 Set::extract() 方法从从 Exam 模型中获取所有行后的结果中提取信息。我们感兴趣的是检索所有成绩的列表。extract() 方法最多接受三个参数:

  • path: 一个与 X-Path 2.0 兼容的表达式,用于显示应提取信息的路径。

    注意

    Set 类仅支持 X-Path 2.0 规范的一部分。在 X-Path 中有效的表达式,如 //,在 Set 中不可用。继续阅读本食谱以了解支持哪些表达式。

  • data:从其中提取信息的数组数据结构。

  • options:这些是可选设置。在撰写本文时,只有选项 flatten(一个布尔值)可用。将其设置为 false 将返回作为结果结构一部分的提取字段。默认为 true

path 参数在定义我们感兴趣的信息时提供了一个灵活的方法。为了进一步了解其语法,考虑从获取所有 Exam 记录及其 Category 信息以及所有相关的 Grade 记录得到的数据结构:

$data = $this->Exam->find('all');

在 X-Path 2.0 中,路径是一个由正斜杠 (/) 分隔的表达式,而该表达式中的每一部分代表一个子路径(CakePHP 的 Set::extract() 方法也强制使用起始斜杠)。因此,表达式 /children 指的是只包含名为 children 的元素的路径,而表达式 /children/grandchildren 将选择名为 grandchildren 的项,这些项是名为 children 的项的后代。当我们提到一个项的名称时,我们指的是数组结构中的键。

注意

更多关于 X-Path 2.0 的信息可以在 www.w3.org/TR/xpath20 获取。

如果我们只想获取 Exam 字段(从而丢弃有关 CategoryGrade 的信息),我们会使用以下方法:

Set::extract('/Exam', $data);

这将返回一个元素数组,每个元素按 Exam 索引,其值是 Exam 键的所有字段。如果我们只对 name 字段感兴趣,我们会在表达式中添加另一个子路径:

Set::extract('/Exam/name', $data);

我们还可以通过添加条件表达式进一步限制路径。条件表达式通过将典型的比较运算符(<, <=, >, >=, =, !=)应用于与路径匹配的每个元素来过滤元素(使用 Set::matches() 方法)。为了获取所有 grade 字段值小于 8Grade 记录,我们会使用以下表达式(注意条件表达式是如何应用于子路径并且被括号包围的):

Set::extract('/Grade[grade<8]', $data);

我们可以使用位置表达式代替比较运算符,这些表达式可以是以下任何一种:

  • :first:指的是第一个匹配的元素。

  • :last:指的是最后一个匹配的元素。

  • number:指的是位于由数字指示的位置的元素,其中 number 是大于或等于 1 的数字。

  • start:end:指的是从位置 start 开始,到位置 end 结束的所有元素。startend 都是大于或等于 1 的数字。

要过滤数据集,以便只返回所有 Grade 记录的第二和第三个元素,使用 grade 大于或等于 8 的记录子集,并仅获取 grade 字段的值,我们将这样做:

Set::extract('/Grade[grade>=8]/grade[2:3]', $data);

回到配方,我们首先只提取每个 Grade 记录的 grade 字段的值。这个 Set::extract() 调用返回一个 grade 值的数组,然后我们可以使用 PHP 的 array_sum()count() 函数来计算平均评分。

注意

Set::extract() 方法和其他 Set 方法的几个示例可以从其测试用例中获得。查看您的 CakePHP 核心文件夹中的 tests/cases/libs/set.test.php 文件,并查看不同的测试用例。

然后我们使用 Set::combine() 方法。此方法最多接受四个参数:

  • data:要操作的数据数组。

  • path1:用于获取结果数组键的 X-Path 2.0 路径。

  • path2:用于获取结果数组值的 X-Path 2.0 路径。如果没有指定,值将被设置为 null

  • groupPath:用于将结果项分组以便每个项都是相应组的子项的 X-Path 2.0 路径。

使用 /Category/id 表达式作为键,/Category/name 作为值,我们获得一个索引数组,其中键是 Category ID,值是相应的 Category 名称。

groupPath 参数在许多场景中非常有用。考虑一下需要按考试类别分组获取特定学生的所有考试成绩的需求。使用以下方法:

$records = $this->Exam->Grade->find('all', array(
'conditions' => array('Student.id' => 1),
'recursive' => 2
));
$data = Set::combine(
$records,
'/Exam/name',
'/Grade/grade',
'/Exam/Category/name'
);

我们将获得一个易于导航的数组:

array(
'Programming Language' => array(
'PHP 5.3' => '10',
'C++' => '8',
'Haskell' => '7.5'
),
'Databases' => array(
'MySQL' => '9',
'MongoDB' => '6'
)
)

配方继续通过获取所有评分,然后使用 Set::format() 方法获取格式化字符串列表。此方法接受三个参数:

  • data:要格式化的数据。

  • format:包含要使用的格式的 sprintf() 基于字符串。

  • keys:用于替换包含在 format 中的 sprintf() 转换规范的 X-Path 2.0 路径数组。

    注意

    要了解更多关于基于 sprintf() 的转换规范的信息,请参阅 php.net/sprintf

Set::format()format 字符串应用于 data 数组中的每个项,并返回一个格式化字符串数组。在配方中,我们使用了字符串 %s got a %-.1f in %s (%s)。此字符串包含四个转换规范:一个字符串、一个浮点数(我们强制只包含一位小数),以及另外两个字符串。这意味着我们的 keys 参数应包含四个路径。每个路径都将按顺序用于替换其相应的转换规范。

配方最后使用 Set::map() 方法结束,如果您想处理对象而不是数组,这个方法非常有用。此方法接受两个可选参数:

  • class: 创建对象实例时要使用的类名。这个参数通常用于指定数据,而 tmp 参数用于指定类名。

  • tmp: 如果第一个参数是数组,则此参数的行为类似于 class 参数。否则,它将被安全忽略。

只需用要转换的数据调用此方法,就会递归地将该数据转换为一系列通用对象实例。如果使用 class 参数,那么在创建相应的对象实例时将使用该参数中指定的类名。

更多...

Set 类的有用性不仅限于此。还有一些其他方法在本食谱中没有涉及,但可以帮助我们在开发 CakePHP 应用程序时。其中一些方法包括:

  • merge(): 作为两个 PHP 方法的组合:array_merge()array_merge_recursive(),允许在至少两个参数中存在相同的键,并且它们自身是数组时,正确合并数组。在这种情况下,它将对这些元素执行另一个 Set::merge()

  • filter(): 从数组中过滤掉空元素,保留评估为空的实值(0'0'

  • pushDiff(): 将一个数组的差异推送到另一个数组中,从第二个参数中插入到第一个参数中不存在的键,递归地。

  • numeric(): 判断数组中的元素是否只包含数值。

  • diff(): 计算并返回两个数组之间的不同元素。

  • reverse(): 将对象转换为数组。这个方法可以看作是 Set::map() 方法的相反。

  • sort(): 根据在 X-Path 2.0 兼容路径中指定的值对数组进行排序。

使用 String 类操作字符串

字符串操作可能是 PHP 最强大的功能之一,因为它提供了一系列函数来执行各种操作。即使几乎每个需求都可以通过使用 PHP 的核心方法来满足,某些形式的字符串操作可能会变得麻烦。

注意

要了解更多关于 PHP 核心字符串方法的信息,请参阅 php.net/manual/en/ref.strings.php

CakePHP 提供了一个名为 String 的实用类,帮助我们处理字符串。本食谱介绍了该类及其少量但有用的方法集。

准备工作

我们需要一个控制器来作为我们代码的占位符。创建一个名为 examples_controller.php 的文件,并将其放置在 app/controllers 文件夹中,内容如下:

<?php
class ExamplesController extends AppController {
public $uses = null;
public function index() {
$this->_stop();
}
?>

如何做到这一点...

编辑 app/controllers/examples_controller.php 文件,并在 index() 方法的开头添加以下内容:

$lines = array(
'"Doe, Jane", jane.doe@email.com',
'"Doe, John", john.doe@email.com'
);
foreach($lines as $i => $line) {
$line = String::tokenize($line, ',', '"', '"');

$line = array_combine(array('name', 'email'), $line);
foreach($line as $field => $value) {
$line[$field] = preg_replace('/^"(.+)"$/', '\\1', $value);
}
$line['id'] = String::uuid();
$lines[$i] = $line;
}
foreach($lines as $line) {
echo String::insert('[:id] Hello :name! Your email is\\: :email', $line) . '<br />';
}

如果你现在浏览到 http://localhost/examples,你应该会看到以下类似文本输出:

[4d403ee1-6bbc-48c6-a8cc-786894a56bba] Hello Doe, Jane! Your email is: jane.doe@email.com

[4d403ee1-9e84-487f-95cf-786894a56bba] Hello Doe, John! Your email is: john.doe@email.com

它是如何工作的...

String 类提供了以下字符串操作方法:

  • cleanInsert(): 清理通过 String::insert() 方法生成的字符串。

  • insert(): 在字符串中用一组值替换变量占位符。

  • tokenize(): 使用给定的分隔符将字符串分割成部分,并忽略出现在指定边界字符串之间的分隔符实例。

  • uuid(): 返回一个随机的 UUID 字符串。

此菜谱首先定义一个包含两个字符串的数组,每个字符串的格式类似于我们会在 CSV(逗号分隔值)文件中找到的格式。对于这些行中的每一行,我们使用 String::tokenize() 方法将 CSV 行分割成一组值。此方法最多接受四个参数:

  • data: 要分割的字符串。

  • separator: 分隔字符串的标记。默认为 ,.

  • `leftBound`: 指示应忽略 `separator` 字符的区域开始的边界字符串。默认为 `(`。

  • `rightBound`: 与 `leftBound` 类似,但标记了该区域的结束。默认为 `)`。

我们告诉 `String::tokenize()` 分割每一行,考虑到任何用引号括起来的表达式可以包含分隔符字符,在这种情况下应忽略它。然后我们使用 PHP 的 `array_combine()` 函数,使每一行成为一个关联数组,以字段名称为索引,其值对应于相应的字段值。

由于 `String::tokenize()` 方法返回的字符串包含了 `leftBound` 和 `rightBound` 参数中定义的边界字符串(如果它们是原始字符串的一部分),我们继续从每一行中删除它们。

然后我们使用 `String::uuid()` 方法将一个随机 UUID 字符串作为每行的 `id` 字段值。这个字符串将对于每一行都是唯一的,并且不应该重复,即使在不同的请求之间也不应该重复。

注意

更多关于 UUID 的信息可以在 [en.wikipedia.org/wiki/Universally_unique_identifier](http://en.wikipedia.org/wiki/Universally_unique_identifier) 获取。

最后,我们遍历每一行,并通过 `String::insert()` 方法输出一个动态生成的字符串。此方法最多接受三个参数:

  • `str`: 包含应替换的变量占位符的字符串。

  • `data`: 形式为 `variable => value` 的关联数组,用于将变量占位符替换为其相应的值。

  • `options`: 定义方法应如何行为的选项集。可用选项:* `before`: 指示变量占位符开始的字符串。默认为:. * `after`: 指示变量占位符结束的字符串。默认为 `null`,这意味着占位符从 `before` 中定义的字符串开始,并在单词结束时结束。

  • `escape`: 在查找用于 `before` 选项的字符串时使用的字符。默认为 `\`。

  • `format`: 用于查找变量占位符的正则表达式。

  • `clean`: 如果指定,它将通过 `String::cleanInsert()` 方法清理替换后的字符串。默认为 `false`,这意味着不进行清理。

在我们的例子中,我们使用字符串 `[:id] Hello :name! Your email is\\: :email`。这个字符串包含三个变量占位符:`:id, :name` 和 `:email`。这些占位符中的每一个都会被作为 `String::insert()` 方法的第二个参数传递的关联数组中的相应值所替换。

php`# Sending an e-mail If there is one task we can hardly avoid when building web applications it is sending out e-mails. It is such a basic need that CakePHP provides us with a ready-to-go component that can send e-mails, either through SMTP, or using PHP's `mail()` function. In this recipe we will learn how to use the `Email` component to send out e-mails through SMTP using a Google Mail account, and how to use e-mail layouts to proper render the e-mails. ## Getting ready We only need some place to put our code, and that place will be a model-less controller. Create a file named `emails_controller.php` and place it in your `app/controllers` folder, with the following contents: class EmailsController extends AppController { public $uses = null; public function index() { $this->_stop(); } } php ## How to do it... 1. Edit your `app/controllers/emails_controller.php` and add the following property to the `EmailsController` class (right below the `uses` property declaration), replacing the `username` and `password` settings highlighted with your Google Mail account, and password: public $components = array( 'Email' => array( 'delivery' => 'smtp', 'smtpOptions' => array( 'host' => 'ssl://smtp.gmail.com', 'port' => 465, 'username' => 'email@gmail.com', 'password' => 'password' ) ) ); php 2. While still editing the controller, add the following code to its `index()` method, right above the call to the `_stop()` method (replace the `to` property highlighted with the e-mail address where you wish to receive the test e-mail): $this->Email->to = 'Destination email@gmail.com'; $this->Email->subject = 'Testing the Email component'; $sent = \(this->Email->send('Hello world!'); if (!\)sent) { echo 'ERROR: ' . $this->Email->smtpError . '
'; } else { echo 'Email sent!'; } php 3. If you now browse to `http://localhost/emails`, you should see the message **Email sent!**, and you should then receive the test e-mail message in your inbox, as shown in the following screenshot:![How to do it...](https://github.com/OpenDocCN/freelearn-php-zh/raw/master/docs/cake-13-appdev-cb/img/1926_11_02.jpg) Let us now continue by sending an HTML e-mail, using layouts and templates. 4. Make the following changes to the `index()` method in your `app/controllers/emails_controller.php` file (remember to change the highlighted `to` property to your desired destination e-mail): $this->set(array( 'name' => 'Mariano Iglesias', 'url' => Router::url('/', true) )); $this->Email->to = 'Destination email@gmail.com'; $this->Email->subject = 'Testing the Email component'; $this->Email->sendAs = 'both'; $this->Email->template = 'test'; $sent = \(this->Email->send(); if (!\)sent) { echo 'ERROR: ' . $this->Email->smtpError . '
'; } else { echo 'Email sent!'; } php 5. Create a file named `default.ctp` and place it in your `app/views/layouts/email/html` folder with the following contents: <?php echo $title_for_layout;?>

This email was sent on:

php 6. Create a file named `default.ctp` and place it in your `app/views/layouts/email/text` folder with the following contents: This email was sent on: php 7. Create a file named `test.ctp` and place it in your `app/views/elements/email/html` folder, with the following contents:

Hello !

This is a test email from Html->link('My Test Application', $url); ?>

php 8. Create a file named `test.ctp` and place it in your `app/views/elements/email/text` folder, with the following contents: Hello ! This is a test email from My Test Application: php If you now browse to `http://localhost/emails` you should see the message **Email sent!**, and you should then receive the test e-mail message in your inbox in HTML format, and with a link to your web application. ## How it works... We start by adding the `Email` component to our controller's list of components. While adding it, we set the settings required to specify the type of delivery we wish to use. The connection settings available in the `Email` component are: * `delivery`: It is the type of delivery to use, and can be either: `mail` (uses PHP's `mail()` function), `smtp` (uses SMTP, and requires proper configuration of the `smtpOptions` setting), and `debug` (which tells the `Email` component to avoid sending the e-mail, and instead create a session flash message with the message contents.) * `smtpOptions`: If delivery is set to `smtp`, it defines an array of settings to specify the type of SMTP connection to attempt. Available settings for this setting are: * `protocol`: Protocol to use when connecting. Defaults to `smtp`. * `host`: SMTP host to connect to. Defaults to `localhost`. * `port`: Port to use when connecting to `host`. Defaults to `25`. * `username`: Username. * `password`: Password to use. * `client`: What is the client connecting to the SMTP server. Defaults to the `HTTP_HOST` environment variable. * `timeout`: How many seconds to wait until the attempt to reach the server times out. Defaults to `30`. We set delivery to `smtp`, and set the `smtpOptions` to what is required when attempting to send e-mails through Google Mail's SMTP server. Once the `Email` component is added to the controller and properly configured, we are ready to build and send e-mails. The controller's `index()` method builds the e-mail by setting some properties. The `Email` component takes most of its configuration through public properties, some of which are: * `to`: Destination, in the form: `name <email>`, where `email` is a valid e-mail address. It can also simply be an email address. * `from`: E-mail address that is sending the e-mail. This property uses the same format as the `to` property. Notice that if you use Google Mail's SMTP, only the name part of this setting will be used (as the e-mail address will be set to your Google Mail e-mail address.) * `replyTo`: Email address to which responses should be sent to. Same format as the `to` property. * `return`: E-mail address to send any delivery errors, sent by the remote mail server. Same format as the `to` property. * `readReceipt`: An e-mail address (using the same format as the `to` property) to where to send read receipt e-mails. Defaults to none. * `cc`: An array containing the e-mail address to where to send copies of this e-mail. Each e-mail address should be specified using the same format as the `to` property. * `bcc`: An array containing e-mail address to send blind copies of this e-mail. Each e-mail address should be specified using the same format as the `to` property. * `subject`: Subject for the e-mail. * `headers`: An array containing additional headers to send with the e-mail; each of those headers will be prefixed with `X-` as per `RFC 2822`. * `attachments`: An array if paths to files that should be attached to the e-mail. Using the `to` and `subject` property we specify the destination and subject of the e-mail. We did not have to define the `from` property since Google Mail uses the account specified when connecting to the SMTP server. We then issue a call to the `send()` method, passing the body of the e-mail as its argument, and based on its boolean response we inform if the e-mail was successfully sent or if it failed, in which case we use the `smtpError` property to show the error. The next part of the recipe uses templates and layouts to properly build the e-mail in two formats: HTML, and text, and uses replacement variables to show the flexibility of the e-mail component. E-mail layouts and templates are no different than controller layouts and views, as they inherit the controller properties (such as its replacement variables, and available helpers.) E-mail layouts wrap the contents of e-mail templates, by means of their `content_for_layout` variable, just as controllers layouts do. There are two types of email layouts: HTML layouts, stored in `app/views/layouts/email/html`, and text layouts, stored in `app/views/layouts/email/text`. Similarly, you can define templates for HTML emails by storing them in the folder `app/views/elements/emails/html`, and text email templates in `app/views/elements/emails/text`. We set the layout of the e-mail through the `layout` property of the `Email` component. If no layout is set, the default is used. Therefore, we start by creating the HTML layout in the file `app/views/layouts/email/html/default.ctp`, and the text layout in `app/views/layouts/email/text/default.ctp`. We create two versions of the same template, called `test`: its HTML version is stored in `app/views/elements/email/html/test.ctp`, and its text version in `app/views/elements/email/html/test.ctp`. The recipe continues by modifying the `index()` action. We start by defining two replacement variables: `name` and `url`, which are used in the `test` template. We then use the `sendAs` property of the `Email` component to say we are sending an HTML and text friendly e-mail. This property can be set to: `html`, to send HTML only e-mails; `text`, to send text only e-mails; and `both`, to send emails that support HTML and text e-mail clients. We use the `template` property of the `Email` component to specify that we wish to use our `test` template, and we finalize with a call to the `send()` method to send out the e-mail. ## There's more... A common mistake that web application developers make is sending out e-mails as part of a controller action that is triggered by the visitor. Strictly speaking, e-mail sending is a non-interactive task, and as such should not be tied to the user browsing experience. It is therefore recommended that the email sending task be performed in a non-interactive manner, which in CakePHP terms means from the console, also known as shell. To exemplify this solution, consider a subscription website, where users enter their information (including their e-mail address), and, as a result, the application sends out a confirmation e-mail. Instead of sending the e-mail as part of the controller action that is triggered from the submission form, we may set a database field that shows that those users have not yet been sent out the confirmation e-mail, and then have a CakePHP shell periodically check for users that need their confirmation e-mails, sending out those e-mails from the shell. This means that we find ourselves needing to be able to send e-mails from the shell, a topic covered in the recipe *Sending e-mails from shells* in Chapter 8, ## See also *Sending e-mails from shells* in Chapter 8, *Working with Shells*. # Detecting file types with MagicDb When handling file uploads, it is often important to determine the type of file being uploaded. While some files may be easily recognizable based on their contents, others may prove to be hard to identify. `MagicDb` is a file database that consists of specifications for several file formats. This recipe shows us how to use this database, through CakePHP's `MagicDb` class, to properly identify files uploaded by our users. The license for the `MagicDb` database file allows its use only on open source or freely available software. If you wish to identify files on commercial applications, you will have to find a different approach. ## Getting ready As we will be working on files uploaded by our users, we need to build a form to upload files. We will store these uploads in a table, so create this table with the following SQL statement: CREATE TABLE uploads( id INT UNSIGNED AUTO_INCREMENT NOT NULL, file VARCHAR(255) NOT NULL, mime VARCHAR(255) default NULL, description TEXT default NULL, PRIMARY KEY(id) ); php Create a file named `uploads_controller.php` and place it in your `app/controllers` folder, with the following contents: class UploadsController extends AppController { public function add() { if (!empty($this->data)) { \(this->Upload->create(); if (\)this->Upload->save($this->data)) { $this->Session->setFlash('File succesfully uploaded'); $this->redirect(array('action'=>'view', $this->Upload->id)); } else { $this->Session->setFlash('Please correct the errors marked below'); } } } } php Create a folder named `uploads` in your `app/views` folder. Create the view for the `add()` method in a file named `add.ctp` and place it in your `app/views/uploads` folder, with the following contents: Form->create('Upload', array('type'=>'file')); echo $this->Form->inputs(array( 'file' => array('type'=>'file') )); echo $this->Form->end('Upload'); ?> php ## How to do it... 1. Download the latest MagicDb database file from [`www.magicdb.org/magic.db`](http://www.magicdb.org/magic.db) and place it in your `app/vendors` folder. You should now have a file named `magic.db` in your `app/vendors` folder. 2. Edit your `app/controllers/uploads_controller.php` file and add the following methods right below the `add()` method: public function view($id) { $upload = $this->Upload->find('first', array( 'conditions' => array('Upload.id' => \(id) )); if (empty(\)upload)) { $this->cakeError('error404'); } \(this->set(compact('upload')); } public function download(\)id) { $upload = $this->Upload->find('first', array( 'conditions' => array('Upload.id' => \(id) )); if (empty(\)upload)) { $this->cakeError('error404'); } $path = TMP . \(upload['Upload']['file']; header('Content-type: '.\)upload['Upload']['mime']); readfile($path); $this->_stop(); } php 3. Create the view for the `view()` method in a file named `view.ctp` and place it in your `app/views/uploads` folder with the following contents:

File:
MIME Type:
Description:


Html->image(array('action'=>'download', $upload['Upload']['id']), array('height'=>200)); ?> Html->link('Download', array('action'=>'download', $upload['Upload']['id'])); ?>

php 4. Create the model in a file named `upload.php` and place it in your `app/models` folder with the following contents: magicDb)) { App::import('Core', 'MagicDb'); $magicDb = new MagicDb(); if (!$magicDb->read(APP . 'vendors' . DS . 'magic.db')) { return null; } $this->magicDb = $magicDb; } return $this->magicDb; } } ?> php 5. While still editing your `app/models/upload.php` file, add the following method to the `Upload` class: public function beforeValidate($options = array()) { \(result = parent::beforeValidate(\)options); $data = \(this->data[\)this->alias]; if (!empty(\(data['file'])) { if ( empty(\)data['file']) || !is_array(\(data['file']) || empty(\)data['file']['tmp_name']) || !is_uploaded_file($data['file']['tmp_name']) ) { $this->invalidate('file', 'No file uploaded'); return false; } $magicDb = \(this->getMagicDb(); if (!isset(\)magicDb)) { $this->invalidate('file', 'Can't get instance of MagicDb'); return false; } $path = TMP . \(data['file']['name']; if (!move_uploaded_file(\)data['file']['tmp_name'], $path)) { $this->invalidate('file', 'Could not move uploaded file'); return false; } \(data['file'] = basename(\)path); unset($data['mime']); $analysis = \(magicDb->analyze(\)path); if (!empty($analysis)) { $analysis = \(analysis[0]; if (preg_match('/^\[.+?;ext=[^;]+;mime=([^;]+);.*?\](.*)\)/i', $analysis[3], $match)) { $data['mime'] = \(match[1]; if (empty(\)data['description'])) { $data['description'] = \(match[2]; } } } if (empty(\)data['mime'])) { \(this->invalidate('Can\'t recognize file '.\)data['file']); return false; } \(this->data[\)this->alias] = $data; } else { $this->invalidate('file', 'This field is required'); return false; } return $result; } php If you now browse to `http://localhost/uploads/add`, you will see a form where you can select a file, and then click the button **Upload**. Doing so with a GIF image will produce a result similar to what shown in the following screenshot: ![How to do it...](https://github.com/OpenDocCN/freelearn-php-zh/raw/master/docs/cake-13-appdev-cb/img/1926_11_03.jpg) ## How it works... The recipe starts by downloading the `MagicDb` file and placing it into the `app/vendors` directory. This file is a text file; containing blocks of identifier file signatures, and for each of these file signature definitions, their respective mime type, and description. Next, we create the `view()` and `download()` controller actions. Both of them are very similar, except that the `download()` action uses the field `mime` to set the `Content-type` header, thus properly informing the client browser the type of data being sent. The `download()` action simply sends the contents of the file by using PHP's `readfile()` function, then calling the `_stop()` method (available to all CakePHP classes that descend from `Object)` to stop execution. The `view()` action, on the other hand, requires a view, which prints out the `Upload` record information, showing an image if the file is indeed an image, or showing a link to download the file, in any other case. The `Upload` model defines two methods: `beforeValidate()`, and `getMagicDb()`. The second method creates an instance of the `MagicDb` class provided by CakePHP, populating it with the contents from the `magic.db` file that was saved in the `app/vendors` directory. The validation callback `beforeValidate()` starts by making sure that a proper file was uploaded. If so, it moves the uploaded file to the application's temporary directory, and then uses the `analyze()` method of the `MagicDb` class to obtain the file information. This method will return an empty array if the file was not identified, or a set of file identifications that match the file. These file identifications are themselves arrays, containing information that is defined in the `magic.db` file. The fourth element out of this array contains the information we are looking for: a string that includes the file extension, the mime type, and the file type description. We extract this information, and we set it so it is saved together with the filename. If the file was not identified, we invalidate the `file` field. # Throwing and handling exceptions CakePHP 1.3 still offers support for PHP4, yet most CakePHP applications are built exclusively for PHP5\. Therefore, it is only expected that our applications use language features only available in PHP5, such as exceptions. However, there is no built-in support in CakePHP to handle exceptions. This recipe shows us how to create a base exception class that can be used throughout our application, and how to properly recover the application workflow after an exception is thrown. ## Getting ready We need a basic application skeleton to work with. Follow the entire recipe *Detecting file types with MagicDb*. ## How to do it... 1. Edit your `app/controllers/uploads_controller.php` file and change the `view()` and `download()` methods, so that where it reads: \(this->cakeError('error404'); ```php It now reads: ``` throw new AppException('Upload '.\)id.' not found'); php 2. Create a file named `app_exception.php` and place it in your `app/` folder, with the following contents: $this->getMessage(), 'trace' => $this->getStackTrace(), 'url' => Router::url(null, true), 'method' => env('REQUEST_METHOD'), 'referer' => env('HTTP_REFERER'), 'POST' => $_POST, 'GET' => $_GET, 'SESSION' => $_SESSION ); } public function getStackTrace($array = true, $count = 5) { if ($array) { $trace = $this->getTrace(); if (!empty($count)) { $trace = array_slice($trace, 0, $count); } foreach($trace as $i => $row) { $location = ''; if (!empty($row['class'])) { $location .= $row['class'] . $row['type'] . $row['function'] . '()'; } $file = !empty($row['file']) ? str_replace(ROOT.DS, '', $row['file']) : ''; if (!empty($file)) { if (!empty($location)) { $location .= ' (' . $file . '@' . $row['line'] . ')'; } else { $location .= $file . '@' . $row['line']; } } $trace[$i]['location'] = $location; unset($trace[$i]['args']); } return $trace; } return $this->getTraceAsString(); } } ?> php 3. Create a file named `exception_handler.php` and place it in your `app/libs` folder, with the following contents: 'File', 'name'=>'AppException', 'file'=>APP.'app_exception.php')); App::import('Core', 'Controller'); class ExceptionHandler extends Object { public static function handleException($exception) { self::getInstance(); self::logException($exception); self::renderException($exception); self::_stop(); } } ```php 4. While still editing your `app/libs/exception_handler.php` file, add the following methods to the `ExceptionHandler` class: ``` public function renderException($exception) { $Dispatcher = new Dispatcher(); $Controller = new Controller(); $Controller->params = array( 'controller' => 'exceptions', 'action' => 'exception' ); $Controller->viewPath = 'exceptions'; if (file_exists(VIEWS.'layouts'.DS.'exception.ctp')) { $Controller->layout = 'exception'; } $Controller->base = $Dispatcher->baseUrl(); $Controller->webroot = $Dispatcher->webroot; $Controller->set(compact('exception')); $View = new View($Controller); if (!file_exists(VIEWS.'exceptions'.DS.'view.ctp')) { if (Configure::read('debug') > 0) { echo 'Exception: '; echo $exception->getMessage(); echo '
';     echo $exception->getStackTrace(false);     echo '
'; return; } return $Controller->redirect(null, 500); } echo $View->render('view'); } public function logException($exception) { $trace = $exception->getStackTrace(); $message = get_class($exception) . ' thrown in ' . $trace[0]['location']; $message .= ': ' . $exception->getMessage(); if (is_a($exception, instanceof AppException)) { $message .= ' | DEBUG: ' . json_encodevar_export($exception->getInfo(), true); } self::log($message, LOG_ERROR); } ```php 5. Add the following at the end of your `app/config/bootstrap.php` file (right above the closing PHP tag): ``` App::import('Lib', 'ExceptionHandler'); set_exception_handler(array('ExceptionHandler', 'handleException')); ```php 6. Create a folder named `exceptions` in your `app/views` folder. Create a file named `view.ctp` and place it in your `app/views/exceptions` folder, with the following contents: ```

getMessage(); ?>

0) { ?>
    getStackTrace() as $trace) { ?>
getInfo(), array('message'=>null, 'trace'=>null))); ?>

An error has been found. It has been logged, and will soon be fixed.

php If you now force an error by browsing to `http://localhost/uploads/view/xx`, you will see a page describing the exception, its stack trace, and including relevant information, such as the URL, any POST or GET parameters, and session information, as shown in the following screenshot: ![How to do it...](https://github.com/OpenDocCN/freelearn-php-zh/raw/master/docs/cake-13-appdev-cb/img/1926_11_04.jpg) ## How it works... We start by using exceptions in our `UploadsController` class, instead of using CakePHP's `cakeError()` method, whenever an `Upload` record is not found. These exceptions are actually instances of `AppException`, but we could have as well created custom exceptions that inherit from `AppException`. The `AppException` class provides us with a base class from where to extend our application exceptions. This class offers us more contextual information through its `getInfo()` method. This information includes not only the exception message and the stack trace (which is simplified by removing the arguments, and limiting the number of items), but also the URL, method, any POST or GET data, and session information, details that can become valuable when working out the exception. We still have to add the ability to handle any exceptions that are thrown. For that purpose, we create the `ExceptionHandler` class. Through the code added to the `app/config/bootstrap.php` file, which uses PHP's `set_exception_handler()` function, we tell PHP that whenever an exception is thrown and not caught anywhere, the static `handleException()` method of the `ExceptionHandler` class is to be executed. This method logs the exception, using the `logException()` method, and renders a friendly page by calling the `renderException()` method. This rendering is performed by creating a dummy controller as an instance of `Controller`, using this controller to render the view `app/views/exceptions.ctp` (optionally using a layout named `exception.ctp` if one is available in `app/views/exceptions`), and setting the view variable `exception` to the exception being handled. This view shows a simple message if the debug level is set to `0`, or a thorough description of the stack trace and any context information that may be relevant.

posted @ 2025-09-05 09:26  绝不原创的飞龙  阅读(15)  评论(0)    收藏  举报