Yii-项目蓝图-全-

Yii 项目蓝图(全)

原文:zh.annas-archive.org/md5/ea12580568b76a473a7aa06cfa6cb491

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

Yii 框架是一个高性能、快速、开源的 PHP 框架,可用于开发现代网络应用。它为开发个人项目和商业应用提供了工具集。

本书是使用 Yii 框架开发八个可重用实际应用的逐步指南。Yii 项目蓝图将引导你通过几个项目,从项目构思到规划项目,最后实现项目。你将探索 Yii 框架的关键特性,并学习如何高效有效地使用它来构建可重用的核心应用,这些应用可以在实际项目中使用。你还将学习如何将 Yii 与第三方库和服务集成,创建自己的可重用代码库,并发现更多扩展你对 Yii 知识和专长的 Yii 特性。

本书涵盖的内容

第一章, 任务管理应用,从零开始使用 SQLite 和基本数据库迁移开发一个简单的任务管理应用。本章将涵盖 Yii 的所有组成部分,并为你准备使用更复杂的应用。

第二章, 发现附近的资源,介绍了如何将 Yii 框架与 Google Maps API 集成以显示用户附近的信息。你还将学习如何创建命令行工具来处理导入和处理数据。

第三章, 计划提醒,专注于开发一个多用户基于网络的调度和提醒应用,该应用可以在计划事件即将发生时通过电子邮件通知用户。

第四章, 开发问题跟踪应用,介绍了如何创建一个多用户问题跟踪和管理系统,包括使用 MySQL 作为数据库后端的一个电子邮件通知系统。本章还将涵盖处理电子邮件提交的输入以在应用程序中触发操作。

第五章, 创建一个微博平台,介绍了如何创建一个类似于 Twitter 的自己的微博平台,包括一个强大的用户认证和注册系统。你还将学习如何使用 HybridAuth 将你的应用程序与第三方社交网络集成,以及如何使用 Composer 简化你的无头开发时间。

第六章, 构建内容管理系统,介绍了如何创建一个功能完善的内容管理系统和博客平台,该平台基于前几章构建的知识扩展。本章还将演示如何与更多的第三方开源库集成。

第七章, 为 CMS 创建管理模块,专注于开发在前一章中构建的内容管理系统的管理模块。在本章中,您将学习如何将数据从控制器迁移到可以独立于内容管理系统重用和管理的 Yii 模块。

第八章, 为 CMS 构建 API,介绍了如何为内容管理系统创建一个 JSON REST API 模块,该模块可用于客户端 Web 应用程序和原生开发。本章将涵盖创建安全且经过身份验证的 JSON REST API 的基础知识,并演示如何将控制器操作调整为 JSON 响应而不是 Web 视图响应。

您需要为本书准备的内容

为了确保您可以在任何操作系统上运行提供的示例,并确保命令行条目的准确性,本书将使用 VirtualBox 和 Vagrant 来建立一个共同的开发平台。本书提供了如何设置此跨平台开发环境的说明。对于本书,您需要以下内容:

  • VirtualBox 4.3.x

  • Vagrant 1.3.x

  • Ubuntu 服务器 14.04 LTS

  • MySQL 5.6.x

  • PHP 5.5.x

  • Yii 框架 1.1.x

  • Composer

本书面向对象

如果您是一位对 PHP5 有良好了解且有一定 Yii 框架经验的 PHP 开发者,希望快速提升您的 Yii 知识并开始构建可重用的实际应用程序和工具,那么这本书是为您准备的。

习惯用法

在本书中,您将找到多种文本样式,用于区分不同类型的信息。以下是一些这些样式的示例及其含义的解释。

文本中的代码词汇、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 昵称应如下所示:“我们还需要修改我们的UserIdentity类,以允许社交认证用户登录。”

代码块应如下设置:

<?php
// change the following paths if necessary
$config=dirname(__FILE__).'/config/main.php';
$config = require($config);
require_once('/opt/frameworks/php/yii/framework/yiic.php');

当我们希望您注意代码块中的特定部分时,相关的行或项目将以粗体显示:

<div class="form-group">
<?php $selected = array('options' => array(isset($_
GET['id']) ? $_GET['id'] : NULL => array('selected' => true))); ?>
<?php echo CHtml::dropDownList('id', array(), CH
tml::listData(Location::model()->findAll(),'id','name'), 
CMap::mergeArray($selected, array('empty' => 'Select a Location'))); 
?>
</div>
<button type="submit" class="pull-right btn btnprimary">Search</button>
</form>

任何命令行输入或输出都应如下所示:

$ php protected/yiic.php migrate up

新术语重要词汇将以粗体显示。您在屏幕上看到的,例如在菜单或对话框中的文字,将以如下形式显示:“点击标题为模型生成器的链接,然后填写出现在页面上的表单。”

注意

警告或重要提示将以这样的框显示。

小贴士

技巧和窍门将以这样的形式出现。

读者反馈

我们始终欢迎读者的反馈。请告诉我们您对这本书的看法——您喜欢什么或可能不喜欢什么。读者反馈对我们开发您真正能从中获得最大收益的标题非常重要。

要发送给我们一般性的反馈,只需发送一封电子邮件到 <feedback@packtpub.com>,并在邮件的主题中提及书名。如果你在某个主题上有专业知识,并且你对撰写或为书籍做出贡献感兴趣,请参阅我们的作者指南 www.packtpub.com/authors

客户支持

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

下载示例代码

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

下载本书的彩色图像

我们还为你提供了一个包含本书中使用的截图/图表彩色图像的 PDF 文件。这些彩色图像将帮助你更好地理解输出的变化。你可以从以下链接下载此文件:www.packtpub.com/sites/default/files/downloads/7734OS_ColoredImages.pdf

勘误

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

侵权

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

请通过 <copyright@packtpub.com> 联系我们,并提供涉嫌盗版材料的链接。

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

问题

如果你在这本书的任何方面遇到问题,可以通过 <questions@packtpub.com> 联系我们,我们将尽力解决。

第一章. 任务管理应用

在开始使用 Yii 框架的最佳方式之一是制作有用的应用。本书将首先介绍一个简单的任务管理应用。在本章中,我们将涵盖这个项目的开发规划、开发应用以及创建我们将在后续章节中重用的有用组件。

描述项目

开始一个新项目最重要的步骤之一是规划它。在我们开始编程之前规划项目,我们可以轻松地确定应用将使用的大多数(如果不是所有)模型,我们需要实现的关键功能,以及可能在我们开发应用时引起问题的任何区域。在项目开始之前进行分解也有助于我们估计开发应用每个部分以及整个应用所需的时间。虽然我们应用的需求和期望在开发过程中很可能会发生变化,但确定应用的核心组件将有助于确保我们的应用核心功能按预期工作。

对于我们的任务管理应用,有两个主要组件:任务和项目。让我们分别分析这些组件。

任务

我们应用的第一部分是任务。任务是我们用户需要完成的项,通常包括一个简短、简洁的标题,以及完成任务所需完成的描述。有时,任务会关联一个截止日期或时间,这让我们知道任务需要在何时完成。任务还需要表明它们是否已经完成。最后,任务通常与一个包含类似或相关任务的组或项目相关联。

项目

我们应用的第二部分是项目。项目将相关任务分组,通常与它们相关联一个描述性的名称。项目也可能有一个截止日期或时间与之相关联,这表明项目中的所有任务需要在何时完成。我们还需要能够表明项目是否已完成。

用户

通过分解我们的项目,我们还确定了应用的一个第三部分:用户。在我们的应用中,用户将能够创建和管理项目以及任务,同时查看任何给定任务的状况和截止日期。虽然这个应用组件可能看起来很明显,但尽早确定它可以帮助我们更好地理解用户将如何与我们的应用的各种组件进行交互。

数据库

在确定了我们应用的核心组件后,我们现在可以开始思考我们的数据库将是什么样子了。让我们从两个数据库表开始。

任务表

通过查看我们的需求,我们可以确定tasks表的几个列和数据类型。一般来说,我们创建的每个任务都将有一个与之关联的唯一递增 ID。我们可以快速识别的其他列包括任务名称、任务描述、截止日期以及任务是否已完成。我们还知道每个任务都将与一个项目相关联,这意味着我们需要在我们的表中引用该项目。

还有一些列我们可以识别,但并不那么明显。两个最有用的列没有明确标识的是任务的创建日期和最后更新日期的时间戳。通过添加这两个列,我们可以获得关于我们应用程序使用的有用见解。可能在未来,我们的假设客户可能想知道未解决的任务开放了多久,以及如果它已经几天没有更新,是否需要额外的关注。

在确定了表的列和数据类型后,我们用通用 SQL 数据类型编写的tasks表将如下所示:

ID INTEGER PRIMARY KEY
name TEXT
description TEXT
completed BOOLEAN
project_id INTEGER
due_date TIMESTAMP
created TIMESTAMP
updated TIMESTAMP

项目表

通过查看我们对项目的需求,我们可以轻松地挑选出projects表的主要列:一个描述性的名称,项目是否已完成,以及项目截止日期。我们还从tasks表中得知,每个项目都需要一个唯一的 ID 以便任务引用。当到了在应用中创建模型的时候,我们将明确定义任何给定项目与其所属的多个任务之间的多对一关系。如果我们保留创建和更新列,我们用通用 SQL 编写的projects表将如下所示:

ID INTEGER PRIMARY KEY
name TEXT
completed BOOLEAN
due_date TIMESTAMP
created TIMESTAMP
updated TIMESTAMP

小贴士

下载示例代码

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

用户

我们的应用需求还显示,我们需要在某个地方存储用户信息。对于这个应用,我们将把我们的用户存储在一个平面文件数据库中。在第三章“计划提醒”中,我们将进一步扩展并将在自己的数据库表中存储用户。

选择数据库技术

现在我们已经决定了数据库的外观,是时候开始考虑我们将把信息存储在哪里了。为了帮助您熟悉 Yii 原生支持的不同数据库适配器,对于这个项目,我们将使用 SQLite。既然我们现在知道了我们将存储数据的位置,我们可以确定数据库表的所有正确数据类型。

任务表

由于 SQLite 只支持五种基本数据类型(NULLINTEGERREALTEXTBLOB),我们需要将我们最初为该表识别的一些数据类型转换为 SQLite 支持的类型。由于 SQLite 不支持布尔值或时间戳,我们需要找到另一种方式使用 SQLite 支持的数据类型来表示这些数据。我们可以将布尔值表示为整数,要么是 0(false),要么是 1(true)。我们还可以通过将当前日期转换为 Unix 时间戳来将所有时间戳列表示为整数。

在确定了最终的数据类型后,我们的 tasks 表现在将看起来像这样:

ID INTEGER PRIMARY KEY
name TEXT
description TEXT
completed INTEGER
project_id INTEGER
due_date INTEGER
created INTEGER
updated INTEGER

项目表

通过将相同的逻辑应用到我们的 projects 表,我们可以推导出该表的以下结构:

ID INTEGER PRIMARY KEY
name TEXT
completed INTEGER
due_date INTEGER
created INTEGER
updated INTEGER

数据库概览

通过事先花几分钟思考我们的应用程序,我们已经成功识别了应用程序的所有表,它们如何相互交互,以及应用程序将使用的所有列名和数据类型。我们在不写一行代码的情况下已经对我们的应用程序做了很多工作。通过这项前期工作,我们还减少了一些在创建模型时需要做的后续工作。

初始化项目

在确定了最终的数据库结构后,我们现在可以开始编写代码。使用官方指南(www.yiiframework.com/doc/guide/)中的说明,下载并安装 Yii 框架。一旦安装了 Yii,导航到您的 webroot 目录,并创建一个名为 tasks 的新文件夹。接下来,导航到 tasks 文件夹内部,创建以下文件夹结构,作为我们应用程序的骨架:

tasks/
    assets/
    protected/
              commands/
              components/
              config/
              controllers/
              data/
              migrations/
              models/
              runtime/
              views/
                    layouts/
                    projects/
                    tasks/
                    site/

小贴士

Yii 拥有一个内置工具名为 yiic,它可以自动生成一个项目骨架。有关更多详细信息,请参阅快速入门指南(www.yiiframework.com/doc/guide/1.1/en/quickstart.first-app)。

根据您使用的 Web 服务器,您可能还需要在 tasks 文件夹的根目录中创建一个 .htaccess 文件。有关如何为您的 Web 服务器设置应用程序的信息,请参阅快速入门指南(www.yiiframework.com/doc/guide/1.1/en/quickstart.apache-nginx-config)。

在设置好骨架结构后,我们首先创建位于 protected/config/main.php 的配置文件。我们的配置文件是应用程序中最重要的文件之一,因为它为 Yii 提供了加载和配置应用程序所需的所有关键信息。配置文件通知 Yii 哪些文件将由 Yii 的内置自动加载器预加载,要加载的模块,要注册的组件,以及我们想要传递给应用程序的任何其他配置选项。

对于这个应用程序,我们将启用 Gii 模块,这将允许我们根据数据库结构创建模型。我们还将启用两个组件,urlManagerdb,这将允许我们设置自定义路由并访问我们的 SQLite 数据库。请看以下代码片段:

<?php
return array(
   'basePath'=>dirname(__FILE__).DIRECTORY_SEPARATOR.'..',
   'name'=>'Task Application',
   'import'=>array(
        'application.models.*',
        'application.components.*',
    ),
    'modules'=>array(
        // Include the Gii Module so that we can
//generate models and controllers for our application
        'gii'=>array(
            'class'=>'system.gii.GiiModule',
            'password'=>false,
            'ipFilters'=>false
        ),
    ),
    'components'=>array(
        'urlManager'=>array(
            'urlFormat'=>'path',
            'showScriptName'=>false,
            'rules'=>array(
                '<controller:\w+>/<id:\d+>'=>'<controller>/view',
                '<controller:\w+>/<action:\w+>/<id:\d+>'=>'<controller>/<action>',
                '<controller:\w+>/<action:\w+>'=>'<controller>/<action>',
            ),
        ),
        // Define where our SQLite database is going to be
        // stored, relative to our config file
        'db'=>array(
            'connectionString' => 'sqlite:'.dirname(__FILE__).'/../data/tasks.db',
        )
    )
);

接下来,我们可以创建我们的index.php文件,如下所示,这将成为我们 Web 应用程序的启动端点:

<?php
// change the following paths if necessary
$yii='/opt/frameworks/php/yii/framework/yii.php';
$config=dirname(__FILE__).'/protected/config/main.php';

// remove the following lines when in production mode
defined('YII_DEBUG') or define('YII_DEBUG',true);
// specify how many levels of call stack should be shown in each log message
defined('YII_TRACE_LEVEL') or define('YII_TRACE_LEVEL',3);

require_once($yii);
Yii::createWebApplication($config)->run();

最后,我们可以在protected/yiic.php中创建我们的应用程序yiic文件,如下所示,这将允许我们从应用程序中运行 Yii 的本地控制台命令:

<?php
// change the following paths if necessary
$config=dirname(__FILE__).'/config/main.php';
$config = require($config);
require_once('/opt/frameworks/php/yii/framework/yiic.php');

使用迁移创建数据库

现在我们已经能够启动应用程序,我们可以创建我们的数据库。为此,我们将创建一个迁移。迁移是 Yii 的一个特性,它允许数据库的创建和修改成为应用程序的一部分。我们不需要在纯 SQL 中创建模式修改,而可以使用迁移来将数据库的增长作为应用程序的一部分。除了作为数据库模式的修订系统外,迁移还有额外的优势,即允许我们与应用程序一起传输数据库,而无需担心共享数据库中存储的数据。

要创建我们的数据库,请打开您选择的命令行界面,导航到您的任务目录,并运行以下命令:

$ php protected/yiic.php migrate create tasks

yiic命令将提示您确认创建新的迁移:

Yii Migration Tool v1.0 (based on Yii v1.1.14)

Create new migration '/var/www/tasks/protected/migrations/m131213_013354_tasks.php'? (yes|no) [no]:yes
New migration created successfully.

小贴士

为了防止与迁移的命名冲突,yiic将使用以下命名结构创建迁移:m<timestamp>_<name>。这还有一个额外的优势,即允许我们根据它们添加的顺序顺序应用或删除特定的迁移。您的迁移的确切名称将与前面命令中列出的名称略有不同。

在确认创建迁移后,将在应用程序的protected/migrations文件夹中创建一个新文件。打开该文件,并在up方法中添加以下内容:

$this->createTable('tasks', array(
   'id' => 'INTEGER PRIMARY KEY',
   'title' => 'TEXT',
   'data' => 'TEXT',
   'project_id' => 'INTEGER',
   'completed' => 'INTEGER',
   'due_date' => 'INTEGER',
   'created' => 'INTEGER',
   'updated' => 'INTEGER'
));

$this->createTable('projects', array(
   'id' => 'INTEGER PRIMARY KEY',
   'name' => 'TEXT',
   'completed' => 'INTEGER',
   'due_date' => 'INTEGER',
   'created' => 'INTEGER',
   'updated' => 'INTEGER'
));

注意,我们的数据库结构与我们之前在章节中确定的模式相匹配。

接下来,用从yiic命令调用migrate down时删除数据库表的说明替换down方法的内容。请看以下代码:

$this->dropTable('projects');
$this->dropTable('tasks');

现在迁移已经创建,请在命令行中运行migrate up以创建数据库并应用迁移。运行以下命令:

$ php protected/yiic.php migrate up
Yii Migration Tool v1.0 (based on Yii v1.1.14)
Total 1 new migration to be applied:
 m131213_013354_tasks

Apply the above migration? (yes|no) [no]:yes
*** applying m131213_013354_tasks
*** applied m131213_013354_tasks (time: 0.009s)
Migrated up successfully.

现在,如果您导航到protected/data/,您将看到一个名为tasks.db的新文件,这是我们迁移创建的 SQLite 数据库。

小贴士

迁移命令可以通过在migrate命令后附加--interactive=0来非交互式地运行。如果您想自动化代码的远程系统部署,或者通过自动化测试服务运行代码,这可能很有用。

使用 Gii 创建模型

现在我们已经创建了数据库,我们可以为我们的数据库表创建模型。为了创建我们的模型,我们将使用 Gii,Yii 内置的代码生成器。

打开您的网络浏览器,导航到 http://localhost/gii(在这本书中,我们将始终使用 localhost 作为我们工作项目的操作主机名。如果您使用的是不同的主机名,请将 localhost 替换为您自己的)。一旦加载,您应该看到 Yii 代码生成器,如下面的截图所示:

使用 Gii 创建模型

小贴士

如果您无法访问 Gii,请确认您的 Web 服务器已启用重写功能。有关如何为 Yii 正确配置 Web 服务器的信息,可以在 www.yiiframework.com/doc/guide/1.1/en/quickstart.apache-nginx-config 找到。

点击标题为 模型生成器 的链接,然后在出现的页面上填写表单。表名应设置为 tasks。模型名称应预先填充。如果没有,将模型名称设置为 Tasks,然后点击预览。一旦页面重新加载,您可以在点击 生成 按钮将新模型写入您的 protected/models/ 目录之前预览模型的外观。一旦您为 tasks 生成了模型,重复此过程为 projects 创建模型。

增强模型

现在我们已经创建了模型,有几个部分应该进行修改。

更新默认验证规则

我们需要修改模型的第一部分是验证规则。在 Yii 中,验证规则存储在模型的 rules() 方法中,并在调用模型的 validate() 方法时执行。从我们的 tasks 模型开始,我们可以看到 Gii 已经根据我们的数据库预先填充了我们的验证规则。

我们希望这个模型的一些字段始终被设置,特别是 project_idtitle、任务本身以及它是否已完成。我们可以通过在我们的规则部分添加一个新的数组来使这些字段在模型中成为必需的,如下所示:

array('project_id, title, data, completed', 'required')

通过使这些字段在模型中成为必需的,当我们开始制作表单时,客户端和服务器端的验证将变得更加容易。我们为这个模型提供的最终方法如下:

public function rules()
{
        return array(
            array('project_id, completed, due_date, created, updated', 'numerical', 'integerOnly'=>true),
		   array('project_id, title, data, completed', 'required'),
            array('title, data', 'safe'),
            array('id, title, data, project_id, completed, due_date, created, updated', 'safe', 'on'=>'search'),
        );
}

我们的项目模型也应进行更改,以便项目名称及其完成状态是必需的。我们可以通过向我们的验证规则数组中添加以下内容来实现这一点:

array('name, completed', 'required')

小贴士

在 Yii wiki www.yiiframework.com/wiki/56/ 可以找到额外的验证规则。

定义关系

我们应该更改的模型另一个组件是 relations() 方法。通过在 Yii 中声明模型关系,我们可以利用 ActiveRecords 自动将多个相关模型连接起来并从中检索数据的能力,而无需显式调用该模型的数据。

例如,一旦我们设置了模型关系,我们就能从 Tasks 模型中检索项目名称,如下所示:

Tasks::model()->findByPk($id)->project->name;

在我们声明关系之前,我们需要确定关系实际上是什么。由于 SQLite 不支持外键关系,Gii 无法自动为我们确定关系。

在 Yii 中,有四种类型的关系:BELONGS_TOHAS_MANYHAS_ONEMANY_MANY。确定关系类型可以通过查看表的外键并根据表将存储的数据选择最适合的关系类型来完成。对于这个应用程序,这个问题可以这样回答:

  • 任务属于单一项目

  • 一个项目有一个或多个任务

现在我们已经确定了两个表之间的关系类型,我们可以编写关系。从 tasks 表开始,将 relations() 方法替换为以下内容:

public function relations()
{
return array(
        'tasks' => array(self::HAS_MANY, 'Task', 'project_id')
    );
}

关系数组的语法如下所示:

'var_name'=>array('relationship_type', 'foreign_model', 'foreign_key', [... other options ..])

对于我们的项目模型,我们的 relations() 方法如下所示:

public function relations()
{
    return array(
        'tasks' => array(self::HAS_MANY, 'Tasks', 'project_id')
    );
}

当项目被删除时移除任务

在我们模型当前的状态下,每当一个项目被删除时,与之关联的所有任务都会变成孤儿。处理这种边缘情况的一种方法就是简单地删除与项目关联的所有任务。而不是在控制器中编写代码来处理这个问题,我们可以通过引用项目模型中的 beforeDelete() 方法来让模型为我们处理,如下所示:

public function beforeDelete()
{
    Tasks::model()->deleteAllByAttributes(array('project_id' => $this->id));
    return parent::beforeDelete();
}

检索项目元数据

关于项目,还有一些我们无法直接从 projects 数据库表中获取的元数据。这些数据包括项目拥有的任务数量,以及项目完成的任务数量。我们可以通过在项目的模型中创建两个新方法来获取这些数据,如下所示:

public function getNumberOfTasks()
{
    return Tasks::model()->countByAttributes(array('project_id' => $this->id));
}

public function getNumberOfCompletedTasks()
{
     return Tasks::model()->countByAttributes(array('project_id' => $this->id, 'completed' => 1));
}

此外,我们可以通过获取已完成任务数与总任务数的百分比来确定项目的进度,如下所示:

public function getPercentComplete()
{
    $numberOfTasks = $this->getNumberOfTasks();
    $numberOfCompletedTasks = $this->getNumberOfCompletedTasks();

    if ($numberOfTasks == 0)
        return 100;
    return ($numberOfCompletedTasks / $numberOfTasks) * 100;
}

自动设置创建和更新时间

需要对模型进行的最后更改是使它们能够在每次模型保存时自动在数据库中设置创建和更新时间戳。通过将此逻辑移动到模型中,我们可以避免在提交数据的表单或处理此数据的控制器中管理它。此更改可以通过向两个模型添加以下内容来实现:

public function beforeSave()
{
    if ($this->isNewRecord)
         $this->created = time();

    $this->updated = time();

    return parent::beforeSave();
}

beforeSave() 方法中,每次模型被保存时,更新属性总是被设置,而创建属性只有在 ActiveRecord 认为这是一个新记录时才会被设置。这是通过检查模型的 isNewRecord 属性来实现的。此外,这两个属性都被设置为 time(),这是 PHP 中用于获取当前 Unix 时间戳的函数。

在这个方法中重要的最后一行代码是 return parent::beforeSave();。当调用 Yii 的 save() 方法时,它会检查 beforeSave() 是否返回 true,然后再将数据保存到数据库中。虽然我们可以让这个方法返回 true,但让它返回父模型(在这个案例中是 CActiveRecord)返回的值更简单。这也确保了任何对父模型所做的更改都会传递到模型中。

小贴士

由于 beforeSave() 方法对两个模型都是相同的,我们也可以创建一个新的模型,该模型只扩展 CActiveRecord 并只实现此方法。然后,任务和项目模型将扩展该模型而不是 CActiveRecord,并将继承此功能。将共享功能移动到共享位置减少了需要编写代码的地方数量,从而减少了错误出现的地方数量。

创建表现层

到目前为止,所编写的所有代码都是后端代码,最终用户将无法看到。在本节中,我们将创建我们应用程序的表现层。我们应用程序的表现层由三个组件组成:控制器、布局和视图。在下一节中,我们将创建所有三个组件。

作为开发者,我们有几种创建表现层的方法。我们可以创建表现层的一种方法是通过 Gii。Gii 有几个内置工具可以帮助你创建新的控制器、视图的表单,甚至为我们应用程序创建完整的创建、读取、更新和删除(CRUD)框架。或者,我们也可以手动编写一切。

管理项目

我们将要工作的表现层的第一部分是项目部分。首先,在 protected/controllers/ 中创建一个名为 ProjectControllerProjectController.php 的新文件,该文件具有以下类签名:

<?php
class ProjectControllerProjectController extends CController {}

对于我们的控制器,我们将扩展 Yii 的基类 CController。在未来的章节中,我们将创建自己的控制器并从它们扩展。

在我们可以开始显示新动作的内容之前,我们需要为我们的内容创建一个布局。为了指定我们的布局,创建一个公共属性 $layout,并将其值设置为 'main'

public $layout = 'main';

接下来,让我们创建我们的第一个动作以确保一切正常工作:

public function actionIndex()
{
    echo "Hello!";
}

现在,我们应该能够从我们的网络浏览器访问 http://localhost/projects/index 并在屏幕上看到打印的文本 Hello。在我们继续定义我们的动作之前,让我们创建一个布局,以帮助我们的应用程序看起来更好。

创建布局

我们指定的布局引用了位于 protected/views/layouts/main.php 的文件。创建此文件并打开它进行编辑。然后,添加以下基本的 HTML5 标记:

<!DOCTYPE html>
<html>
   <head>
   	</head>
   <body>
   </body>
</html>

然后在 <head> 标签内添加一个标题,该标题将显示我们在 protected/config/main.php 中定义的应用程序名称:

<title><?php echo Yii::app()->name; ?></title>

接下来,让我们添加一些元标签、CSS 和脚本。为了减少需要下载的文件数量,我们将从公开可用的内容分发网络CDN)中包含样式和脚本。而不是为这些元素编写标记,我们将使用CClientScript,这是一个用于管理视图中的 JavaScript、CSS 和元标签的类。

对于这个应用程序,我们将使用一个名为Twitter Bootstrap的前端框架。这个框架将为我们的应用程序使用的大多数常见 HTML 标签提供样式,使其整体看起来更干净。

提示

当你准备好将应用程序上线时,你应该考虑将你使用的静态资源移动到 CDN 上,从公开可用的 CDN 引用流行的库,如 Twitter Bootstrap 和 jQuery。CDN 可以通过减少服务器需要用于发送文件所需的带宽来帮助降低托管成本。使用 CDN 还可以加快你的网站速度,因为它们通常比你的主服务器地理位置更靠近你的用户。

首先,我们将调用CClientScript,如下所示:

<?php $cs = Yii::app()->clientScript; ?>

其次,我们将Content-Type设置为text/html,字符集为UTF-8,如下所示:

<?php $cs->registerMetaTag('text/html; charset=UTF-8', 'Content-Type'); ?>

接下来,我们将从流行的 CDN 注册 Twitter Bootstrap 3 的 CSS,如下所示:

<?php $cs->registerCssFile( '//netdna.bootstrapcdn.com/bootstrap/3.0.3/css/bootstrap.min.css' ); ?>

然后我们将注册 Twitter Bootstrap 的 JavaScript 库:

<?php $cs->registerScriptFile( '//netdna.bootstrapcdn.com/bootstrap/3.0.3/js/bootstrap.min.js' ); ?>

最后,我们将注册 jQuery 2.0 并将 Yii 放置在<body>标签的末尾,如下所示:

<?php $cs->registerScriptFile( '//code.jquery.com/jquery.js', CClientScript::POS_END ); ?>

CClientScript也支持方法链,因此你也可以将前面的代码更改为以下内容:

<?php Yii::app()->clientScript
        	->registerMetaTag('text/html; charset=UTF-8', 'Content-Type')
        	->registerCssFile( '//netdna.bootstrapcdn.com/bootstrap/3.0.3/css/bootstrap.min.css'
        	->registerScriptFile( '//netdna.bootstrapcdn.com/bootstrap/3.0.3/js/bootstrap.min.js' )
        	->registerScriptFile( 'https://code.jquery.com/jquery.js' , CClientScript::POS_END); ?>

对于布局的最后部分,让我们在<body>标签内添加一个基本的标题,这将有助于导航,如下所示:

<div class="row">
    <div class="container">
        <nav class="navbar navbar-default navbar-fixed-top" role="navigation">
            <div class="navbar-header">
                <a class="navbar-brand" href="/"><?php echo CHtml::encode(Yii::app()->name); ?></a>
             </div>
        </nav>
    </div>
   </div>

</div>标签关闭后,添加以下内容:

<div class="row" style="margin-top: 100px;">
    <div class="container">
        <?php echo $content; ?>
    </div>
</div>

我们添加到布局中的$content变量是一个特殊变量,它包含来自我们视图文件的所有渲染的 HTML 标记,并由CController类在render()方法中定义。每当我们在控制器内部调用render()方法时,Yii 都会自动为我们填充这个变量。

创建项目索引动作

我们的布局定义完成后,我们可以回到创建动作。让我们首先修改我们的actionIndex()方法,使其渲染一个视图。

首先,创建一个变量来存储我们模型的可搜索副本。看看以下代码:

$model = new Projects('search');

接下来,渲染一个名为index的视图,它引用protected/views/projects/index.php,并将我们创建的模型传递给此视图,如下所示:

$this->render('index', array('model' => $model));

现在,在protected/views/projects/index.php中创建视图文件并打开它进行编辑。首先,在视图中添加一个按钮,如下所示,它将引用我们稍后创建的save动作:

<?php echo CHtml::link('Create New Project', $this->createUrl('/projects/save'), array('class' => 'btn btn-primary pull-right')); ?>
<div class="clearfix"></div>

然后添加一个描述性的标题,这样我们就能知道我们在哪个页面。看看以下代码行:

<h1>Projects</h1>

最后,创建一个新的小部件,它使用CListView,这是一个用于显示CActiveDataProvider数据的内置 Yii 小部件。在 Yii 中,小部件是前端组件,帮助我们快速生成常用代码,通常用于展示目的。这个小部件将根据需要自动生成分页,并允许我们的每个项目看起来都一样。请看以下代码:

<?php $this->widget('zii.widgets.CListView', array(
    'dataProvider'=>$model->search(),
    'itemView'=>'_project',
)); ?>

我们创建的新小部件由两部分组成。第一部分是dataProvider,它为小部件提供数据。这些数据来自我们项目模型的search()方法,这是 Gii 自动生成的一段代码。

小部件的第二部分是itemView,它引用了我们的项目将被渲染的具体视图文件。在这种情况下,视图引用了protected/views/projects目录中的同一文件名为_project.php的文件。创建此文件,然后向其中添加以下代码:

<div>
    <div class="pull-left">
        <p><strong><?php echo CHtml::link(CHtml::encode($data->name), $this->createUrl('/projects/tasks', array('id' => $data->id))); ?></strong></p>
        <p>Due on <?php echo date('m/d/Y', $data->due_date); ?></p>
<?php if ($data->completed): ?>
            Completed
        <?php else: ?>
            <?php if ($data->numberOfTasks == 0): ?>
                <p>No Tasks</p>
            <?php else: ?>
                <p><?php echo $data->getPercentComplete(); ?>% Completed</p>
            <?php endif; ?>
        <?php endif; ?>
    </div>
    <div class="pull-right">
        <?php echo CHtml::link(NULL, $this->createUrl('/projects/save', array('id' => $data->id)), array('title' => 'edit', 'class' => 'glyphicon glyphicon-pencil')); ?>
        <?php echo CHtml::link(NULL, $this->createUrl('/projects/complete', array('id' => $data->id)), array('title' => $data->completed == 1 ? 'uncomplete' : 'complete', 'class' => 'glyphicon glyphicon-check')); ?>
        <?php echo CHtml::link(NULL, $this->createUrl('/projects/delete', array('id' => $data->id)), array('title' => 'delete', 'class' => 'glyphicon glyphicon-remove')); ?>
    </div>
    <div class="clearfix"></div>
</div>
<hr/>

如果我们现在刷新浏览器页面,我们的视图将显示没有找到结果。在我们能够看到数据之前,我们需要创建一个动作和视图来创建和更新它。在我们开始创建新记录之前,让我们创建两个其他动作,这些动作在我们项目的视图中已经概述过:完成和删除。

更改项目的完成状态

首先,让我们创建一个动作来标记项目为完成或未完成。这个动作将只负责将项目表中的完成字段更改为 0 或 1,具体取决于其当前状态。为了简单起见,我们可以通过异或 1 来更改字段并保存模型。请看以下代码:

public function actionComplete($id)
{
    $model = $this->loadModel($id);
    $model->completed ^= 1;
    $model->save();
    $this->redirect($this->createUrl('/projects'));
}

此外,我们还将创建另一个名为loadModel()的私有方法,它将为我们加载适当的模型,如果找不到模型,将抛出一个错误。对于这个方法,我们将使用CHttpException,如果找不到具有指定 ID 的模型,它将创建一个带有我们提供的错误消息的 HTTP 异常。请看以下代码:

private function loadModel($id)
{
    $model = Projects::model()->findByPk($id);
    if ($model == NULL)
        throw new CHttpException('404', 'No model with that ID could be found.');
    return $model;
}

删除项目

接下来,我们将创建一个删除项目的函数。这个函数将使用我们之前定义的loadModel()方法。另外,如果在删除模型时遇到错误,我们将抛出一个 HTTP 异常,以便用户知道出了些问题。以下是我们的操作步骤:

public function actionDelete($id)
{
    $model = $this->loadModel($id);

    if ($model->delete())
        $this->redirect($this->createUrl('/projects'));

    throw new CHttpException('500', 'There was an error deleting the model.');
}

创建和更新项目

定义了这两个其他方法之后,我们现在可以开始创建和更新项目。我们不会创建两个动作来处理这两个任务,而是创建一个动作,这个动作知道如何通过检查我们作为GET参数传递的 ID 来处理这两个任务。我们可以通过定义一个新的动作来实现这一点,如下所示:

public function actionSave($id=NULL) {

然后,我们可以根据用户是否提供了 ID 来创建一个新的项目或更新一个项目。通过利用loadModel(),我们还处理了如果提供了 ID 但该项目不存在时可能发生的任何错误。请看以下代码:

if ($id == NULL)
    $model = new Projects;
else
    $model = $this->loadModel($id);

接下来,我们可以通过检查 $_POST 变量中是否存在名为 Projects 的数组来检测用户是否提交了数据。如果该数组已定义,我们将将其分配给我们的 $model->attributes 对象。然而,在保存模型之前,我们希望将用户输入的内容转换为 Unix 时间戳。请看以下代码:

if (isset($_POST['Projects']))
{
    $model->attributes = $_POST['Projects'];
    $model->due_date = strtotime($_POST['Projects']['due_date']);
    $model->save();
}

最后,我们将渲染视图并将模型传递给它,如下所示:

$this->render('save', array('model' => $model));

protected/views/projects/ 中创建一个名为 save.php 的新文件并打开它进行编辑。首先添加一个标题,让我们知道我们是在编辑项目还是创建一个新的项目,如下所示:

<h1><?php echo $model->isNewRecord ? 'Create New' : 'Update'; ?> Project</h1>

接下来,我们将使用 CActiveForm 创建一个新的小部件,该小部件将负责在视图文件中创建和插入表单字段(例如,表单字段的名称和 ID)的困难任务:

<?php $form=$this->beginWidget('CActiveForm', array(
    'id'=>'project-form',
    'htmlOptions' => array(
        'class' => 'form-horizontal',
        'role' => 'form'
    )
)); ?>
<?php $this->endWidget(); ?>

beginWidgetendWidget 调用之间,如果用户遇到错误,请添加一个错误摘要:

<?php echo $form->errorSummary($model); ?>

然后,在错误摘要之后,添加表单字段及其相关样式,如下所示:

<div class="form-group">
    <?php echo $form->labelEx($model,'name', array('class' => 'col-sm-2 control-label')); ?>
    <div class="col-sm-10">
        <?php echo $form->textField($model,'name', array('class' => 'form-control')); ?>
    </div>
</div>

<div class="form-group">
    <?php echo $form->labelEx($model,'completed', array('class' => 'col-sm-2 control-label')); ?>
    <div class="col-sm-10">
        <?php echo $form->dropDownList($model,'completed', array('0' => 'No','1' => 'Yes'), array('class' => 'form-control')); ?>
    </div>
</div>

<div class="form-group">
    <?php echo $form->labelEx($model,'due_date', array('class' => 'col-sm-2 control-label')); ?>
    <div class="col-sm-10">
        <div class="input-append date">
MM/DD/YYYY
            <?php $this->widget('zii.widgets.jui.CJuiDatePicker', array(
                    'model' => $model,
                    'attribute' => 'due_date',
                    'htmlOptions' => array(
                        'size' => '10',
                        'maxlength' => '10',
                        'class' => 'form-control',
                        'value' => $model->due_date == "" ? "" : date("m/d/Y", $model->due_date)
                    ),
                )); ?>		
</div>
    </div>
</div>

<div class="row buttons">
    <?php echo CHtml::submitButton($model->isNewRecord ? 'Create' : 'Save', array('class' => 'btn btn-primary pull-right')); ?>
</div>

注意

你注意到我们是如何利用 Yii 小部件 CJuiDatePicker 吗?这个小部件将为我们提供一个干净的界面,用于从日历视图中选择日期,而不是要求我们的最终用户手动输入日期并按照我们请求的格式输入。

现在,我们可以创建、更新、查看和删除项目。此外,我们还创建了一个简单的操作来标记它们为完成。在我们完成这个控制器之前,我们需要添加一个允许我们查看项目中任务的操作。

查看任务

此控制器的 tasks 操作将与我们的 index 操作以相同的方式工作,但将使用名为 tasks 的视图:

public function actionTasks($id=NULL)
{
    if ($id == NULL)
        throw new CHttpException(400, 'Missing ID');

    $project = $this->loadModel($id);
    if ($project === NULL)
        throw new CHttpException(400, 'No project with that ID exists');

    $model = new Tasks('search');
    $model->attributes = array('project_id' => $id);

    $this->render('tasks', array('model' => $model, 'project' => $project));
}

protected/views/projects/tasks.php 中的 tasks.php 视图将如下所示:

<?php echo CHtml::link('Create New Task', $this->createUrl('/tasks/save?Tasks[project_id]=' . $project->id), array('class' => 'btn btn-primary pull-right')); ?>
<div class="clearfix"></div>
<h1>View Tasks for Project: <?php echo $project->name; ?></h1>
<?php $this->widget('zii.widgets.CListView', array(
    'dataProvider'=>$model->search(),
    'itemView'=>'_tasks',
));
?>

protected/views/projects/tasks.php 中的 _tasks.php 项目视图将如下所示:

<div>
    <div class="pull-left">
        <p><strong><?php echo CHtml::link(CHtml::encode($data->title), $this->createUrl('/tasks/save', array('id' => $data->id))); ?></strong></p>
        <p>Due on <?php echo date('m/d/Y', $data->due_date); ?></p>
    </div>
    <div class="pull-right">
        <?php echo CHtml::link(NULL, $this->createUrl('/tasks/save', array('id' => $data->id)), array('class' => 'glyphicon glyphicon-pencil')); ?>
        <?php echo CHtml::link(NULL, $this->createUrl('/tasks/complete', array('id' => $data->id)), array('title' => $data->completed == 1 ? 'uncomplete' : 'complete', 'class' => 'glyphicon glyphicon-check')); ?>
        <?php echo CHtml::link(NULL, $this->createUrl('/tasks/delete', array('id' => $data->id)), array('class' => 'glyphicon glyphicon-remove')); ?>
    </div>
    <div class="clearfix"></div>
</div>
<hr/>

管理任务

现在我们能够管理项目了,让我们来管理任务。我们的 TasksController 将几乎与项目控制器相同,只有一些差异。首先,在 protected/controllers 中创建一个名为 TasksController.php 的新文件,其签名如下:

<?php class TasksController extends CController {}

通过对我们的 loadModel() 方法进行小小的修改,我们可以重用项目控制器中的删除和完成操作,如下所示:

private function loadModel($id)
{
    $model = Tasks::model()->findByPk($id);
    if ($model == NULL)
        throw new CHttpException('404', 'No model with that ID could be found.');
    return $model;
}

我们的 save 操作几乎与项目的 save 操作相同。请看以下代码:

public function actionSave($id=NULL)
{
    if ($id == NULL)
        $model = new Tasks;
    else
        $model = $this->loadModel($id);

    if (isset($_GET['Tasks']))
        $model->attributes = $_GET['Tasks'];

    if (isset($_POST['Tasks']))
    {
        $model->attributes = $_POST['Tasks'];
        $model->due_date = strtotime($_POST['Tasks']['due_date']);
        $model->save();
    }

    $this->render('save', array('model' => $model));
}

此操作的视图文件几乎与之前相同。如果您还没有创建,请在 protected/views/tasks/ 中创建一个名为 save.php 的文件,然后添加以下代码行以完成视图:

<ol class="breadcrumb">
  <li><?php echo CHtml::link('Project', $this->createUrl('/projects')); ?></li>
  <li class="active"><?php echo $model->isNewRecord ? 'Create New' : 'Update'; ?> Task</li>
</ol>
<hr />
<h1><?php echo $model->isNewRecord ? 'Create New' : 'Update'; ?> Task</h1>
<?php $form=$this->beginWidget('CActiveForm', array(
    'id'=>'project-form',
    'htmlOptions' => array(
        'class' => 'form-horizontal',
        'role' => 'form'
    )
)); ?>
    <?php echo $form->errorSummary($model); ?>

    <div class="form-group">
        <?php echo $form->labelEx($model,'title', array('class' => 'col-sm-2 control-label')); ?>
        <div class="col-sm-10">
            <?php echo $form->textField($model,'title', array('class' => 'form-control')); ?>
        </div>
    </div>

    <div class="form-group">
        <?php echo $form->labelEx($model,'data', array('class' => 'col-sm-2 control-label')); ?>
        <div class="col-sm-10">
            <?php echo $form->textArea($model,'data', array('class' => 'form-control')); ?>
        </div>
    </div>

    <div class="form-group">
        <?php echo $form->labelEx($model,'project_id', array('class' => 'col-sm-2 control-label')); ?>
        <div class="col-sm-10">
            <?php echo $form->dropDownList($model,'project_id', CHtml::listData(Projects::model()->findAll(), 'id', 'name'), array('empty'=>'Select Project', 'class' => 'form-control')); ?>
        </div>
    </div>

    <div class="form-group">
        <?php echo $form->labelEx($model,'completed', array('class' => 'col-sm-2 control-label')); ?>
        <div class="col-sm-10">
            <?php echo $form->dropDownList($model,'completed', array('0' => 'No','1' => 'Yes'), array('class' => 'form-control')); ?>
        </div>
    </div>

    <div class="form-group">
        <?php echo $form->labelEx($model,'due_date', array('class' => 'col-sm-2 control-label')); ?>
        <div class="col-sm-10">
            <div class="input-append date">
                <?php $this->widget('zii.widgets.jui.CJuiDatePicker', array(
                    'model' => $model,
                    'attribute' => 'due_date',
                    'htmlOptions' => array(
                       'size' => '10',
                       'maxlength' => '10',
                        'class' => 'form-control',
                       'value' => $model->due_date == "" ? "" : date("m/d/Y", $model->due_date)
                    ),
                )); ?>			</div>
        </div>
    </div>

    <div class="row buttons">
        <?php echo CHtml::submitButton($model->isNewRecord ? 'Create' : 'Save', array('class' => 'btn btn-primary pull-right')); ?>
    </div>

<?php $this->endWidget(); ?>

防止未经授权访问我们的应用程序

我们的任务应用程序现在可以完成我们在需求中定义的所有内容。然而,它是面向世界的。任何想要编辑我们的任务的人都可以简单地访问我们的网站并随意更改,而不需要我们的知识。在完成之前,让我们创建一个简单的身份验证系统来保护我们的数据。

使用过滤器和访问规则要求认证

保护我们的应用程序的第一步是确保只有授权的人可以访问我们的应用程序。我们可以通过向我们的控制器添加一个名为accessControl的过滤器并定义访问规则来访问我们的内容来实现这一点。

过滤器是一段在控制器动作运行之前(和/或之后)执行的代码,这意味着用户在访问我们的内容之前必须进行认证。要添加accessControl过滤器,请将以下内容添加到TasksControllerProjectsController中:

public function filters()
{
    return array(
        'accessControl',
    );
}

接下来,创建一个名为accessRules()的新方法,该方法将定义用户可以访问我们的应用程序的内容。对于我们的应用程序,我们希望拒绝未认证的任何人的访问。看看以下代码片段:

public function accessRules()
{
    return array(
        array('allow',
            'users'=>array('@'),
        ),
        array('deny',  // deny all users
            'users'=>array('*'),
         ),
    );
}

在前面的数组中,@是对认证用户的简写引用。现在如果我们尝试访问我们的网页,我们将被重定向到/site/login,这是 Yii 的默认login动作。

为认证创建控制器

protected/controllers中创建一个名为SiteController.php的文件,然后创建loginlogout动作,如下所示:

<?php
class SiteController extends CController
{
    public $layout = 'signin';

    public function actionLogin()
    {
        $model = new LoginForm;

        if (isset($_POST['LoginForm']))
        {
            $model->attributes = $_POST['LoginForm'];
            if ($model->login())
                $this->redirect($this->createUrl('/projects'));
        }
        $this->render('login', array('model' => $model));
    }

    public function actionLogout()
    {
        Yii::app()->user->logout();
        $this->redirect($this->createUrl('/site/login'));
    }
}

创建登录布局

对于这个控制器,我们将在protected/views/layouts中创建一个新的布局,名为login.php。将protected/views/layouts/main.php中的标记复制到我们的新布局中,并用以下内容替换<body>标签的内容:

<div class="row">
    <div class="container">
        <?php echo $content; ?>
    </div>
</div>

为了使我们的登录页面看起来更像一个登录页面,请将以下 CSS 添加到布局中,无论是作为内联样式还是作为/css/signup.css中的单独文件:

body {
  padding-top: 40px;
  padding-bottom: 40px;
  background-color: #eee;
}

.form-signin {
  max-width: 330px;
  padding: 15px;
  margin: 0 auto;
}
.form-signin .form-signin-heading,
.form-signin .checkbox {
  margin-bottom: 10px;
}
.form-signin .checkbox {
  font-weight: normal;
}
.form-signin .form-control {
  position: relative;
  font-size: 16px;
  height: auto;
  padding: 10px;
  -webkit-box-sizing: border-box;
     -moz-box-sizing: border-box;
          box-sizing: border-box;
}
.form-signin .form-control:focus {
  z-index: 2;
}
.form-signin input[type="text"] {
  margin-bottom: -1px;
  border-bottom-left-radius: 0;
  border-bottom-right-radius: 0;
}
.form-signin input[type="password"] {
  margin-bottom: 10px;
  border-top-left-radius: 0;
  border-top-right-radius: 0;
}

创建登录视图

protected/views/site/login.php中创建一个新的表单,该表单将包含我们的登录模型,如下所示:

<?php $form=$this->beginWidget('CActiveForm', array(
    'id'=>'login-form',
    'enableClientValidation'=>true,
    'htmlOptions' => array(
            'class' => 'form-signin',
            'role' => 'form'
    ),
    'clientOptions'=>array(
        'validateOnSubmit'=>true,
    ),
)); ?>

    <?php if (!Yii::app()->user->isGuest): ?>
        <h2 class="form-signin-heading">You are already signed in! Please <?php echo CHtml::link('logout', $this->createUrl('/site/logout')); ?> first.</h2>
    <?php else: ?>
        <h2 class="form-signin-heading">Please sign in</h2>
        <?php echo $form->errorSummary($model); ?>
        <?php echo $form->textField($model,'username', array('class' => 'form-control', 'placeholder' => 'Username')); ?>
        <?php echo $form->passwordField($model,'password', array('class' => 'form-control', 'placeholder' => 'Password')); ?>
        <?php echo CHtml::tag('button', array('class' => 'btn btn-lg btn-primary btn-block'), 'Submit'); ?>
    <?php endif; ?>
<?php $this->endWidget(); ?>

使用 UserIdentity CUserIdentity 类识别我们的用户

在我们创建登录模型之前,我们需要创建一种识别我们用户的方法。幸运的是,Yii 有一个内置的类来处理这个任务,称为CUserIdentity。通过简单地扩展CUserIdentity,我们可以创建一个键值登录对,这将确保只有经过认证的用户才能登录到我们的应用程序。

/components中创建一个名为UserIdentity.php的新文件,并添加以下内容:

<?php
class UserIdentity extends CUserIdentity
{
    public function authenticate()
    {
        $users=array(
            'demo'=>'demo',
            'admin'=>'admin',
        );
        if(!isset($users[$this->username]))
            $this->errorCode=self::ERROR_USERNAME_INVALID;
        elseif($users[$this->username]!==$this->password)
            $this->errorCode=self::ERROR_PASSWORD_INVALID;
        else
            $this->errorCode=self::ERROR_NONE;
        return !$this->errorCode;
    }
}

UserIdentityauthenticate()方法是我们在登录模型中使用的,以确保我们有有效的凭据。在这个类中,我们只是检查将被我们的登录模型发送到这个类的username是否与它关联的键匹配。如果用户的密码与我们的$users数组中的键不匹配,或者如果用户未定义在我们的$users数组中,我们返回一个错误代码。

创建登录模型

我们需要创建的最后组件是创建一个通用的模型来验证用户。首先,在protected/models中创建一个名为LoginForm.php的新文件。这个类将扩展 Yii 中用于表单的通用模型CFormModel,如下所示:

<?php class LoginForm extends CFormModel {

由于 CFormModel 不连接到数据库,我们定义属性为公共属性,如下所示:

public $username;
public $password;
private $_identity;

我们的模式还需要验证规则来验证我们有一个有效的用户。除了确保提供了 usernamepassword,我们还将提供一个额外的验证规则,称为 authenticate,它将验证我们有一个有效的用户名和密码。请看以下代码行:

public function rules()
{
    return array(
        array('username, password', 'required'),
        array('password', 'authenticate'),
    );
}

因为我们的 authenticate() 方法是一个自定义验证器,它的方法签名有两个参数,$attribute$params,它们包含有关属性和可能从验证器传递过来的参数的信息。此方法将确定我们的凭证是否有效。请看以下代码:

public function authenticate($attribute,$params)
{
    if(!$this->hasErrors())
    {
        $this->_identity=new UserIdentity($this->username,$this->password);
        if(!$this->_identity->authenticate())
            $this->addError('password','Incorrect username or password.');
    }
}

最后,我们将创建 login() 方法,这是我们的 SiteController 调用的。除了验证我们的凭证外,它还将为用户创建会话的重任。请看以下代码:

public function login()
{
    if (!$this->validate())
        return false;

    if ($this->_identity===null)
    {
        $this->_identity=new UserIdentity($this->username,$this->password);
        $this->_identity->authenticate();
    }

    if ($this->_identity->errorCode===UserIdentity::ERROR_NONE)
    {
        $duration = 3600*24*30;
        Yii::app()->user->login($this->_identity,$duration);
        return true;
    }
    else
        return false;
}

现在,您可以使用我们 UserIdentity.php 文件中提供的凭证访问我们的网站并登录。

完成细节

在完成我们的项目之前,我们需要在我们的 protected/config/main.php 文件中处理一些事情,以增强我们应用程序的安全性并使我们的应用程序更容易使用。

如果能添加一些最终应用程序的图片那就更好了。

禁用 Gii

在我们项目的开始阶段,我们启用了 Gii 模块来帮助我们创建应用程序的模型。由于 Gii 有能力将新文件写入我们的项目,我们应该从我们的 config 文件中删除以下部分:

'gii'=>array(
    'class'=>'system.gii.GiiModule',
    'password'=>false,
    'ipFilters' => false
),

定义默认路由

目前,如果我们尝试访问我们应用程序的根 URL,我们会看到一个错误。为了避免这种情况,我们可以在我们 URL 管理组件的路由数组中添加一个路由。有了这个添加,每次我们访问我们应用程序的根 URL 时,我们都会看到项目的控制器中的 index 动作。请看以下代码:

'components'=>array(
    [...]
    'urlManager'=>array(
        [...]
        'rules'=>array(
            [...]
            '/' => 'projects/index'
        ),
    )
)

添加额外路由

最后,将两个额外的路由添加到我们的 URL 管理器路由数组中。这些路由将帮助我们更容易地访问我们网站的 loginlogout 动作。请看以下代码:

'login' => 'site/login',
'logout' => 'site/logout'

摘要

在本章中,我们涵盖了大量的信息。我们创建了一种自动创建和分发我们数据库、表示数据库表中表的模式以及一些用于管理和交互数据的控制器的方法。我们还创建了一个简单的键值认证系统来保护我们的数据。我们在本章中使用的大多数方法和我们编写的代码都可以在后面的章节中重用和扩展。在继续之前,请务必查看我们在章节中引用的所有类,在官方 Yii 文档中,以便更好地理解它们。

第二章。探索附近有什么

在开发应用时,我们经常遇到特定兴趣点的地理位置数据。无论是商业位置还是最终用户申请的工作,了解该特定位置周围的情况可以在用户做出关于该位置的决定时提供即时价值。例如,用户可能想知道特定位置附近的餐馆或公共设施或公共交通选项。借助第三方位置 API,我们可以告知用户给定兴趣点附近的情况。对于我们的第二个应用,我们开发了一个 Web 应用,使用 Google Places API 的信息向用户展示特定兴趣点附近的情况。在本章中,我们还介绍了如何将第三方库集成到我们的应用中以及如何通过缓存来提高我们应用的性能。

描述项目

正如我们在第一章中概述的任务应用一样,即任务管理应用,我们首先通过获取项目将做什么以及我们的应用将如何表现的高级概述来开始开发。

搜索附近位置

该应用的核心组件是其查找现有位置附近其他位置的能力。获取此类信息的最简单方法是利用第三方 API。对于此应用,我们将使用 Google Places API,这是一个可以提供给定纬度和经度坐标附近位置的 Web API。

展示位置

而不是仅仅告诉用户给定兴趣点附近的位置,我们可以通过在地图上展示兴趣点和附近位置来增强用户体验。存在许多不同的地图源可以显示地图。对于此应用,我们将利用另一个 Google API,即 Google Maps API。

存储位置

为了向用户展示他们可以搜索的可用位置,我们首先需要存储这些位置。为了存储这些位置,我们需要一个可以存储导入位置的数据库。就像我们在第一章中开发的任务应用一样,我们将再次使用 SQLite 作为我们的主要数据库。

导入位置

最后,我们需要一个命令行工具从数据源导入位置。为了完成这项任务,我们将创建一个可以从命令行运行的控制台任务。此任务将从提供的 JSON 源获取信息并将其导入我们的数据库。通过将其作为命令行任务,我们可以通过 Windows 上的计划任务或 Unix 的 crontab 来自动化和安排导入。

设计数据库

在确定了应用程序的核心组件后,我们现在可以开始开发数据库了。让我们从创建我们的 locations 表开始。

位置

在开发从外部源导入数据的应用程序时,您通常可以利用外部源的结构来确定自己的数据库表应该是什么样子。在 protected/data/ 的章节资源中提供了一个名为 parks.json 的文件,作为我们的外部数据源。由于该源中的数据是一致的,让我们看一下源中的一个条目:

{
   "name" : "Cancer Survivors' Garden",
   "lat" : "41.884242",
   "long" : "-87.617404",
   "city" : "Chicago",
   "state" : "IL"
}

我们数据源中的单个元素由位置的名称、纬度和经度坐标以及该位置的市和州组成。为了简化,我们可以将这些属性表示为表中每个的 TEXT 属性。一旦我们添加了 ID 列和 created 以及 updated 列,我们的 locations 表将如下所示:

ID INTEGER PRIMARY KEY
name TEXT
lat TEXT
long TEXT
city TEXT
state TEXT
created INTEGER
updated INTEGER

初始化项目

正如我们在任务项目中做的那样,我们在应用程序的 web 根目录下创建了一些文件夹以开始开发:

nearby/
   assets/
   js/
   protected/
      commands/
      config/
      controllers/
      data/
      extensions/
      migrations/
      models/
      runtime/
      views/

在这个应用程序中,我们添加了两个新的文件夹,commandsextensionscommands 文件夹是 Yii 中的一个特殊文件夹,当运行命令行命令时,yiic 将引用它。extensions 文件夹是 Yii 中的一个特殊文件夹,其中可以放置 Yii 扩展或第三方类。

接下来,让我们将我们的 Yii 启动文件 index.php 添加到应用程序的根目录。我们需要确保将 Yii 路径更改为系统上的位置:

<?php

// change the following paths if necessary
$yii='/path/to/yii/framework/yii.php';
$config=dirname(__FILE__).'/protected/config/main.php';

error_reporting(E_ALL);
ini_set('display_errors', '1');
// remove the following lines when in production mode
defined('YII_DEBUG') or define('YII_DEBUG',true);
// specify how many levels of call stack should be shown in each log message
defined('YII_TRACE_LEVEL') or define('YII_TRACE_LEVEL',3);

require_once($yii);
Yii::createWebApplication($config)->run();

现在,让我们在 protected 文件夹中创建我们的 yiic.php 文件,该文件将运行我们的迁移和命令行命令。我们再次需要确保在 require 语句中调整到 Yii 框架的路径:

<?php

// change the following paths if necessary
$config=dirname(__FILE__).'/config/main.php';

$config = require($config);

require_once('/path/to//yii/framework/yiic.php');

创建配置文件

接下来,我们需要创建我们的 Yii 应用程序将使用的配置文件。让我们将以下内容添加到 protected/config/main.php

<?php
return array(
   'basePath'=>dirname(__FILE__).DIRECTORY_SEPARATOR.'..',
   'name'=>'Places Nearby',
   'import'=>array(
        'application.models.*',
   ),
   'components'=>array(
        'db'=>array(
            'connectionString' => 'sqlite:'.dirname(__FILE__).'/../data/locations.db',
      ),
        'urlManager'=>array(
            'urlFormat'=>'path',
            'showScriptName'=>false,
            'rules'=>array(
            '<controller:\w+>/<id:\d+>'=>'<controller>/view', '<controller:\w+>/<action:\w+>/<id:\d+>'=>'<controller>/<action>','<controller:\w+>/<action:\w+>'=>'<controller>/<action>',
            ),
        )
    )
);

与我们在 第一章 中制作的配置文件相比,任务管理应用程序,文件中唯一改变的部分是 SQLite 数据库文件的存储位置以及应用程序的名称。

获取示例数据

protected/data 文件夹内的章节资源中有一个名为 parks.json 的文件;它包含我们将用于应用程序的示例数据。让我们从项目资源中获取这个文件并将其添加到 protected/data 文件夹中。

创建数据库

为了创建数据库,我们再次使用迁移。现在,让我们从命令行导航到项目根目录并使用 yiic 创建迁移:

$ php protected/yiic.php migrate create locations

确认创建后,我们在 protected/migrations 中打开新的迁移文件,并将 contents up() 方法替换为以下内容:

return $this->createTable('locations', array(
   'id' => 'INTEGER PRIMARY KEY',
   'name' => 'TEXT',
   'lat' => 'TEXT',
   'long' => 'TEXT',
   'city' => 'TEXT',
   'state' => 'TEXT',
   'created' => 'INTEGER',
   'updated' => 'INTEGER'
));

然后,我们将 down() 方法的内文替换为以下内容:

return $this->dropTable('locations');

现在,我们从命令行应用新的迁移:

$ php protected/yiic.php migrate up

创建位置模型

为了与我们的数据交互,我们需要创建一个模型,该模型再次引用我们新的数据库表。使用第一章中概述的说明,“任务管理应用程序”,我们启用Gii模块并创建一个名为 Location 的新模型,以与数据库中的locations表交互。

创建完成后,我们在生成的文件(protected/modules/Location.php)中添加一个beforeSave()方法来自动设置创建和更新时间:

public function beforeSave()
{
   if ($this->isNewRecord)
      $this->created = time();

   $this->updated = time();

   return parent::beforeSave();
}

然后,我们修改rules()方法:

public function rules()
{
    return array(
        array('created, updated', 'numerical', 'integerOnly'=>true),
        array('name, lat, long, city, state', 'required'),
        array('title, data', 'safe'),
        array('name, lat, long, city, state, created, updated', 'safe', 'on'=>'search'),
    );
}

导入数据源

在创建前端控制器以显示我们的数据之前,我们需要创建一个工具来导入我们的数据源。为了创建这个工具,我们在命令目录中创建了一个继承自CConsoleCommand的类;这将使我们能够从命令行导入数据,并选择自动化它。

首先,我们需要在/protected目录下的commands目录中创建一个名为ImportLocationsCommand的新类,该类继承自CConsoleCommand。在命令目录中的文件名应该是ImportLocationscommand.php

<?php
class ImportLocationsCommand extends CConsoleCommand {}

接下来,我们添加一个方法来处理我们想要导入的数据的检索。为了提供最大的灵活性,我们创建了两个方法:第一个将从我们的外部数据源获取数据,第二个实际上将数据导入我们的数据库。

在实际应用中,我们构建的第一个方法可能会通过 CURL 从网络资源获取数据。或者,数据可能通过 FTP 上传并供我们使用。然而,由于我们的数据是本地存储的,因此我们的方法将简单地获取文件的全部内容:

private function getData()
{
   $file = __DIR__ . '/../data/parks.json';
   return CJSON::decode(file_get_contents($file));
}

通过将此功能移动到自己的方法中,我们可以轻松地在未来更改此方法,以从另一个位置获取数据,而无需更改代码的其他部分。

接下来,我们创建一个名为actionImportLocations()的新方法来执行导入:

public function actionImportLocations() {}

为了简单起见,我们假设getData()方法将始终返回有效数据给此方法。在方法内部,我们添加以下内容:

echo "Loading Data...\n";
$data = $this->getData();

在导入数据时,一个重要的考虑因素是确保我们不会意外地在应用程序中创建重复数据。有几种处理方法。

处理这种边缘情况的最简单方法就是简单地截断数据库表并执行全新的导入。虽然这种类型的导入非常简单,但在处理更大的数据集时,它可能会在导入过程中导致我们的应用程序无法正常工作。

一种更可靠的方法是将这些数据导入到一个临时数据库表中,然后删除活动表并将临时表重命名为活动表的名字。除了确保我们没有重复数据外,这种方法还可以确保如果我们导入数据时出现问题,我们可以简单地通过错误中断导入,而不必担心数据库被损坏。此外,这种方法还应减少与导入原始数据相关的停机时间。

导入数据最复杂的方法是将现有的数据库与数据源中的数据进行比较,只导入两者之间的差异。虽然这种方法更复杂,但它可以减少检索数据所需的开销,并且与之前的方法结合使用时,应几乎减少所有与导入相关的停机时间。

为了保持简单,我们将选择第一种方法,这很容易实现。首先,我们将截断数据库中的现有数据:

echo "Truncating old data...\n";
Location::model()->deleteAll();

由于我们的数据库与我们的数据源匹配,我们将简单地遍历结果并逐行导入:

echo "Importing Data...\n";
foreach($data as $id=>$content)
{
   $model = new Location;
   $model->attributes = $content;
   $model->save();
}

从命令行,我们现在可以通过运行我们刚刚创建的importlocations命令来导入我们的数据。运行命令行任务的格式如下:

$ php protected/yiic.php <command_name> <action_name>

在我们这个例子中,完整的命令如下所示:

$ php protected/yiic.php importlocations importlocations

如果导入顺利,我们将看到我们添加到命令中的调试输出,没有任何错误:

$ php protected/yiic.php importlocations importlocations
Loading Data...
Truncating old data...
Importing Data...

注意

您可以从官方指南www.yiiframework.com/doc/guide/1.1/en/topics.console或从 Yii 类参考www.yiiframework.com/doc/api/1.1/CConsoleCommand中了解更多关于CConsoleCommand的信息。

Google API

在我们开始应用前端工作之前,我们需要创建一个 API 密钥以与 Google Maps 和 Google Places API 交互。

启用 Google API

要启用我们项目使用的 Google API,请打开一个网络浏览器并导航到位于console.developers.google.com/project的 Google API 控制台。一旦我们登录到 Google 账户,我们点击创建项目按钮,并填写具有唯一项目名称和项目 ID 的表单,如下面的截图所示:

启用 Google API

一旦创建项目,我们将导航到新创建的项目,并在侧边栏中点击APIs & auth链接。从 API 列表中,我们将Google Maps JavaScript API v3Places API切换到开启,如下面的截图所示:

启用 Google API

生成 API 密钥

在项目启用了这两个 API 之后,我们点击侧边栏中的凭证链接。从这个菜单,我们可以为我们的应用程序创建一个新的 API 密钥。一旦进入这个页面,我们将有两个选项,即 OAuth 客户端 ID 或公共 API 密钥。点击公共 API 访问下的创建新密钥,如以下截图所示:

生成 API 密钥

然后,从下一个菜单选择服务器密钥,这将为我们生成一个新的客户端 API 密钥,以便在我们的应用程序中使用:

生成 API 密钥

页面重新加载后,我们将完整的 API 密钥复制到我们的剪贴板。

存储 API 密钥

接下来,我们需要在我们的应用程序中存储我们的 API,以便我们可以使用它。幸运的是,Yii 在protected/config/main.php中提供了一个用于静态参数的设置,称为params,我们可以在这里存储我们的 API 密钥。让我们将以下内容作为配置文件的根元素添加,并用实际的 API 密钥替换<your_api_key_here>

'params' => array(
   'PlacesApi' => array(
        'apiKey' => '<your_api_key_here>'
   )
)

然后,这些数据可以通过Yii::app()->params作为一个数组来访问,我们可以按照以下方式查询:

$apiKey = Yii::app()->params['PlacesApi']['apiKey'];

创建表示层

现在,我们已经准备好开始显示内容。为了开始,我们在protected/controllers目录中创建一个新的控制器,命名为SiteController.php,它包含以下内容:

<?php
class SiteController extends CController
{
   public function actionIndex()
   {
        $this->render('index');
   }
}

接下来,让我们在protected/views/layouts/main.php中创建我们的主要布局。为了简单起见,我们再次将使用从公开可用的 CDNs 获取的 jQuery 和 Twitter Bootstrap 样式,如下所示:

<!DOCTYPE html>
<html>
   <head>
        <title><?php echo CHtml::encode(Yii::app()->name); ?></title>

        <?php Yii::app()->clientScript
                   ->registerMetaTag('text/html; charset=UTF-8', 'Content-Type')
                   ->registerCssFile( '//netdna.bootstrapcdn.com/bootstrap/3.0.3/css/bootstrap.min.css' )
                   ->registerScriptFile( 'https://code.jquery.com/jquery.js' )
                   ->registerScriptFile( '//netdna.bootstrapcdn.com/bootstrap/3.0.3/js/bootstrap.min.js')
                   ->registerScriptFile( 'https://maps.googleapis.com/maps/api/js?sensor=false&key=' . Yii::app()->params['PlacesApi']['apiKey']);
        ?>
   </head>
   <body>
        <div class="row">
            <div class="container">
                <nav class="navbar navbar-default navbar-fixed-top navbar-inverse" role="navigation">
                    <div class="navbar-header">
                         <a class="navbar-brand" href="/"><?php echo CHtml::encode(Yii::app()->name); ?></a>
                    </div>
                </nav>
            </div>
        </div>
        <div class="row" style="margin-top: 100px;">
             <div class="container">
                <?php echo $content; ?>
            </div>
        </div>
    </body>
</html>

由于我们的应用程序将只有一个页面,我们将直接在我们的布局中注册 Google Maps JavaScript API,如前述代码所示。请注意,当我们注册此 JavaScript 文件时,我们包括了我们的 Google API 密钥,该密钥已添加到配置文件的params部分:

->registerScriptFile( 'https://maps.googleapis.com/maps/api/js?sensor=false&key=' . Yii::app()->params['PlacesApi']['apiKey']);

接下来,让我们为我们的site/index动作在protected/views/sites/index.php中创建一个简单的视图文件,以保存我们的地图容器:

<div class="col-xs-12 col-sm-9">
   <div id="map-canvas" style="width: 100%; min-height: 500px"></div>
</div>

与 Google Maps JavaScript API 交互

由于 Google Maps 是一个 JavaScript API,我们需要编写一些 JavaScript 代码来与之交互。

首先,在/js目录中创建一个新的文件,命名为Main.js。此 JavaScript 文件将存储我们创建和与 Google Maps 交互的所有 JavaScript 方法。我们在这里创建的实用函数将使稍后与地图交互更容易。

在我们开始编写任何 JavaScript 之前,我们需要从我们的布局中加载我们的 JavaScript 文件。为此,我们可以在protected/views/layouts目录下的main.php文件中通过添加以下内容来注册一个新的脚本CClientScript

->registerScriptFile(Yii::app()->baseUrl .'/js/Main.js');

现在 JavaScript 文件将被加载,我们打开Main.js文件并创建一个新的 JavaScript 对象,命名为Main

var Main = {}

在此对象中,我们需要创建三个属性:一个用于存储 Google Maps 对象,一个用于存储 Google Maps 可能需要的任何选项,以及一个用于存储我们添加到地图上的任何标记:

map : null,
mapOptions : {},
markers : [],

接下来,我们创建一个函数来实际加载谷歌地图对象。这个函数需要处理两个不同的加载情况。

这个函数需要处理的第一个情况是加载没有地图标记的谷歌地图。在这种情况下,我们假设用户第一次到达该页面,并且尚未选择他们想要查看附近位置的感兴趣点。这个函数需要处理的第二个情况是使用给定的感兴趣点初始化地图,并聚焦在该点上。

为了处理这两种情况,我们的函数将接受一个纬度和经度位置。如果提供了纬度和经度位置,我们将地图中心定位在那个位置。如果没有提供,我们将地图中心定位在我们数据通常所在的位置的缩放视图,在这个例子中是芝加哥市中心地区:

loadMap : function(lat, lng) {
zoom = 16;
    if (lat == undefined && lng == undefined)
    {
        // Lat long of downtown Chicago area
        lat = "41.878114";
        lng = "-87.629798";
        zoom = 13;
    }
}

然后,在同一个函数中,我们将设置我们的地图选项并在我们的index.php文件中的protected/views/site占位符中加载地图:

Main.mapOptions = {
   zoom: zoom,
   center: new google.maps.LatLng(lat, lng),
};

Main.map = new google.maps.Map(document.getElementById("map-canvas"), Main.mapOptions);

为了让我们看到地图的实际效果,我们在index.php文件中的protected/views/site添加以下内容并刷新页面:

<?php $cs->registerScript('loadMap', "Main.loadMap();"); ?>

页面加载后,我们应该看到谷歌地图对象显示,如下面的截图所示:

与谷歌地图 JavaScript API 交互

在验证我们的地图已加载后,让我们回到我们的Main.js文件并添加一些额外的实用函数。

首先,让我们添加一个简单的包装器来创建谷歌地图的纬度和经度坐标。这种方法将帮助我们确保当我们要与之交互时,我们的谷歌地图对象能够加载:

createLocation : function(lat,lng) {
   return new google.maps.LatLng(lat,lng);
},

其次,让我们创建一个函数来添加地图标记。这个函数需要显示两种类型的标记,第一种是选定的感兴趣点,第二种是附近的感兴趣点:

addMarker : function(position, title, icon) {
   if (icon == true)
   {
        var pinColor = "2F76EE"; // a random blue color that i picked
        var icon = new google.maps.MarkerImage("http://chart.apis.google.com/chart?chst=d_map_pin_letter&chld=%E2%80%A2|" + pinColor,
                     new google.maps.Size(21, 34),
                     new google.maps.Point(0,0),
                     new google.maps.Point(10, 34));
    }
}

在函数内部,我们创建一个新的marker对象:

var marker = new google.maps.Marker({
position: position,
   title: title,
   icon: icon
});

然后,我们将这个marker对象推送到地图上:

Main.markers.push(marker);

然后,我们将marker对象添加到我们之前定义的markers变量中。这允许我们在想要使我们的应用程序更动态时清除地图:

marker.setMap(Main.map);

最后,让我们创建一个函数来清除地图。这个函数将遍历我们在之前定义的markers变量中的所有标记,并移除我们使用addMarker()设置的地图标记:

clearMarkers : function() {
   $(Main.markers).each(function() {
        this.setMap(null);
   });

   Main.markers = [];
}

注意

关于如何与谷歌地图 JavaScript API v3 交互的更多信息,请参阅developers.google.com/maps/documentation/javascript/tutorial

搜索附近位置

为了搜索附近的地点,我们将利用 Google Places API。我们不会自己实现 API 文档,如developers.google.com/places/documentation/中所述,而是将利用位于github.com/joshtronic/php-googleplaces的开源 API 包装器。

为了利用这个包装器,我们将仓库下载到我们的extensions文件夹中,下载完成后应该如下所示:

protected/
   extensions/
        GooglePlaces.php

下载完包装器后,我们重新打开SiteController.php并创建一个新的私有方法getPlaces(),它接受我们的数据库中的一个地点作为参数:

private function getPlaces($location) {}

为了让 Yii 知道ext.GooglePlaces代表protected/extensions/GooglePlaces.php,我们首先使用Yii::import()导入它。这个方法比requireinclude语句更受欢迎,因为它既将类注册到 Yii 的自动加载器中,又在我们多次使用时只加载类一次。由于这个类在我们的配置文件中没有自动加载,我们需要在这里手动导入它:

Yii::import('ext.GooglePlaces');

注意

Yii 是如何知道ext.GooglePlaces代表protected/extensions/GooglePlaces.php的呢?Yii 使用路径别名来轻松识别文件和文件夹在我们应用程序根目录中的位置。这使得我们可以轻松地引用这些文件和文件夹,而无需指定绝对路径。您可以在www.yiiframework.com/doc/guide/1.1/en/basics.namespace上了解更多关于路径别名的信息。

接下来,我们使用我们之前创建的 API 密钥实例化这个类:

$places = new GooglePlaces(Yii::app()->params['PlacesApi']['apiKey']);

然后,我们指定我们想要搜索的半径和地点:

$places->radius = 200;
$places->location = array($location->lat, $location->long);

注意

在一个人口密集、商店众多的地区,我们可以合理地假设在 200 米半径内我们会找到几个结果。在一个人口较少的地区,调整我们的半径到一个更大的值以更好地找到附近的搜索结果会更明智。

然后,我们搜索附近的地点:

return $places->search();

在有了执行搜索的方法后,我们现在需要更新我们的index操作来调用我们的新方法。为此,我们假设客户端将通过从下拉列表中选择一个地点并发送我们创建的唯一 ID 来指定他们想要搜索的地点。在SiteController.php中,我们添加以下操作:

public function actionIndex()
{
   $location = $places = array();

   if (isset($_GET['id']))
   {
        $location = Location::model()->findByPk($_GET['id']);
        $places = $this->getPlaces($location);
   }

   $this->render('index', array('location' => $location, 'places' => $places));
}

选择地点

现在我们控制器可以搜索附近的地点了,我们需要更新我们的视图,protected/views/site/index.php,添加一个表单,允许用户选择他们想要插入的地点:

<div class="col-xs-6 col-sm-3 sidebar-offcanvas">
   <h3>Locations</h3>
   <hr />
   <form role="form">
        <div class="form-group">
            <?php $selected = array('options' => array(isset($_GET['id']) ? $_GET['id'] : NULL => array('selected' => true))); ?>
            <?php echo CHtml::dropDownList('id', array(), CHtml::listData(Location::model()->findAll(),'id','name'), CMap::mergeArray($selected, array('empty' => 'Select a Location'))); ?>
        </div>
        <button type="submit" class="pull-right btn btn-primary">Search</button>
    </form>
    <div class="clearfix"></div>
</div>

在前面的代码示例中,我们使用了CHtml::listData()来同时从我们的数据库中检索地点列表,并用适当的 ID 名称对填充下拉菜单以显示。使用CHtml::listData(),我们可以确保我们的数据是根据数据库中的内容动态获取和显示的。

在地图上显示位置

虽然我们的表单功能正常,但我们仍然需要更新我们的视图来实际显示地图上的位置。这就是我们使用之前创建的 JavaScript 代码的地方。在我们的侧边栏的</div>标签之前,让我们加载CClientScript以动态地将 JavaScript 与 Yii 注册:

<?php $cs = Yii::app()->getClientScript(); ?>

现在,我们需要处理两种情况。在第一种情况下,用户第一次到达我们的网站,只需要显示地图。在第二种情况下,我们需要显示一个以我们的兴趣点为中心的地图。由于在第一种情况下,我们的$places['results']数组将是空的,我们可以这样表示:

<?php 
if (!empty($places['results']))
{
$cs->registerScript('loadMap', "Main.loadMap({$location->lat}, {$location->long});");

   // Center the map with the origin marker
   $lat = $location->lat;
   $long = $location->long;
   $name = $location->name;
   $cs->registerScript('origin', "
        Main.addMarker(
            Main.createLocation('{$lat}', '{$long}'),
            \"{$name}\",
            true
        );
    ");
}
else
{
$cs->registerScript('loadMap', "Main.loadMap();");
}

让我们重新加载页面并尝试一下。如果选择了位置,地图上会显示一个蓝色标记。否则,不会显示任何标记。

接下来,我们需要将附近的地点添加到地图上。为此,我们只需遍历$places['results']数组,并注册一个唯一的脚本,该脚本将在地图上放置一个标记。为了提高最终用户的清晰度,我们还在侧边栏中添加了文本条目:

<hr />
<h3>What's Nearby?</h3>
<ul>
   <?php foreach ($places['results'] as $place): ?>
        <li><?php echo $place['name']; ?></li>
        <?php
            // Add the nearby POI's
            $lat = $place['geometry']['location']['lat'];
            $long = $place['geometry']['location']['lng'];
            $name = $place['name'];
            $icon = $place['icon'];
            $cs->registerScript('loadMarker-' . $place['id'], "
                Main.addMarker(
                    Main.createLocation('{$lat}', '{$long}'),
                  \"{$name}\"
                     );
            ");
            ?>
        <?php endforeach; ?>
</ul>

一切准备就绪后,我们现在可以搜索我们的locations数据库,并在地图上显示附近的地点:

在地图上显示位置

使用缓存优化性能

正如第三方 API 通常的情况一样,Google Places API 是一个付费资源,它附带每日的礼貌限制(目前为每天 1,000 次请求),这意味着每次用户向我们的应用程序发出请求时,我们都在为其付费。

然而,由于在接下来的几个小时、几天甚至几周内创建一个新的兴趣点的可能性相当小,我们可以在本地缓存这些数据,而不是每次请求页面时都向谷歌发送请求。这样做不仅可以节省我们的费用,而且还可以加快我们的应用程序的速度,因为可以从本地资源而不是第三方资源检索这些数据。

要做到这一点,我们首先需要在配置文件中启用缓存。Yii 中有几种不同的缓存可供使用,包括基于文件的缓存、基于 memcache 的缓存和 Redis 缓存。对于这个应用程序,我们将保持简单,并使用基于文件的缓存。要启用缓存,我们在配置文件的组件部分添加以下内容:

'cache' => array(
   'class' => 'CFileCache'
),

启用缓存后,我们开始在应用程序中使用它。让我们打开SiteController.php文件,并将getPlaces()方法替换为以下内容:

private function getPlaces(&$location)
{
   // Generate a hash
   $hash = md5($location->lat . '-' . $location->long);

   // Retrieve data from the cache
   $cache = Yii::app()->cache->get($hash);

   // If we don't have any cached data, perform a search
   // against the API
   if ($cache === false)
   {
        Yii::import('ext.GooglePlaces');
        $places = new GooglePlaces(Yii::app()->params['PlacesApi']['apiKey']);
        $places->radius = 200;
        $places->location = array($location->lat, $location->long);
        $cache = $places->search();
        // And store the result in the cache
Yii::app()->cache->set($hash, $cache);
    }

    return $cache;
}

让我们回顾一下我们刚才做了什么。首先,我们将生成一个唯一的哈希值,我们将使用这个哈希值存储我们的哈希值。为此,我们将任何给定位置的纬度和经度存储为一个md5哈希值,这应该为我们提供足够的空间来存储我们的结果:

$hash = md5($location->lat . '-' . $location->long);

接下来,我们将从缓存中检索缓存结果。如果数据未返回,此方法将返回 false:

$cache = Yii::app()->cache->get($hash);

如果当前缓存中没有存储任何值,我们将对 API 进行搜索:

if ($cache === false) {}

从 API 检索结果后,我们将其存储在之前生成的md5哈希值中:

$cache = $places->search();
Yii::app()->cache->set($hash, $cache);

最后,我们返回数据:

return $cache;

通过添加这个缓存,当多个用户同时搜索时,我们的应用程序应该会表现得更好,并且我们降低了触碰到每日 API 限制的风险。如果我们确实需要升级我们的应用程序到一个需要更多请求的应用程序,我们可以确信我们只为我们绝对需要的部分付费,而不是为每个请求付费。

摘要

在本章中,我们覆盖了很多内容。我们讨论了如何使用CConsoleCommand将控制台命令集成到我们的应用程序中,以及如何从外部源将数据导入我们的数据库。我们还讨论了如何集成两个流行的 Google API:Google Maps 和 Google Places API。此外,我们还讨论了这些 API 响应的缓存。最后,我们讨论了将第三方代码导入我们的应用程序。

在本章和第一章《任务管理应用程序》中,我们讨论了构建 Yii 应用程序的大部分基本组件。在下一章中,我们将创建一个调度应用程序,该应用程序将在事件发生之前自动提醒用户。我们还将扩展我们迄今为止讨论的所有主题,以构建和操作更复杂的话题。在继续之前,请务必查看本章中引用的所有类,在官方 Yii 文档中,以便更好地理解它们。

第三章:预定提醒

在前两个章节中,我们开发了简单的反应式应用程序,覆盖了 Yii 框架的基本组件。对于我们的下一个项目,我们将通过创建一个允许用户搜索、创建和为自己安排事件和提醒的预定提醒应用程序来扩展之前讨论的概念。此应用程序还将自动在预定提醒发生时向用户发送通知。

先决条件

在我们开始之前,有一些我们需要安装和获取的东西:

  • 安装 MySQL 的最新版本(在撰写本文时,MySQL 5.6)。MySQL 是最受欢迎的开源数据库,是 LAMP(Linux、Apache、MySQL 和 PHP)的关键部分。由于其受到网络托管提供商的青睐,MySQL 通常是现代 Web 应用程序的事实上的选择。

    备注

    MySQL 可以从您的发行版包管理系统中安装,或者从mysql.com下载。更多详细信息可以在dev.mysql.com/doc/refman/5.6/en/installing.html找到。

  • 为我们的应用程序获取一个 SMTP 服务器或 SMTP 服务器的凭据,以便发送电子邮件。我们需要的关键细节包括 SMTP 主机、端口、用户名和密码。根据服务器不同,您可能还需要了解您的服务器使用的安全类型(如 SSL 或 TLS)。如果您没有可用的 SMTP 服务器,有许多选项可供选择,从设置 Postfix SMTP 服务器,使用 Gmail 作为 SMTP 中继,甚至从 SendGrid 获取免费的 SMTP 账户(www.sendgrid.com)。

  • 验证我们的 PHP 实例已安装 mcrypt 库,以便我们可以正确地散列我们将使用的密码。如果您的 PHP 实例已经支持 mcrypt,您应该在phpinfo()中看到一个 mcrypt 部分。如果 mcrypt 在您的 PHP 实例中未启用,您可以从您的上游提供商安装它,通过启用 mcrypt 模块,或者通过重新编译 PHP。

  • 最后,我们需要从getcomposer.org/下载并安装 Composer。Composer 是一个 PHP 依赖管理器,它将允许我们声明并自动安装应用程序将使用的库。

一旦我们获取了应用程序的所有先决条件,我们就可以开始开发工作了。

描述项目

我们计划中的提醒项目可以分为四个主要部分:

  • 将创建事件和提醒的用户

  • 用户希望被提醒的事件

  • 实际事件的提醒(可能有很多)

  • 一个命令行任务,用于处理并发送提醒到用户通过电子邮件

用户

我们应用程序的第一个组件是使用它的用户。用户将负责为自己创建事件和提醒。用户也将是他们创建的提醒电子邮件的收件人。使用这些信息,我们可以简化我们的数据库模式为以下结构:

ID INTEGER PRIMARY KEY
email STRING
password STRING
created INTEGER
updated INTEGER

在第一章《任务管理应用》中,我们创建了一个非常原始的用户认证系统,我们将在后面的章节中重新使用、扩展并重复使用它。在本章中,我们将开发一个系统,使用我们的应用程序创建、删除和管理用户的密码。我们还将介绍一些基本指南,以正确地保护、存储和处理我们用户的凭据。

事件

我们应用程序的第二个组件是事件。事件是特定用户希望被提醒的事情,将在特定日期的特定时间发生。事件应该易于搜索且直观。此外,事件可以有一个、多个或没有与之关联的提醒。我们可以在我们的数据库模式中表达如下:

ID INTEGER PRIMARY KEY
user_id INTEGER
title STRING
data TEXT
time INTEGER
created INTEGER
updated INTEGER

本章我们将引入的新概念是数据库关系。很多时候,我们数据库中的数据将与另一个表中的属性或数据相关联。在这种情况下,事件是某个特定用户拥有的东西。我们在本应用程序中创建的关系将使我们能够轻松地表示表中的数据,而无需在多个地方存储该数据。

提醒

提醒是一个对用户创建的事件具有时间敏感性的事件,它作为对任务运行器的指示,通知用户事件的详细信息。这可以在我们的简化数据库模式中表达如下:

ID INTEGER PRIMARY KEY
event_id INTEGER
title STRING
offset INTEGER
time INTEGER
created INTEGER
updated INTEGER

当我们设置提醒模型时,我们将定义提醒和事件之间的关系。由于事件已经与用户绑定,我们可以通过传递方式确定应该发送提醒的用户,而无需在提醒本身中添加user_id字段。

我们提醒系统的最后一部分与处理时间戳的方式有关。在之前的章节中,时间戳仅作为特定记录的元数据。然而,我们的提醒必须考虑事件被触发的时间,这意味着我们将涉及时区。虽然使用协调世界时(UTC)在处理时间问题时解决了许多问题,但我们的提醒必须了解特定提醒的时间偏移量。

对于我们的应用程序来说,这意味着我们需要存储最终用户将看到的时间,以及用户的时区偏移量或将其转换为真实 UTC 时间。

任务运行器

我们应用程序的最后一个组件是任务运行器,它将找到需要发送的提醒,并将它们实际发送给用户。虽然有许多方法可以创建这个任务运行器,但我们将创建一个在n分钟后重复运行的命令行任务,并处理触发时间和提供的分钟间隔之间的所有事件。这种方法将允许我们定义我们希望提醒处理得多频繁或少频繁,而无需重写代码。

初始化项目

到目前为止,您应该已经相当熟悉如何初始化一个基本的 Yii 框架项目。继续创建基础文件夹结构,并创建index.phpyiicyiic.batyiic.php文件。然后在我们的应用程序的webroot目录中创建一个名为vendors的文件夹。这个文件夹将用于我们所有的 Composer 依赖项。

创建 MySQL 用户和数据库名

如果您还没有为项目创建 MySQL 用户、密码和数据库名,请现在创建。从 MySQL 命令行,您可以运行以下命令来完成此操作:

CREATE USER 'ch3_reminders'@'localhost' IDENTIFIED BY 'ch3_reminders';
CREATE DATABASE IF NOT EXISTS `ch3_reminders`;
GRANT ALL PRIVILEGES ON `ch3\_reminders` . * TO 'ch3_reminders'@'localhost';

创建 Yii 配置文件

由于使用了 MySQL 数据库,我们的 Yii 配置文件将与我们之前的配置文件略有不同。我们将从基础配置protected/config/main.php开始,然后添加新的组件:

<?php return array(
   'basePath'=>dirname(__FILE__).DIRECTORY_SEPARATOR.'..',
   'name'=>'Scheduled Reminders',
   'import'=>array(
      'application.models.*',
   ),
   'components'=>array(

      'errorHandler'=>array(
            'errorAction'=>'site/error',
        ),
       'urlManager'=>array(
         'urlFormat'=>'path',
         'showScriptName'=>false,
         'rules'=>array(
            '/' => 'event/index',
            'event/date/<date:[\w-]+>' => 'event/index', '<controller:\w+>/<action:\w+>/<id:\d+>'=>'<controller>/<action>', <controller:\w+>/<action:\w+>'=>'<controller>/<action>')
      )
   )
);

为了让我们的应用程序与 MySQL 交互,我们需要更新数据库组件,以便 Yii 知道如何使用 MySQL PDO 适配器。我们可以通过向我们的组件数组中添加以下内容来实现:

'db' => array(
    'class' => 'CDbConnection',
    'connectionString' => 'mysql:host=127.0.0.1;dbname=ch3_reminders',
    'emulatePrepare' => true,
    'username' => 'ch3_reminders',
    'password' => 'ch3_reminders',
    'charset' => 'utf8',
    'schemaCachingDuration' => '3600'
),

注意

在这个配置中,我们添加了schemeaCachingDuration,它说明了 Yii 将缓存我们的 MySQL 模式多长时间。这将防止不必要的 SQL 命令,例如DESCRIBE TABLE,这将减慢我们的应用程序。需要注意的是,如果您使用此选项,您需要清除 Yii 的内部缓存。您可以在www.yiiframework.com/doc/api/1.1/CDbConnection了解更多有关 MySQL 特定数据库配置的信息。

创建参数配置文件

许多时候,我们希望将敏感信息存储在我们的配置文件中,但我们可能不会出于安全原因将其与版本控制软件一起存储。我们可以通过将此信息存储在单独的文件中,然后将其排除在源控制提交之外来解决这个问题。当我们将应用程序部署到我们的生产服务器时,我们可以手动添加此文件。

在 Yii 中,我们可以通过向我们的配置文件的基础数组中添加以下内容来完成此操作:

'params' => array(
    'smtp' => require __DIR__ . '/params.php'
)

接下来,在config文件夹中创建一个名为params.php的新文件。此文件将存储我们应用程序的 SMTP 凭据。请查看以下代码:

<?php return array(
   'host' => '',
   'username' => '',
   'password' => '',
   'from' => '',
   'port' => ''
);

在此期间,请将您的 SMTP 凭据添加到params.php文件中。

添加 Composer 依赖项

我们需要做的最后一个配置更改是在webroot目录中包含一个名为composer.json的文件。对于这个项目,我们将使用一个名为PHPMailer的依赖项,它将帮助我们从应用程序发送电子邮件。我们还将包括一个名为password-compat的包,它将为我们提供与 Bcrypt 密码散列库一起工作的必要用户空间函数,我们将在开始处理用户和身份验证时更详细地介绍 Bcrypt。

此文件应如下所示:

{
   "minimum-stability" : "dev",
   "require": {
       "phpmailer/phpmailer": "dev-master"
	   "ircmaxell/password-compat": "dev-master"
   }
}

在定义了我们的 Composer 依赖项之后,我们现在可以通过在命令行中运行以下命令来安装它们:

cd /path/to/project
php /path/to/composer.phar

如果一切顺利,你应该会在屏幕上看到类似的内容输出。如果不顺利,Composer 将返回并通知你错误,以便你进行纠正:

Loading composer repositories with package information
Installing dependencies (including require-dev) from lock file
 - Installing phpmailer/phpmailer (dev-master f9d229a)
 Cloning f9d229af549d28d4c9fdd3273bf6525cde3bc472
Generating autoload files

最后,我们需要将依赖项加载到 Yii 中。最简单的方法是在index.php文件中的require_once($yii)之前添加以下内容:

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

创建数据库

在我们的依赖项和配置文件就绪后,我们现在可以创建我们的数据库。使用yiic命令,创建一个名为 users 的迁移和一个名为 reminders 的迁移。

用户迁移

用户迁移将创建users数据库并确保在数据库级别不能输入重复的电子邮件地址。在protected/migrations文件夹中,打开用户迁移:

up()方法中添加以下内容:

$this->createTable('users', array(
   'id'           => 'pk',
   'email'        => 'string',
   'password'     => 'string',
   'created'      => 'integer',
   'updated'      => 'integer'
));

注意

你可能会注意到我们选择的列类型与 MySQL 列类型不匹配。这是因为我们允许 Yii 为我们使用的数据库适配器确定适当的列类型。这允许多个数据库驱动程序之间的互操作性,这意味着我们可以无缝地在 MySQL 数据库、SQLite 或 Postgres 数据库之间切换底层数据库技术,而无需更改我们的迁移。Yii 手册有更多关于有效列类型的信息,请参阅www.yiiframework.com/doc/api/1.1/CDbSchema#getColumnType-detail

接下来,我们想在email列上创建一个唯一索引,我们可以这样做:

$this->createIndex('email_index', 'users', 'email', true);

最后,在down()方法中添加一个调用以删除users表:

$this->dropTable('users');

提醒和事件迁移

现在,我们将创建提醒和事件迁移,这些迁移将在我们的数据库中创建remindersevents表。这两个表将存储我们应用程序的大部分数据。

  1. 在我们的提醒迁移中,将以下内容添加到up()方法以创建events表:

    $this->createTable('events', array(
       'id'        => 'pk',
       'user_id'   => 'integer',
       'title'     => 'string',
       'data'      => 'text',
       'time'      => 'integer',
       'created'   => 'integer',
       'updated'   => 'integer'
    ));
    
  2. 然后在eventsusers之间创建一个外键关系:

    $this->addForeignKey('event_users', 'events', 'user_id', 'users', 'id', NULL, 'CASCADE', 'CASCADE');
    
  3. 然后创建reminders表,如下所示:

       $this->createTable('reminders', array(
       'id'          => 'pk',
       'event_id'    => 'integer',
       'offset'      => 'integer',
       'time'        => 'integer',
       'created'     => 'integer',
       'updated'     => 'integer'
    ));
    
  4. 最后,在remindersevents之间创建一个外键关系:

    $this->addForeignKey('reminder_events', 'reminders', 'event_id', 'events', 'id', NULL, 'CASCADE', 'CASCADE');
    

注意,对于两个外键,我们希望在删除父记录时删除所有内容。例如,如果我们删除一个事件,与该事件关联的所有提醒也应该被删除。同样,如果删除一个用户,与该用户关联的所有事件和提醒也应该被删除。

然后,向down()方法添加以下内容以删除外键和表。一旦数据已添加到我们的数据库中,除非删除外键关系,否则我们无法删除表:

$this->dropForeignKey('event_users', 'events');
$this->dropForeignKey('reminder_events', 'reminders');
$this->dropTable('events');
$this->dropTable('reminders');

一切添加完毕后,应用迁移。

创建模型

到目前为止,您应该熟悉使用 Gii 工具为我们新创建的表创建模型。请继续创建UsersRemindersEvents模型的模型。创建每个模型后,我们需要对每个模型进行一些更改。

模型行为

我们需要对我们新创建的模型进行的第一个更改是自动设置创建和更新时间戳。在之前的章节中,我们修改了beforeSave()方法来实现这一点;然而,Yii 提供了一个更简单的方法来实现这个功能,它是数据库无关的,并且减少了我们需要添加到模型中的代码量。为此,我们将为每个模型附加一个行为。

Yii 中的行为是具有可以附加到组件(在我们的情况下是模型)的方法的对象。这些行为会监听附加组件上的某些事件(如beforeSave()方法),并在事件触发时执行。

我们将添加到每个模型中的行为称为CTimestampBehavior,它提供了自动设置创建和更新时间的必要工具。要附加此行为,只需将以下方法添加到我们的Users.phpEvents.phpReminders.php文件中,这些文件位于protected/models目录内:

public function behaviors()
{
   return array(
      'CTimestampBehavior' => array(
         'class' => 'zii.behaviors.CTimestampBehavior',
         'createAttribute'    => 'created',
         'updateAttribute'    => 'updated',
         'setUpdateOnCreate' => true
      )
   );
}

注意

关于CTimestampBehavior的更多信息可以在 Yii 文档中找到,该文档可在www.yiiframework.com/doc/api/1.1/CTimestampBehavior/找到。

用户模型

我们需要对Users模型进行的第一个更改是定义用户和事件之间的关系。如果您使用了 Gii 来生成模型,它必须已经为您预先填充了relations()方法。否则,请将以下方法添加到protected/models/目录下的Users.php模型中:

public function relations()
{
   return array(
      'events' => array(self::HAS_MANY, 'Events', 'user_id'),
   );
}

接下来,我们需要给我们的模型添加一个私有属性,用于存储模型的老旧属性,这样我们就可以在不需要重新查询数据库的情况下,比较旧值和更改后的值。请看以下代码行:

private $_oldAttributes = array();

我们可以通过给我们的模型添加一个afterFind()方法来自动填充此属性:

public function afterFind()
{
   if ($this !== NULL)
      $this->_oldAttributes = $this->attributes;
   return parent::afterFind();
}

最后,我们希望给我们的模型添加一个beforeSave()方法,当更改用户的电子邮件地址时,该方法不会修改用户的密码,并且如果确实更改了密码,将正确地加密密码:

public function beforeSave()
{
   if ($this->password == NULL)
      $this->password = $this->_oldAttributes['password'];
   else
      $this->password = password_hash($this->password, PASSWORD_BCRYPT, array('cost' => 13));

   return parent::beforeSave();
}

Bcrypt 密码散列

在数据库中存储密码时,非常重要的一点是,要以一种使我们能够轻松验证用户提供了正确的密码,同时使攻击者难以猜测密码的方式存储这些密码。由于大多数用户使用相同的电子邮件地址和密码来处理他们的所有在线身份,因此我们保持该信息尽可能安全至关重要。

实现这一点的其中一种方法是通过使用对称块加密算法,如 Bcrypt。Bcrypt 将明文密码转换为加盐的哈希值,根据成本因子迭代多次。当使用 Bcrypt 时,成本因子增加了生成和验证密码所需的工作量。通过增加生成和验证密码所需的时间,我们可以使暴力攻击对潜在攻击者变得非常昂贵。此成本因子还允许我们作为开发者随着计算能力的增加调整密码的难度。

注意

你可以在 us2.php.net/manual/en/ref.password.php 上了解更多关于 PHP 5.5 中引入的密码函数的信息。

提醒模型

接下来,我们需要对我们的提醒模型进行一些修改。首先,让我们验证关系是否已经正确设置。在 protected/models/Reminders.php 中添加以下内容:

public function relations()
{
   return array(
      'event' => array(self::BELONGS_TO, 'Events', 'event_id'),
   );
}

然后,添加一个 beforeValidate() 方法,将用户提交的时间转换为整数时间戳,并将偏移时间存储为 UTC 到我们的数据库中:

public function beforeValidate()
{
   $this->time = (int)strtotime($this->time);
   $this->offset = ($this->offset*60 + $this->time);

   return parent::beforeValidate();
}

事件模型

接下来,我们将在 protected/models/Events.php 模型中添加和更新几个方法。步骤如下:

  1. 首先验证关系是否已经正确设置:

    public function relations()
    {
       return array(
          'user' => array(self::BELONGS_TO, 'Users', 'user_id'),
          'reminders' => array(self::HAS_MANY, 'Reminders', 'event_id'),
       );
    }
    
  2. 然后添加一个 beforeValidate() 方法来自动调整提交时间和时间,并自动将用户设置为当前登录用户:

    public function beforeValidate()
    {
       $this->time = (int)strtotime($this->time);
    
       // Set the user_id to be the current user
       $this->user_id = Yii::app()->user->id;
    
       return parent::beforeValidate();
    }
    

    注意

    Yii::app()->user 是对一个 CWebUser 对象的引用,一旦我们进行身份验证,它将处理我们的用户身份。要了解更多关于 CWebUser 的信息,请查看 www.yiiframework.com/doc/api/1.1/CWebUser

  3. 接下来,添加以下获取器方法到我们的模型中。此方法将允许我们从 URL 检索所需数据以搜索我们的活动数据库:

    private function getDate()
    {
        if (isset($_GET['date']))
           return $_GET['date'];
    
        return gmdate("Y-m-d");
    }
    
  4. 然后,我们将更新我们的模型 search() 方法,以便我们能够搜索在特定时间发生的事件,特别是单日的时间段。修改方法签名如下:

    public function search($between = false)
    
  5. 然后,在方法返回之前添加以下内容:

    if ($between)
        $criteria->addBetweenCondition('time', strtotime($this->getDate() . ' 00:00:00'), strtotime($this->getDate() . ' 23:59:59'));
    

搜索事件并显示它们

在我们深入到控制器之前,让我们看看我们的前端将如何搜索和显示事件,因为它将有助于解释对事件模型所做的模型更改,并将帮助我们确定我们还需要实现什么。请查看以下截图:

搜索事件并显示它们

我们的前端视图被分解为几个不同的组件。首先,我们在右上角有一个按钮,该按钮应链接到一个简单的 CRUD 表单,用于创建和更新事件。我们还有一个月份和年份选择器,显示当前选定的年份,并允许我们按一个月或一年的增量向前或向后推进时间。直接在下面,我们有一个日期选择器,显示当前选定的日期(如果没有选择,则为当前日期),在其两侧各有十五天。

在左侧,我们显示当前选定的日期文本,然后是显示在选定日期下的事件的时间和标题的排序器。

最后,在右侧,我们有一个 Ajax 视图,当点击事件时将显示事件详情以及与该事件相关的所有提醒,并提供一些额外功能,可以立即删除该提醒。此外,我们还将为用户提供一个链接来编辑所选事件。

要达到这一级别的功能,我们需要创建一个自定义列表视图,该视图将扩展CListView,添加一个自定义 URL 路由,并创建几个新的控制器方法。让我们开始吧。

日期的自定义路由

我们需要做的第一个更改是更改主配置文件中的urlManager。在urlManager['rules']数组中,添加以下路由:

'event/date/<date:[\w-]+>' => 'event/index',

这个自定义路由将允许我们任意地在 URL 中设置一个日期字符串,并将其自动作为$_GET参数传递给我们的EventController类的indexAction()方法,该类我们将很快创建。

创建事件控制器

让我们继续到我们的EventController。这个控制器将处理我们应用程序中与事件相关的所有必要操作。在protected/controllers中创建一个名为EventController.php的新文件,其中包含以下类定义:

<?php class EventController extends CController{}

执行以下步骤:

  1. 我们应该创建的第一个方法是我们的indexAction()。传递给此方法的$_GET参数将决定最终将在页面上显示哪些事件。为此,我们将利用我们的活动模型的search()方法。在搜索时,我们还想确保只显示当前登录用户的数据:

    public function actionIndex()
    {
        $model = new Events('search');
        $model->unsetAttributes();
    
        if (isset($_GET['Events']))
            $model->attributes = $_GET['Events'];
    
        $model->user_id = Yii::app()->user->id;
    
        $this->render('index', array('model' => $model));
    }
    
  2. 接下来,我们需要创建一个实用方法来通过给定的主键加载我们的模型。我们将在整个模型中使用此方法:

    private function loadModel($id)
    {
       if ($id == NULL)
          throw new CHttpException(400, 'Bad Request');
    
       $model = Events::model()->findByPk($id);
    
       if ($model == NULL)
          throw new CHttpException(404, 'No model with that ID was found');
    
       return $model;
    }
    
  3. 最后,我们需要创建一个 AJAX 方法来在我们的列表视图中显示特定事件的详情:

    public function actionDetails($id = NULL)
    {
       if (Yii::app()->request->isAjaxRequest)
       {
          $model = $this->loadModel($id);
    
          $this->renderPartial('details', array('model' => $model));
          Yii::app()->end();
       }
        Throw new CHttpException(400, 'Bad Request');
    }
    
  4. 当我们在EventController中时,实现保存和删除事件所需的其他功能是值得的。我们的save()方法将简单地接受来自视图文件的$_POST输入,并应如下所示:

    public function actionSave($id = NULL)
    {
       if ($id != NULL)
          $model = $this->loadModel($id);
       else
          $model = new Events;
    
       if (isset($_POST['Events']))
       {
          $model->attributes = $_POST['Events'];
    
          if ($model->save())
             $this->redirect($this->createUrl('/event/save', array('id' => $model->id)));
       }
    
       $this->render('save', array('model' => $model));
    }
    
  5. 最后,是我们的delete()方法,它将促进事件的删除:

    public function actionDelete($id = NULL)
    {
       $model = $this->loadModel($id);
    
       if ($model->delete())
          $this->redirect($this->createUrl('/event'));
    
       throw new CHttpException(400, 'Bad Request');
    }
    

创建提醒控制器

我们接下来要实现的是 ReminderController 控制器。与我们的 EventController 不同,这个控制器应该只提供 AJAX 响应,并且不需要任何视图。

我们将首先在 protected/controllers 目录下创建一个新的文件 ReminderController.php,并将类扩展为 CController。执行以下步骤:

  1. 首先,我们想要确保只有 POST 请求被发送到这个控制器。强制所有请求在执行每个操作之前都是 POST 请求的一个简单方法是检查请求类型。我们可以通过使用 beforeAction() 方法来实现这个检查:

    public function beforeAction($action)
    {
       if (!Yii::app()->request->isPostRequest)
          throw new CHttpException(400, 'Bad Request');
    
       return parent::beforeAction($action);
    }
    
  2. 接下来,我们应该实现一个方法来加载特定的提醒,以及另一个方法来验证我们是否有权访问特定提醒关联的事件,如下所示:

    private function loadEvent($event_id)
    {
       $event = Events::model()->findByPk($event_id);
       if ($event == NULL)
          return false;
    
       if ($event->user_id != Yii::app()->user->id)
          return false;
    
       return true;
    }
    
    private function loadModel($id)
    {
       if ($id == NULL)
          throw new CHttpException(400, 'Bad Request');
    
       $model = Reminders::model()->findByPk($id);
    
       if ($model == NULL)
          throw new CHttpException(404, 'No model with that ID was found');
    
       return $model;
    }
    
  3. 然后,我们将添加删除提醒所需的功能:

    public function actionDelete($id = NULL)
    {
       $model = $this->loadModel($id);
    
       if (!$this->loadEvent($model->event_id))
          return false;
    
       if ($model->delete())
          return true;
    
       throw new CHttpException(400, 'Bad Request');
    }
    
  4. 最后,我们将添加保存和修改提醒所需的功能:

    public function actionSave($id = NULL)
    {
       if ($id != NULL)
          $model = $this->loadModel($id);
       else
          $model = new Reminders;
    
       if (isset($_POST['Reminders']))
       {
          $model->attributes = $_POST['Reminders'];
    
          if (!$this->loadEvent($model->event_id))
             return false;
    
          if ($model->save())
             return true;
          else
             throw new CHttpException(400, print_r($model->getErrors(), true));
       }
    
       return true;
    }
    

我们的 save() 方法被设计成允许通过单一操作创建和修改提醒,而不是多个操作。

创建布局

我们应该实现的第一视图是位于 views/layouts/ 目录下的 main.php 文件。由于这个文件将与我们在前两章中创建的布局相同,所以请将项目资源文件夹中的 views/layouts/main.php 文件复制到您的应用程序中。

创建主视图

接下来,我们将实现一个列表视图,用于显示所有事件。为此,我们将扩展 CListView 类。执行以下步骤:

  1. 首先,在 protected/views/events 中创建一个名为 index.php 的视图文件,该文件将调用这个自定义类,然后添加一个按钮,允许用户创建新事件:

    <?php echo CHtml::link('Create New Event', $this->createUrl('/event/save'), array('class' => 'pull-right btn btn-primary')); ?>
    <div class="clearfix"></div>
    
  2. 然后,添加以下内容以实现列表视图。首先,我们需要实例化一个新的小部件,它将包含我们的自定义列表视图:

    <?php $this->widget('application.components.EventListView', array(
    
  3. 之后,我们需要指定 dataProvider,它将填充我们的模型。这是我们之前对事件模型 search() 方法所做的更改发挥作用的地方:

        'dataProvider'=>$model->search(true),
    
  4. 接下来,我们想要指定列表视图将使用的模板,以及列表视图应该包含的元素标签:

        'template' => '{items}',
        'itemsTagName' => 'ul',
    
  5. 然后,我们将启用列表视图的排序功能,并指定哪些模型属性可以用于排序:

        'enableSorting' => true,
        'sortableAttributes' => array(
           'time',
           'title'
        ),
    
  6. 最后,我们需要指定 itemView,这将定义列表中每个项目的样子:

        'itemView'=>'_event'
    ));
    

在此文件的末尾,我们还应该注册用于使视图看起来更漂亮的 CSS,同时创建 CSS 文件 /css/calendar.css,以便在下一步中 Yii 不会抛出错误。请参考本章的源代码以检索 calendar.css 文件:

Yii::app()->clientScript->registerCssFile(Yii::app()->baseUrl . '/css/calendar.css');

创建项目视图

接下来,我们需要创建的是 itemView 文件,即 protected/views/events/_event.php,如下所示:

<li class="event" data-attr-id="<?php echo $data->id; ?>">
   <div class="time"><?php echo gmdate("H:i", $data->time); ?></div>
   <h2 class="title"><?php echo CHtml::encode($data->title); ?></h2>
</li>

为了节省以后的时间,让我们先实现一个视图来显示特定事件的详细信息,在protected/views/events/details.php。当我们创建EventListView时,我们将添加 JavaScript 绑定来显示它。从项目资源文件夹中获取此文件,并将其添加到你的应用程序中。

创建事件列表视图

在我们的视图就绪后,我们现在需要实现我们的EventListView,它将显示我们的日历选择器和事件。步骤如下:

  1. 要做到这一点,在protected/components中创建一个名为EventListView.php的新文件。这个类应该扩展CListView,我们将必须显式加载它以使 Yii 了解它。通过扩展CListView,我们立即可以访问几个有用的函数,例如排序和显示我们的事件:

    <?php
    Yii::import('zii.widgets.CListView');
    class EventListView extends CListView {}
    
  2. 接下来,我们需要创建另一个自定义获取器来从 URL 中检索当前日期:

    public function getDate()
    {
        if (isset($_GET['date']))
           return $_GET['date'];
    
        return gmdate("Y-m-d");
    }
    
  3. 现在,我们将重载CListViewrenderItems()方法,这将允许我们按我们的喜好显示我们的事件。为此,创建renderItems()方法,如下所示:

    public function renderItems()
    {
       echo CHtml::openTag('div', array('class' => 'event_container'));
       echo CHtml::closeTag('div');
    }
    
  4. 在我们刚刚创建的events_container div中,我们需要添加我们的月/年选择器。这些链接将通过当前日期确定下一个和上一个月份和年份,它将从这个我们之前定义的getDate()方法中检索:

    echo CHtml::openTag('div', array('class' => 'month_year_picker'));
       echo CHtml::link(NULL, $this->controller->createUrl('/event', array('date' => gmdate("Y-m-d", strtotime($this->date ." previous year")))), array('class' => 'fa fa-angle-double-left pull-left'));
       echo CHtml::link(NULL, $this->controller->createUrl('/event', array('date' => gmdate("Y-m-d", strtotime($this->date ." previous month")))), array('class' => 'fa fa-angle-left pull-left'));
       echo CHtml::tag('span', array(), date('M Y', strtotime($this->date)));
       echo CHtml::link(NULL, $this->controller->createUrl('/event', array('date' => gmdate("Y-m-d", strtotime($this->date ." next year")))), array('class' => 'fa fa-angle-double-right pull-right'));
       echo CHtml::link(NULL, $this->controller->createUrl('/event', array('date' => gmdate("Y-m-d", strtotime($this->date ." next month")))), array('class' => 'fa fa-angle-right pull-right'));
    echo CHtml::closeTag('div');
    
  5. 紧接着这个关闭div之后,我们需要添加我们的日期选择器,它将显示当前选定日期两侧的 15 天。我们可以这样实现它:

    echo CHtml::openTag('div', array('class' => 'day_picker'));
       echo CHtml::openTag('ul');
          $this->renderDays(gmdate('Y-m-d', strtotime($this->date . ' -15 days')), $this->date);
          $this->renderDays($this->date, gmdate('Y-m-d', strtotime($this->date . ' +15 days')));
       echo CHtml::closeTag('ul');
    echo CHtml::closeTag('div');
    
  6. 为了使我们的生活更简单,我们可以创建一个实用方法,它会自动显示一系列日期,称为renderDays()。这将使我们的代码更易于阅读和调试,如果我们需要的话。这个方法应该接受两个参数:一个开始日期和一个结束日期:

    private function renderDays($start, $end)
    {
       $start    = new DateTime($start);
       $end      = new DateTime($end);
       $interval = new DateInterval('P1D');
       $period   = new DatePeriod($start, $interval, $end);
    
       foreach ($period as $dt)
          $this->renderDay($dt->format('Y-m-d'));
    }
    
  7. 然后,我们需要创建另一个实用方法来显示特定日期并提供一个链接:

    private function renderDay($date)
    {
       $class = 'day';
       if ($this->date == $date)
          $class .= ' selected';
       echo CHtml::openTag('li', array('class' => $class));
          echo CHtml::tag('span', array('class' => 'day_string'), gmdate('D', strtotime($date)));
          echo CHtml::link(date('d', strtotime($date)), $this->controller->createUrl('/event', array('date' => gmdate('Y-m-d', strtotime($date)))), array('class' => 'day_date'));
       echo CHtml::closeTag('li');
    }
    

我们自定义视图的最后一部分是一个容器,用于显示排序器、项目以及特定项目的详细信息。我们应该立即添加我们之前打开的day_picker div。因为我们利用了CListView,我们可以简单地引用父类的renderItems()方法来显示我们所有的项目,以及父类的renderSorter()方法来根据我们在索引视图中传递的配置显示排序器:

echo CHtml::openTag('div', array('class' => 'outer_container'));
   echo CHtml::openTag('div', array('class' => 'inner_container'));
      echo CHtml::openTag('div', array('class' => 'selected_date'));
         echo CHtml::tag('span', array('class' => 'selected_date_date'), gmdate("l F d Y", strtotime($this->date)));
      echo CHtml::closeTag('div');
      $this->renderSorter();
      parent::renderItems();
   echo CHtml::closeTag('div');

   // Details container is populated via Ajax Request
   echo CHtml::tag('div', array('class' => 'details'), NULL);
   echo CHtml::tag('div', array('class' => 'clearfix'), NULL);
echo CHtml::closeTag('div');

然后,让我们添加一些 AJAX 来在点击事件时显示事件的详细信息,如果事件有任何附加的提醒,则移除它。我们可以在关闭renderItems()方法之前添加这个:

Yii::app()->clientScript->registerScript('li_click', '
   $(".items li").click(function() {
      var id = $(this).attr("data-attr-id");
      $.get("/event/details/" + id, function(data) {
         $(".details").replaceWith(data);

         $(".fa-times").click(function() {
            var id = $(this).parent().attr("id");
            var self = $(this).parent();
            $.post("/reminder/delete/id/" + id, function() {
               $(self).remove();
            })
         });
      });
   });
');

一旦你将关联项目源代码中的calendar.css文件中的 CSS 添加进来,我们的视图就应该完成了。看看下面的截图:

创建事件列表视图

创建和保存事件

现在我们有了显示事件的方法,我们需要实际创建它们。这个视图将允许我们保存事件以及动态向现有事件添加多个提醒。首先创建protected/views/events/save.php,如下所示:

  1. 首先,我们将创建修改事件核心属性(标题、日期和时间)所需的功能:

    <h3><?php echo $model->isNewRecord ? 'Create New' : 'Update'; ?> Event</h3>
    <?php $form=$this->beginWidget('CActiveForm', array(
       'id'=>'project-form',
       'htmlOptions' => array(
          'class' => 'form-horizontal',
          'role' => 'form'
       )
    )); ?>
       <?php echo $form->errorSummary($model); ?>
    
       <div class="form-group">
          <?php echo $form->labelEx($model,'title', array('class' => 'col-sm-2 control-label')); ?>
          <div class="col-sm-10">
             <?php echo $form->textField($model,'title', array('class' => 'form-control')); ?>
          </div>
       </div>
    
       <div class="form-group">
          <?php echo $form->labelEx($model,'data', array('class' => 'col-sm-2 control-label')); ?>
          <div class="col-sm-10">
             <?php echo $form->textArea($model,'data', array('class' => 'form-control')); ?>
          </div>
       </div>
    
       <div class="form-group">
          <?php echo $form->labelEx($model,'time', array('class' => 'col-sm-2 control-label')); ?>
          <div class="col-sm-10">
             <div class="input-append date">
                <?php echo $form->textField($model, 'time', array('value' => $model->isNewRecord ? NULL : gmdate('Y-m-d H:i:s', $model->time), 'class' => 'form-control')); ?>
             </div>
          </div>
       </div>
    
  2. 接下来,如果我们已经创建了事件,我们希望显示所有附加到事件上的提醒。由于我们已经建立了提醒和事件之间的关系,我们可以通过迭代$events->reminders关系来实现,这将填充与我们的事件相关联的所有提醒:

    <?php if (!$model->isNewRecord): ?>
       <input type="hidden" id="event_id" value="<?php echo $model->id; ?>" />
       <hr />
       <h3>Reminders</h3>
       <div class="reminders_container">
          <?php foreach ($model->reminders as $reminder): ?>
             <div class="form-group">
                <?php echo CHtml::tag('label', array('class' => 'col-sm-2 control-label'), 'Reminder'); ?>
                <div class="col-sm-9">
                   <?php echo CHtml::tag('input', array(
                      'id' => $reminder->id,
                      'name' => 'Reminders[' . $reminder->id . '][time]',
                      'class' => 'form-control',
                      'value' => gmdate('Y-m-d H:i:s', $reminder->time)
                   ), NULL); ?>
                </div>
                <span class="fa fa-times"></span>
             </div>
          <?php endforeach; ?>
       </div>
    <?php endif; ?>
    
  3. 在这个if子句中,我们还想创建一个模板提醒,我们可以使用 JavaScript 将其附加和克隆。这将允许我们为事件创建尽可能多的提醒:

    <div class="form-group template" style="display:none">
       <?php echo CHtml::tag('label', array('class' => 'col-sm-2 control-label'), 'Reminder'); ?>
       <div class="col-sm-9">
          <?php echo CHtml::tag('input', array(
             'id' => NULL,
             'name' => 'Reminders[0][time]',
             'class' => 'form-control'
          ), NULL); ?>
       </div>
    </div>
    
  4. 最后,我们需要添加一些按钮并关闭我们的小部件:

    <div class="row buttons">
       <?php echo CHtml::submitButton($model->isNewRecord ? 'Create' : 'Save', array('class' => 'btn btn-primary pull-right col-md-offset-1')); ?>
    
       <?php if (!$model->isNewRecord): ?>
          <?php echo CHtml::link('Delete Event', $this->createUrl('/event/delete', array('id' => $model->id)), array('class' => 'btn btn-danger pull-right col-md-offset-1')); ?>
          <?php echo CHtml::link('Add Reminder', '#', array('class' => 'btn btn-success pull-right', 'id' => 'add_reminder')); ?>
       <?php endif; ?>
    </div>
    <?php $this->endWidget(); ?>
    

在当前状态下,我们的时间字段对用户来说并不十分友好,因为用户必须手动输入特定的日期时间戳,例如 2014-02-21 19:50:00。为了让用户体验更简单,我们可以从 GitHub 下载一个名为 bootstrap-datetimepicker 的插件。只需使用git将仓库克隆到应用程序的/js目录,或者直接下载该包:

git clone https://github.com/smalot/bootstrap-datetimepicker


然后,注册相关的 CSS 和 JavaScript:

<?php Yii::app()->clientScript->registerCssFile(Yii::app()->baseUrl . '/js/bootstrap-datetimepicker/css/bootstrap-datetimepicker.min.css'); ?>
<?php Yii::app()->clientScript->registerScriptFile(Yii::app()->baseUrl . '/js/bootstrap-datetimepicker/js/bootstrap-datetimepicker.js', CCLientScript::POS_END); ?>

最后,我们可以添加必要的 JavaScript 绑定来显示日期时间选择器,并动态添加新的提醒。在项目资源文件夹中,将protected/views/events/目录下的save.php文件中的剩余 JavaScript 代码复制到该文件中。

由于我们已经创建了保存和显示事件所需的所有控制器操作,我们现在可以创建和修改新事件,添加提醒,并在我们之前构建的界面上查看它们。看看吧!

创建用于管理用户的控制器

接下来,我们需要实现创建和修改我们应用程序中用户所需的方法。由于我们的users表还没有角色的概念,我们将通过CConsoleCommand从命令行管理用户。此方法将确保只有经过身份验证的用户(可以访问我们的服务器)才能修改用户信息。在实际应用中,此功能可以移动到我们应用程序中的受保护UsersController

创建用户

要开始用户管理,请在protected/commands/UserCommand.php中创建一个新的控制台命令,并添加以下内容:

<?php class UserCommand extends CConsoleCommand {}

CConsoleCommand类与我们控制器非常相似。在那里,我们可以定义要运行的操作以及我们想要添加的任何参数。我们应该创建的第一个操作是创建我们的用户。由于我们已经设置了用户模型来处理适当的密码散列,我们可以简单地使用以下内容:

public function actionCreateUser($email, $password)
{
   $model = new Users;
   $model->attributes = array(
      'email' => $email,
      'password' => $password
   );

   if (!$model->validate())
      echo "Missing Required Attribute\n";
   else
   {
      try {
         if ($model->save())
            echo "User Created\n";
         else
            print_r($model->getErrors);
         return;
      } catch (Exception $e) {
         print_r($e->getMessage());
      }
   }
}

然后,我们可以从命令行创建新用户,如下所示:

php protected/yiic.php user createuser --email=test@test.com --password=password123

如果成功,命令将输出User Created;否则,将返回错误。

删除用户

删除用户也可以是一个可调用的操作,它接受用户的电子邮件地址作为参数:

public function actionDeleteUser($email)
{
   $model = Users::model()->findByAttributes(array('email' => $email));
   if ($model == NULL)
   {
      echo "No user with that email was found.\n";
      return 0;
   }

   if ($model->delete())
      echo "User has been deleted.\n";
   else
      echo "User could not be deleted.\n";
}

然后,我们可以通过从我们的命令行运行以下命令来调用我们刚刚创建的操作:

php protected/yiic.php user deleteuser --email=test@test.com

修改用户的密码

接下来,我们将提供更改用户密码的功能。在我们更改用户密码之前,我们需要验证用户的身份。通常,我们通过验证他们是否有账户密码来完成这项工作。我们可以在protected/commands/UserCommand.php中按照以下方式实现这一点:

public function actionChangePassword($email, $oldPassword, $newPassword)
{
   $model = Users::model()->findByAttributes(array('email' => $email));

   if ($model == NULL)
   {
      echo "No user with that email was found.\n";
      return 0;
   }

   if (password_verify($oldPassword, $model->password))
   {
      $model->password = password_hash($newPassword, PASSWORD_BCRYPT, array('cost' => 13));

      if ($model->save())
         echo "Password has been changed.\n";
      else
         echo "Password could not be changed.\n";
   }
   else
      echo "Unable to Verify Old Password.\n";
}

再次利用 PHP 的password_*函数,这些函数包括验证密码的能力:

if (password_verify($oldPassword, $model->password))

假设用户的密码有效,然后我们可以对用户在命令行提供的密码进行散列,并将其与模型一起存储:

$model->password = password_hash($newPassword, PASSWORD_BCRYPT, array('cost' => 13));

从命令行运行此命令如下:

php protected/yiic.php user changepassword --email=test@test.com --oldpassword=password123 --newpassword=newsecurepassword

注意

虽然从命令行管理用户很简单,但并不安全,因为用户的密码可能以纯文本形式存储在您的终端命令历史记录中。在实际应用中,请考虑通过安全连接从 Web 界面管理用户。

使用 Bcrypt 进行认证

我们需要为我们的用户实现最后一件事情,那就是认证。为此,我们将扩展我们在第一章中开发的认证流程,任务管理应用,并将其修改为与我们的 Bcrypt 散列密码一起工作。

首先,从第一章的源代码(或本章的源代码)中复制以下文件到我们的项目中:

  • css/signin.css

  • protected/views/layouts/signin.php

  • protected/views/site/login.php

  • protected/models/LoginForm.php

  • protected/controllers/SiteController.php

  • protected/components/UserIdentity.php

由于验证用户的大部分工作已经完成,我们只需要修改我们的认证流程中的UserIdentity类。首先,打开protected/components/UserIdentity.php文件。我们将首先按照以下方式定义该类:

注意

Yii 可能已经为您生成了此文件。如果是这样,请删除其全部内容,并按照本节中概述的说明进行操作。

class UserIdentity extends CUserIdentity {}

执行以下步骤:

  1. 首先,我们想确保将数据库中每个用户的 ID 存储在我们的WebUser属性中。为此,创建一个新的私有属性$_id

    private $_id;
    
  2. 然后,创建一个 getter 来检索它:

    public function getId()
    {
       return $this->_id;
    }
    
  3. 接下来,我们需要定义我们的authenticate()方法,该方法将从LoginForm中调用:

    public function authenticate() {}
    
  4. 在此方法中,我们需要使用用户通过LoginForm提供的电子邮件地址找到适当的用户模型:

    $record = Users::model()->findByAttributes(array('email'=>$this->username));
    
  5. 使用这些信息,我们可以验证是否存在具有该电子邮件地址的用户:

    if ($record == NULL)
        $this->errorCode = self::ERROR_UNKNOWN_IDENTITY;
    
  6. 然后,我们需要验证用户的密码是否与我们记录的密码匹配。如果匹配,我们应该确保不向LoginForm返回任何错误,并设置WebUser ID:

    else if (password_verify($this->password, $record->password))
    {
       $this->errorCode = self::ERROR_NONE;
       $this->_id        = $record->id;   
    }
    
  7. 然后,我们应该拒绝通过该方法传入的其他任何内容:

    else
       $this->errorCode = self::ERROR_UNKNOWN_IDENTITY;
    
  8. 最后,将错误代码返回给LoginForm

    return !$this->errorCode;
    

注意

在实际应用中,我们希望尽可能少地向用户或潜在攻击者暴露有关潜在登录尝试的信息,这就是为什么我们返回ERROR_UNKNOWN_IDENTITY。在调试您的应用程序时,您可能会发现返回ERROR_USERNAME_INVALIDERROR_PASSWORD_INVALID很有用,这有助于您更好地理解登录请求失败的原因。

需要身份验证

最后,我们可以通过在EventControllerReminderController中添加以下内容来强制用户对我们的数据库进行身份验证:

public function filters()
{
   return array(
        'accessControl',
   );
}

public function accessRules()
{
   return array(
        array('allow',
            'users'=>array('@'),
        ),
        array('deny',  // deny all users
            'users'=>array('*'),
        ),
    );
}

发送电子邮件提醒

到目前为止,用户可以通过我们的网络界面创建新的事件和提醒;然而,他们目前还不能接收这些提醒。为了发送这些提醒,我们将在protected/commands/RemindersCommand.php中创建一个新的控制台命令RemindersCommand。完成之后,我们可以将此命令添加到 crontab 或计划任务中,以便在后台自动处理提醒。

一旦创建了RemindersCommand文件,除了创建一个接受时间间隔作为参数的动作来发送提醒外,还需要创建类定义。这个时间间隔将定义我们应该运行命令的分钟数。它将找到该时间间隔时间段内的所有提醒进行处理:

class RemindersCommand extends CConsoleCommand
{
   public function actionSendReminders($interval) {}
}

在我们的动作中,定义我们应该开始的时间戳以及我们应该结束的时间,针对我们正在处理的时间间隔。结束时间应该在下一个间隔开始之前的所有微秒之前,这样我们就不发送重复的提醒:

$time = time();
$start = $time - ($time % $interval * 60);
$end = $start + (($interval *60) - 1));

定义了时间间隔后,我们现在可以使用CDBCriteria创建一个数据库搜索条件,我们可以将其传递给remindersfind()方法:

$criteria = new CDbCriteria;
$criteria->addBetweenCondition('offset', $start, $end);
$reminders = Reminders::model()->findAll($criteria);

find()方法将返回我们指定的所有时间间隔内的提醒。现在我们可以简单地遍历$reminders数组,并向提醒所属的用户发送电子邮件:

foreach ($reminders as $reminder)
{
   // Load the PHPMailer Class
   $mail = new PHPMailer;

   // Tell PHP Mailer to use SMTP with authentication
   $mail->isSMTP();
   $mail->SMTPAuth = true;

      // Specify the Host and Port we should connect to
   $mail->Host = Yii::app()->params['smtp']['host'];
   $mail->Port = Yii::app()->params['smtp']['port'];

   // Specify the username and password we should use
   // (if required by our SMTP server)
   $mail->Username = Yii::app()->params['smtp']['username'];
   $mail->Password = Yii::app()->params['smtp']['password'];

   // Set the security type of required
   $mail->SMTPSecure = 'tls';

   // Set the from and to addresses
   $mail->from = Yii::app()->params['smtp']['from'];
   $mail->addAddress($reminder->event->user->email);

   // This should be an HTML email
   $mail->isHTML(true);

   // Set the subject and body
   $mail->Subject ='Reminder from Scheduled Reminders';
   $mail->Body = 'This is a reminder that '.$reminder->event->title.' is due on '. gmdate("Y-m-d H:i UTC", $reminder->offset) . '. This event has the following details:<br />' . $reminder->event->data;

   // Then send the email
   if (!$mail->send())
        echo $mail->ErrorInfo . "\n";
   else
        echo ".";
}

注意

如果您正在使用远程 SMTP 服务器,并且已经将 SMTP 信息填充到protected/config/params.php文件中,则前面的代码应该适用于您。如果您正在使用本地邮件服务器,如 Postfix 或其他配置,请确保阅读 PHPMailer 文档github.com/PHPMailer/PHPMailer,了解如何正确配置 PHPMailer。

从命令行,我们现在可以通过运行以下命令来发送提醒(在示例中我们使用的是5分钟间隔):

php protected/yiic.php reminders sendreminder --interval=5

一旦你在数据库中创建了事件,你可以运行该命令或将此命令放入你的 crontab 或计划任务中,让你的应用程序自动向用户发送提醒。

摘要

我们在本章中涵盖了大量的信息!我们学习了如何将我们的应用程序与 MySQL 数据库集成,开始安全地存储用户信息到我们的数据库中,并扩展了我们对控制台命令的知识。我们还介绍了如何为我们的模型添加行为和关系。此外,我们还讨论了如何将 Composer 及其依赖项包含到我们的项目中,以减少需要手动导入的代码量。

在下一章中,我们将扩展我们在本章中学到的知识和我们开发的工具,以构建更复杂和集成的 Web 应用程序。在继续之前,请务必查看官方 Yii 文档中我们本章引用的所有类,文档位于www.yiiframework.com/doc/

第四章。开发问题跟踪应用程序

在前面的章节中,我们研究了非常简单和实用的应用。随着我们继续前进,我们的应用将变得更加复杂和精细。对于我们接下来的项目,我们将开发一个问题跟踪系统,该系统将允许客户报告问题,并使我们能够从单一应用程序中管理这些用户和问题。在这个应用程序中,我们还将提供通过电子邮件创建和更新问题的支持。最后,我们将扩展我们的用户管理系统,以允许为每个用户分配角色。

在本章中,我们将涵盖以下主题:

  • 创建用户管理界面

  • 添加基于角色的授权

  • 从 Yii 应用程序发送和接收电子邮件

  • 将第三方库和工具集成到我们的应用程序中

先决条件

在我们开始之前,有一些事情我们需要设置并运行:

  • 由于我们将从我们的应用程序发送和接收电子邮件,我们需要一个注册并活跃的域名。如果您还没有一个正在工作的域名,您可以从域名注册商那里购买一个,例如 www.namecheap.comwww.name.com,或 www.gandi.net

  • 我们还需要能够修改该域名的域名系统(DNS)记录。为了我们的应用程序能够接收电子邮件,我们需要能够修改我们域名的 DNS 记录。大多数注册商提供基本的 DNS 管理系统。如果您的注册商没有,您可以使用免费的 DNS 托管服务,例如 www.cloudflare.comwww.rackspace.com/cloud/dns

  • 接下来,您需要一个具有公开 IP 地址的 Web 服务器。这将允许电子邮件发送到我们的应用程序。许多云虚拟专用服务器VPS)提供商以低月费或时费提供使用。这些服务包括 www.digitalocean.comwww.linode.com,以及 www.rackspace.com/cloud/servers

  • 我们不必创建、配置和维护自己的电子邮件服务器和 SMTP 中继,我们可以利用第三方工具和库。这将使我们能够专注于应用程序的开发,而不是次要服务的维护。使用这项服务和其伴随的 PHP 库,我们可以利用已经经过彻底测试和审查的代码,这使我们作为开发者能够直接进入编码。为了利用 SendGrid,我们将创建一个免费的 SendGrid 开发者账户,该账户可以在 www.sendgrid.com/developers 上设置。目前,只需设置您的账户。在章节的后面部分,我们将介绍如何设置我们的应用程序以接收来自该服务的电子邮件。

  • 在本章中,我们还将使用最新的 MySQL 版本(在撰写本文时为 MySQL 5.6)。请确保你的 MySQL 服务器已经设置并运行在你的服务器上。

  • 最后,我们需要从getcomposer.org/下载并安装 Composer。

一旦你获得了前面步骤中列出的所有内容,在你的域名上创建一个子域名,并将其指向你的服务器。在本章中,我将使用chapter4.example.com来指代这个子域名。在一切设置完毕并且你的服务器对该域名做出响应后,我们就可以开始了。

描述项目

我们的问题跟踪项目可以分为以下三个主要组成部分:

  • 将创建和响应问题的用户

  • 可以由最终用户或支持者(一种将支持我们的最终用户的特定类型的用户)更新的问题

  • SendGrid 可以 POST 任何我们可能收到的电子邮件的公开可用端点

用户

我们应用程序的第一个组成部分是将会使用它的用户。对于这个应用程序,我们将使用与我们在第三章中相同的数据库结构,即“计划提醒”,并增加一个名为role_id的新列,这将允许我们区分用户在我们应用程序中的位置。对于这个应用程序,我们将扩展我们的登录过程,以确保用户的角色可供我们在应用程序中引用和操作。

角色

而不是有一个能够管理我们系统的单个管理员,在这个应用程序中,我们可以有多个用户,我们可以提升或降低他们在我们应用程序中的不同角色。我们与用户关联的角色将允许我们确定该角色的用户在我们应用程序中可以做什么。

对于这个应用程序,我们将支持三个基本角色:一个提交问题和更新的客户,一个拥有与客户相同权限并且能够更新属于其他客户的问题的支持者,以及一个拥有与支持者相同权限并且可以管理其他用户角色的管理员。

为了存储这些信息,我们将在数据库设置中使用一个简单的角色表,如下所示。然后我们将设置用户和角色之间的关系,以便这些信息自动与我们的用户关联。

ID INTEGER PRIMARY KEY
name STRING
created INTEGER
updated INTEGER

问题

我们应用程序的第二个组成部分是用户将创建的问题。问题是一个可以创建于应用程序内部或通过向我们的应用程序发送电子邮件从外部创建的项目。问题也可以从应用程序内部或通过客户发送的电子邮件进行更新。问题还将有一个与之关联的状态,这将帮助我们的支持者跟踪特定问题的当前项目。我们将使用的数据库将如下所示:

ID INTEGER PRIMARY KEY
customer_id INTEGER FK
title STRING
description TEXT
status_id INTEGER FK
created INTEGER
updated INTEGER

状态

每个问题都将有一个唯一的状态。这些状态将允许我们的支持者跟踪问题的项目,并允许我们在问题从一个状态变为另一个状态时触发特定事件。我们用于这些记录的表将与我们的角色表相同:

ID INTEGER PRIMARY KEY
name STRING
created INTEGER
updated INTEGER

更新

每个问题都与一个更新相关联。每个问题可以有一个或多个更新附加到它们,这将允许支持者看到对特定问题所做的工作,并作为用户与我们支持者沟通的媒介。每个更新都将与一个用户和一个问题相关联。我们用于此信息的数据库表将如下所示:

ID INTEGER PRIMARY KEY
issue_id INTEGER FK
author_id INTEGER FK
update TEXT
created INTEGER
updated INTEGER

接收电子邮件

我们应用程序的最后一个组件将允许客户通过电子邮件创建新问题并更新现有问题。对于最终用户来说,这个过程将感觉无缝,同时它将允许我们的支持者跟踪特定问题的工作和更新。此自定义端点还将允许我们在应用程序中无缝创建新用户,并在必要时将信息与这些用户关联。

初始化项目

到现在为止,您应该已经熟悉了从头创建项目。为了提供一个共同起点,本章的项目资源中包含了一个骨架项目。此骨架项目包括必要的迁移、数据文件、控制器和视图,以帮助我们开始。还包括我们将用于本章整个章节的登录系统。

我们将首先将章节资源中包含的骨架项目复制到我们的 Web 服务器上,并按照章节开头概述的配置,使其响应chapter4.example.com,然后按照以下步骤进行,以确保一切设置正确:

  1. 由于提供了一个骨架项目,请首先调整index.php中的 Yii 框架路径,使其指向您的 Yii 安装路径。在此阶段,您还希望调整assetsprotected/runtime文件夹的权限。

  2. 接下来,创建我们的应用程序将使用的 MySQL 用户和数据库表。如果您不想修改提供的默认配置文件,以下 MySQL 命令将为您创建数据库和用户:

    CREATE USER 'ch4_issue'@'localhost' IDENTIFIED BY 'ch4_issue';
    CREATE DATABASE IF NOT EXISTS  `ch4_issue` ;
    GRANT ALL PRIVILEGES ON  `ch4\_issue` . * TO  'ch4_issue'@'localhost';
    FLUSH PRIVILEGES;
    
  3. 接下来,我们需要运行初始迁移,然后导入protected/data文件夹中提供的示例数据。这些示例数据将使我们能够在应用程序运行后立即登录并开始使用它。导航到项目根目录,然后运行以下命令:

    php protected/yiic.php migrate up --interactive=0
    mysql –u ch4_issue –p ch4_issue < protected/data/combined.sql
    
    
  4. 我们需要在protected/config/中的params.php更新我们的 SendGrid 信息。您的用户名和密码将与您的 SendGrid 用户名和密码相对应。按照我们的示例域名,将发件人地址设置为noreply@chapter4.example.com

  5. 最后,我们需要安装必要的 composer 依赖项:

    composer install
    
    

执行这些步骤后,您应该能够在浏览器中导航到chapter4.example.com,并看到我们的应用程序的登录页面。在登录到我们的应用程序并使用本段之后表格中提供的凭证之一后,您应该看到表格之后的页面加载:

用户名 密码
customer@example.com test
supporter@example.com test
admin@example.com test

初始化项目

管理用户

在我们开始处理问题之前,我们首先需要确保用户可以从我们的应用程序中创建和管理。在第三章“计划提醒”中,我们使用命令行工具来完成这个任务。在本章中,我们将从 Web 界面创建一个完整的管理工具,以补充该工具。

角色和认证

在我们开始管理用户之前,让我们看看在我们的应用程序中如何处理认证和角色。在骨架应用程序提供的UserControllerIssueController中有一个更复杂的accessRules()方法,它添加了一个新属性。让我们看看UserController中的这个方法:

public function accessRules()
{
   return array(
      array('allow',
         'actions' => array('search', 'view'),
         'users'=>array('@'),
         'expression' => 'Yii::app()->user->role>=2'
      ),
      array('allow',
         'actions' => array('index', 'save', 'delete'),
         'users'=>array('@'),
         'expression' => 'Yii::app()->user->role==3'
      ),
      array('deny',  // deny all users
         'users'=>array('*'),
      ),
   );
}

如您所见,我们现在在这个方法中列出了一个名为expression的新属性。在内部,Yii 将评估这个表达式为一个布尔值。如果该表达式解析为 true,并且操作和用户条件匹配,则允许用户继续执行操作。在我们的情况下,我们正在检查Yii::app()->user->role是否具有特定的值。

默认情况下,Yii 不知道该值应该是多少,除非我们定义它,否则它将是未定义的。由于Yii::app()->user是一个CWebUser对象,我们可以在创建UserIdentity组件时向其添加额外的信息。如果我们查看项目提供的UserIdentity组件,我们可以看到这个属性是通过CUserIdentity setState()方法添加的:

public function authenticate()
{
   $record = User::model()->findByAttributes(array('email'=>$this->username));

   if ($record == NULL)
      $this->errorCode = self::ERROR_UNKNOWN_IDENTITY;
   else if (password_verify($this->password, $record->password))
   {
      $this->errorCode = self::ERROR_NONE;
      $this->_id 		 = $record->id;
      $this->setState('email', $record->email);
      $this->setState('role', $record->role_id);
   }
   else
      $this->errorCode = self::ERROR_UNKNOWN_IDENTITY;

   return !$this->errorCode;
}

用户登录后,Yii 将把此信息存储在我们的$_SESSION变量中,允许我们在会话活跃期间引用它。

注意

虽然使用简单的布尔表达式很容易,但如果我们想更改哪些用户可以访问我们的系统,我们就必须重构我们的控制器方法,而不是数据库中的数据。相反,可以考虑创建一个模型方法,例如User::isSupporter()User::isAdmin()。这些方法使谁有权访问我们的操作更加清晰,并将使您的应用程序在未来更容易维护。

列出用户

现在我们已经了解了在我们的应用程序中角色是如何工作的,让我们开始构建我们的UserController控制器方法。打开protected/controllers/UserController.php,您可以看到我们已经为将要实现的方法提供了定义。

要显示用户列表,我们将在控制器中使用 User::search() 方法,并在视图中使用 CGridView 小部件:

public function actionIndex()
{
   $users = new User('search');
   $users->unsetAttributes();

   if (isset($_GET['User']))
      $users->attributes = $_GET['Users'];

   $this->render('index', array(
      'model' => $users
   ));
}

views/user/index.php 文件中,我们将加载一个 CGridView 实例:

<h3>Manage Users</h3>
<?php $this->widget('zii.widgets.grid.CGridView', array(
   'itemsCssClass' => 'table table-striped',
   'enableSorting' => true,
   'dataProvider'  =>$model->search(),
    'columns' => array(
        'id',
        'email',
        'name',
       array(
            'class'=>'CButtonColumn',
            'template' => '{view}{update}{delete}',
            'viewButtonOptions' => array(
                'class' => 'fa fa-search'
            ),
            'viewButtonLabel' => false,
            'viewButtonImageUrl' => false,
            'viewButtonUrl' => 'Yii::app()->createUrl("user/view", array("id" => "$data->id"))',
            'updateButtonOptions' => array(
               'class' => 'fa fa-pencil'
            ),
            'updateButtonLabel' => false,
            'updateButtonImageUrl' => false,
            'updateButtonUrl' => 'Yii::app()->createUrl("user/save", array("id" => "$data->id"))',
            'deleteButtonOptions' => array(
                'class' => 'fa fa-trash-o'
            ),
            'deleteButtonLabel' => false,
            'deleteButtonImageUrl' => false,
            'deleteButtonUrl' => 'Yii::app()->createUrl("user/delete", array("id" => "$data->id"))'
        ),
    )
));

在我们的 CGridView 实例的列属性中,我们定义了一个名为 CButtonColumn 的自定义列。CButtonColumn 允许我们在 CGridView 实例中添加一系列有用的按钮,例如查看按钮、更新按钮和删除按钮,并包含所有必要的 JavaScript。通过利用这个列,我们现在可以快速从我们的视图中访问这些操作。

注意

你可以在其 Yii 类参考页面了解更多关于 CButtonColumn 的信息,页面地址为 www.yiiframework.com/doc/api/1.1/CButtonColumn.

删除用户

接下来,我们应该实现一个 actionDelete() 方法来处理我们的删除按钮。为了简化操作,我们可以添加一个有用的 loadModel() 方法来为我们执行所有必要的检查。查看以下代码:

public function actionDelete($id=NULL)
{
   if ($id == Yii::app()->user->id)
      throw new CHttpException(403, 'You cannot delete yourself');

   $user = $this->loadModel($id);

   if ($user->delete())
      $this->redirect($this->createUrl('user/index'));

   throw new CHttpException(400, 'Bad Request');
}

private function loadModel($id=NULL)
{
   if ($id == NULL)
      throw new CHttpException(400, 'Missing ID');

   $model = User::model()->findByPk($id);

   if ($model == NULL)
      throw new CHttpException(404, 'No user with that ID could be found');

   return $model;
}

创建和更新用户

接下来,我们可以创建一个 actionSave() 方法来处理创建和更新用户。由于我们的视图将传递给我们所有需要的信息,我们使用简单的 $user->save() 调用来保存我们的信息。查看以下代码:

public function actionSave($id=NULL)
{
   if ($id == NULL)
      $user = new User;
   else
      $user = $this->loadModel($id);

   if (isset($_POST['User']))
   {
      $user->attributes = $_POST['User'];

      try
      {
         if ($user->save())
         {
            Yii::app()->user->setFlash('success', 'The user has sucessfully been updated');
            $this->redirect($this->createUrl('user/save', array('id' => $user->id)));
         }
      } catch (Exception $e) {
         $user->addError('email', 'A user with that email address already exists');
      }
   }

   $this->render('save', array(
      'model' => $user
   ));
}

在这个操作中,我们还故意在 save 方法周围添加了一个 try/catch 块。我们这样做是因为我们在数据库的 email 字段上设置了一个唯一索引约束。如果我们尝试将两个具有相同电子邮件的用户保存到我们的数据库中,由于 Yii 不知道如何处理约束,它将抛出一个内部错误。在我们的控制器中,我们可以捕获这个错误,并通过在视图中使用 $user->addError() 方法,在 $form->errorSummary($model) 中向用户返回一个更友好的错误信息。

然后,将项目资源文件夹中的 view/user/save.php 文件复制到你的项目中。在我们的视图中,我们可以使用 CHtml::listData() 来填充一个下拉选择框,显示我们数据库中当前所有的角色。使用这种方法,我们可以在未来无需修改视图的情况下向数据库中添加新的角色:

CHtml::listData(Role::model()->findAll(), 'id', 'name');

查看用户和相关问题

最后,我们应该创建一个视图来显示特定用户及其当前分配的所有未解决问题的详细信息。对于我们的 actionView() 方法,添加以下代码:

public function actionView($id=NULL)
{
    $user = $this->loadModel($id);
    $issues = new Issue('search');
    $issues->unsetAttributes();

    if(isset($_GET['Issue']))
        $issues->attributes=$_GET['Issue'];
    $issues->status_id = '<5';

    $issues->customer_id = $user->id;

    $this->render('view', array(
       'user' 	 => $user,
      'issues' => $issues
   ));
}

然后,将 views/user/ 中的 view.php 文件从项目资源文件夹复制到我们的项目中,并打开它。在文件的底部,你会看到一个 renderPartial() 调用,用于渲染我们尚未创建的视图:

<?php $this->renderPartial('//issue/issues', array('model' => $issues)); ?>

注意

在 Yii 中,布局前的 // 注释表示 Yii 应该在主应用程序的 views 文件夹中搜索视图文件。你可以在 www.yiiframework.com/doc/api/1.1/CController#getLayoutFile-detail 了解更多关于 Yii 加载视图文件的信息。

我们将在整个应用程序中使用这个视图文件,以确保所有列表看起来一致。在继续之前,让我们创建这个问题视图。在 views/issues/issue.php 中创建一个新文件,并添加以下 CGridView 小部件:

<?php $this->widget('zii.widgets.grid.CGridView', array(
   'itemsCssClass' => 'table table-striped',
   'enableSorting' => true,
    'dataProvider'=>$model->search(),
    'columns' => array(
       'id',
       'customer_id' => array(
          'name' => 'Customer',
          'value' => '$data->customer->name'
       ),
       'title',
        'status_id' => array(
            'name' => 'Status',
            'value' => '$data->status->name'
        ),
       'updated' => array(
          'name' => 'Last Updated',
          'value' => 'date("F m, Y @ H:i", $data->updated) . " UTC"'
       ),
       array(
            'class'=>'CButtonColumn',
            'template' => '{update}',
            'updateButtonOptions' => array(
            	'class' => 'fa fa-pencil'
            ),
            'updateButtonLabel' => false,
            'updateButtonImageUrl' => false
        ),
    )
));

虽然我们的视图现在可以渲染,但我们数据库中还没有任何问题可以显示,因此不会显示结果。一旦我们添加了问题,我们就可以回到这个视图来查看与用户相关联的所有问题。

实现问题管理组件

我们应用程序的核心是用户将提交的问题。对于这个应用程序,我们将假设用户将为自己提交新问题,支持者将支持这些问题。为了确保只为登录用户创建问题,我们必须对我们的问题模型进行一些更改。打开 protected/models/Issues.php,让我们开始吧。

问题模型

在我们的骨架模型顶部提供了属性,这些属性将帮助我们稍后在模型中使用:

   private $_isNewRecord = false;
   public  $_isEmailCreate = false;

第一个属性 $_isNewRecord 是一个布尔值,我们将在 afterSave() 方法中使用它来决定发送什么电子邮件。虽然 CActiveRecord 提供了一个名为 $isNewRecord 的属性,但 Yii 在 afterSave() 方法之前将此值更改为 FALSE。

第二个属性 $_isEmailCreate 也是一个布尔值。由于我们收到的电子邮件不会有与之关联的会话,我们需要知道将问题关联给哪个用户。由于我们将限制问题的所有者为当前登录用户,我们需要一种方法来覆盖电子邮件提交的行为。

在验证这些属性已添加后,我们可以开始工作于需要添加到该模型的其他方法:

  1. 我们在 Issue 模型中需要实现的第一种方法是 beforeSave() 方法,以限制问题的客户。在这个方法中,我们还想将新问题的状态设置为 New,并标记我们的 $_isNewRecord 属性,以便我们可以在 afterSave() 方法中使用它。此外,我们还想防止对 customer_id 的意外更改,如果它在现有问题中意外更改:

    public function beforeSave()
    {
       if ($this->isNewRecord)
       {
          // If this is a new issue, set the customer_id to be the currently logged in user
          if (!$this->_isEmailCreate)
             $this->customer_id = Yii::app()->user->id;
    
          // And set the status to 'New'
          $this->status_id = 1;
    
          // Set IsNewRecord so that afterSave can pick this up
          $this->_isNewRecord = true;
       }
       else // Otherwise reset the customer_id back to what it previously was (prevent it from being changed)
          $this->customer_id = $this->_oldAttributes['customer_id'];
    
       return parent::beforeSave();
    }
    
  2. 接下来,我们需要更新 afterSave() 方法,以便向客户发送电子邮件。对于这个模型,如果为他们创建了一个问题或解决了问题的状态,我们将向用户发送电子邮件。为此,我们将使用 SendGrid。在添加此方法之前,请确保您的 protected/config/ 目录中的 params.php 文件包含正确的凭证:

    public function afterSave()
    {
       $user = User::model()->findByPk($this->customer_id);
       $sendgrid = new SendGrid(Yii::app()->params['sendgrid']['username'], Yii::app()->params['sendgrid']['password']);
       $email    = new SendGrid\Email();
       $email->setFrom(Yii::app()->params['sendgrid']['from'])
            ->addTo($user->email);
    
       if ($this->_isNewRecord)
       {
          $email->setSubject("[Issue #$this->id] $this->subject | A New Issue Has Been Created For You")
               ->setText('Issue has been created')
               ->setHtml(Yii::app()->controller->renderPartial('//email/created', array('issue' => $this, 'user' => $user), true));
    
          // Send the SendGrid email
          $sendgrid->send($email);
       }
       else
       {
          if ($this->status_id == 5 && $this->_oldAttributes['status'] != 5)
          {
             $email->addTo($user->email)
               ->setSubject("[Issue #$this->id] Issue has been resolved")
               ->setText('Issue has been resolved')
               ->setHtml(Yii::app()->controller->renderPartial('//email/resolved', array('issue' => $this, 'user' => $user), true));
    
             // Send the SendGrid email
             $sendgrid->send($email);
          }
       }
    
       return parent::afterSave();
    }
    
  3. 我们需要对 Issue 模型进行的最后一个更改是在 search() 方法中。理想情况下,我们希望我们的支持者能够通过问题的 ID 或标题或描述中的关键字来搜索问题。为此,我们可以简单地重用 Issue::search() 方法,通过更改 $criteria->compare() 调用这两个属性来使用 $criteria->addSearchCondition()

    $criteria->addSearchCondition('title',$this->title,true, 'OR');
    $criteria->addSearchCondition('description',$this->title,true, 'OR');
    

问题更新模型

在开始工作于 IssueController 之前,我们还需要对我们的 protected/models/Update.php 模型进行一些更改。这些更改将允许我们自动将正确的所有者分配给更新,并帮助我们向用户发送在议题中添加更新时的电子邮件。

再次在我们的模型中,我们有一个属性,我们可以用它来确定这个更新是否来自电子邮件:

public $isEmailUpdate = false;

在这个模型中,我们使用这个属性来确定是否应该向用户发送电子邮件,因为我们不应该通知用户他们提交的更新。

此外,我们还需要对我们的模型方法进行两项更新:

  1. 我们需要对模型进行的第一次更新是在 beforeSave() 方法中。如果用户已登录,该更新的作者应分配给该用户。请查看以下代码:

    public function beforeSave()
    {
       // Allow the author_id to be set, but reset it to the logged in user->id if it isn't set
       if ($this->author_id == NULL)
          $this->author_id = Yii::app()->user->id;
    
       if ($this->update == '')
          return false;
    
       return parent::beforeSave();
    }
    
  2. 然后,我们应该更新我们的 afterSave() 方法,以便在适当的实例中向用户发送电子邮件:

    public function afterSave()
    {
       // If the issue was created by the currently logged in user, or this is an email update, don't send an email
       $issue = Issue::model()->findByPk($this->issue_id);
    
       // Don't send an email if the customer provides an update, if this came from email, or the status of the issue is resolved
       if ($issue->customer_id == Yii::app()->user->id || $this->isEmailUpdate || $issue->status_id == 5)
          return parent::afterSave();
    
       // If this is a NEW issue, send the user an email with the detais
       $user = User::model()->findByPk($issue->customer_id);
    
       // Init the SendGrid object and the Email Object
       $sendgrid = new SendGrid(Yii::app()->params['sendgrid']['username'], Yii::app()->params['sendgrid']['password']);
       $email    = new SendGrid\Email();
    
       $email->setFrom(Yii::app()->params['sendgrid']['from'])
            ->addTo($user->email)
            ->setSubject("[Issue #$issue->id] $this->subject | Issue has been updated")
            ->setText('Issue has been updated')
            ->setHtml(Yii::app()->controller->renderPartial('//email/updated', array('issue' => $issue, 'update' => $this, 'user' => $user), true));
    
       $sendgrid->send($email);
    
       return parent::afterSave();
    }
    

显示属于用户的议题

完成我们对模型的更新后,现在我们可以开始工作于 IssueController。我们应该实现的第一种方法是 actionIndex(),它将向登录用户展示所有当前分配给他们的未解决事项:

public function actionIndex()
{
    $issues = new Issue('search');
    $issues->unsetAttributes();

    if(isset($_GET['Issue']))
        $issues->attributes=$_GET['Issue'];

    // Don't search resolved issues
    $issues->status_id = '<5';

    $issues->customer_id = Yii::app()->user->id;

    $this->render('index', array(
      'issues' => $issues
   ));
}

然后,在我们的 index.php 文件 views/issue/ 中,我们可以重用我们之前创建的局部视图来显示所有这些议题:

<h3>My Issues</h3>
<?php $this->renderPartial('issues', array('model' => $issues)); ?>

搜索议题

我们需要实现的下一个方法是 actionSearch() 方法,它将允许我们通过议题 ID 或标题或描述中的关键词来搜索议题。为此,我们将创建一个搜索视图,该视图将带有搜索参数发送到我们的动作。如果那个 $_GET 参数是数字的,并且我们可以找到具有该 ID 的议题,我们将立即重定向到它。否则,我们将使用我们之前修改过的 Issue::search() 方法来搜索数据库中的所有议题。我们的控制器动作将如下所示:

public function actionSearch()
{
   $issues = new Issue('search');
   $issues->status_id = '<5';

   if (isset($_GET['issue']))
   {
      if (is_numeric($_GET['issue']))
      {
         $issue = Issue::model()->findByPk($_GET['issue']);
         if ($issue != NULL)
            $this->redirect($this->createUrl('issue/update', array('id' => $issue->id)));
      }

      $issues->title = $_GET['issue'];
      $issues->description = $_GET['issue'];
   }

   $this->render('search', array(
      'issues' => $issues
   ));
}

然后,我们的 search.php 文件在 views/issue/ 下的样子如下:

<h3>Search for Issues</h3>
<?php $form=$this->beginWidget('CActiveForm', array(
   'id'=>'project-form',
   'method' => 'get',
   'htmlOptions' => array(
      'class' => 'form-horizontal',
      'role' => 'form',
   )
)); ?>
   <p>Search for issues...</p>
   <div class="form-group">
      <?php echo CHtml::textField('issue', isset($_GET['issue']) ? $_GET['issue'] : NULL, array('class' => 'form-control', 'placeholder' => 'Search for Issues by ID, Title, or Description...')); ?>
   </div>
   <div class="row buttons">
      <?php echo CHtml::submitButton('Search', array('class' => 'btn btn-primary pull-right col-md-offset-1')); ?>
   </div>
<?php $this->endWidget(); ?>

<?php if ($issues != NULL): ?>
   <?php $this->renderPartial('issues', array('model' => $issues)); ?>
<?php endif; ?>

创建事项

接下来,我们需要实现一个创建新议题的动作和视图。由于新议题不会有相关的更新,创建和更新动作需要分开。对于 actionCreate() 方法,我们将简单地从 $_POST 参数中填充值:

public function actionCreate()
{
   $issue = new Issue;
   if (isset($_POST['Issue']))
   {
      $issue->attributes = $_POST['Issue'];

      if ($issue->save())
      {
         Yii::app()->user->setFlash('success', "Issue #{$issue->id} has successfully been created");
         $this->redirect($this->createUrl('issue/update', array('id' => $issue->id)));
      }
   }

   $this->render('create', array(
      'model' => $issue
   ));
}

然后,将位于 views/issue/ 下的 create.php 文件从我们的项目资源文件夹复制到你的项目中。

在这个控制器动作中,还有一个对我们 CWebUser 对象的引用。在之前的章节中,每次我们从控制器更改数据库项时,我们不是重新加载页面就是重定向到新页面。为了使我们的应用程序更友好,我们可以设置只显示一次的闪存消息。为了设置这些消息,我们将使用 CWebUser 对象的 setFlash() 方法:

Yii::app()->user->setFlash($key, $value);

然后,从我们的视图中,我们可以使用 hasFlash() 来查看是否存在特定键的闪存消息:

Yii::app()->user->hasFlash($key);

然后,使用getFlash()显示该闪存消息:

Yii::app()->user->getFlash($key);

或者,如果我们不想在特定视图中查找闪存消息,我们可以告诉布局查找所有闪存消息并将它们显示出来。查看以下代码:

foreach (Yii::app()->user->getFlashes() as $key => $message)
   echo '<div class="flash-' . $key . '">' . $message . "</div>";

查看和更新问题

现在我们能够创建和查找问题,我们需要能够查看和更新它们。为此操作,我们将合并这两个功能到单个操作中。因为不同角色的用户将访问此操作,我们需要调整它,以便特定角色的用户只能执行某些任务:

  1. 首先,我们应该生成一个loadModel()方法:

    private function loadModel($id=NULL)
    {
       if ($id == NULL)
          throw new CHttpException(400, 'Missing ID');
    
       $model = Issue::model()->findByPk($id);
    
       if ($model == NULL)
          throw new CHttpException(404, 'No issue with that ID was found');
    
       return $model;
    }
    
  2. 然后,我们需要创建actionUpdate()函数。我们将从加载具有该 ID 的模型并创建一个新的Update对象开始,以防通过$_POST发送更新:

    public function actionUpdate($id=NULL)
    {
       // Load the necessary models
       $issue = $this->loadModel($id);
       $update = new Update;
       $update->update = NULL;
       $customer_id = $issue->customer_id;
    
  3. 然后,我们应该确保只有管理员、支持者或问题所有者可以查看问题。查看以下代码:

    if (Yii::app()->user->role == 1)
    {
       if (Yii::app()->user->id != $customer_id)
          throw new CHttpException(403, 'You do not have permission to view this issue');
    }
    
  4. 然后,我们应该允许管理员和支持者修改Issue对象本身,如下所示:

    if (Yii::app()->user->role >= 2)
    {
       if (isset($_POST['Issue']))
       {
          $issue->attributes = $_POST['Issue'];
          if ($issue->save())
             Yii::app()->user->setFlash('success', "Issue #{$issue->id} has successfully been updated");
       }
    }
    
  5. 然后,允许任何用户提交更新,如下所示:

    if (isset($_POST['Update']))
    {
       $update->issue_id = $issue->id;
       $update->update = $_POST['Update']['update'];
       if ($update->save())
       {
          Yii::app()->user->setFlash('success', "Issue #{$issue->id} has successfully been updated");
          $this->redirect($this->createUrl('issue/update', array('id' => $issue->id)));
       }
    }
    
  6. 最后,我们应该渲染视图。在渲染视图时,我们还将传递一个CMarkdownParser对象。以 Markdown 语法渲染问题更新将使我们能够轻松访问许多不同的格式化功能,例如换行、文本样式和引用功能。以 Markdown 格式渲染更新还将保护我们免受简单的 XSS 攻击,例如 JavaScript 注入尝试:

       $this->render('update', array(
          'issue' => $issue,
          'update' => $update,
          'md' => new CMarkdownParser
       ));
    }
    

注意

您可以在daringfireball.net/projects/markdown/了解更多关于 Markdown 语法以及如何使用 Markdown 的信息。

最后,我们将创建一个更新视图,使我们能够从不同的角色查看问题和更新。将位于view/issue/update.php视图从项目资源文件夹复制到您的项目中。

电子邮件视图

在我们开始使用应用程序之前,我们需要创建三个不同的电子邮件视图,一个用于将发送给用户的每种类型的电子邮件。这些视图将包含有关问题本身的信息以及对其应用了哪些更改的信息。它还将包含特殊的格式化,使用户能够回复该电子邮件,并使我们能够理解哪些电子邮件部分应包含在更新中:

  1. 我们应该创建的第一个视图是创建视图。此视图将包含有关新创建的问题的信息。它还将包含一个特殊标记,我们的应用程序将能够识别,以便只包含用户的响应。在views/email/created.php中创建一个新文件,并添加以下代码:

    --------------- DO NOT EDIT BELOW THIS LINE ---------------
    <div class="email">
       Hello <?php echo $user->name; ?>,<br /><br />
    
       This is a notification that a new issue (#<?php echo $issue->id; ?>) has been opened for you. A member of our team will review this shortly.<br /><br />
    
       As a reminder, here is the description of the issue you provided:<br /><br />
    
       <strong><?php echo $issue->title; ?></strong>
       <blockquote>
          <?php echo $issue->description; ?>
       </blockquote>
    
       <br /><br />
    
       To add additional information to this issue, you may either reply to this email, or login <?php echo CHtml::link('here', $this->createAbsoluteUrl('issue/update', array('id' => $issue->id))); ?>.
       <br /><br />
    
       Thank you,<br />
       Issue Tracking System
    </div>
    
  2. 然后在views/email/updated.php中创建一个更新的视图。这封电子邮件将告知用户他们的问题已更新,并将包含对问题应用更新的内容。同样,它将包含一个特殊标记,以便如果用户回复我们的电子邮件,我们知道在更新中包含哪些内容以及忽略哪些内容:

    --------------- DO NOT EDIT BELOW THIS LINE ---------------
    <div class="email">
       Hello <?php echo $user->name; ?>,<br /><br />
    
       This is a notification that a new issue (#<?php echo $issue->id; ?>) has been updated with the following message:<br /><br />
    
       <blockquote>
          <?php echo $update->update; ?>
       </blockquote>
       <hr />
       As a reminder, here is the description of the issue you provided:<br /><br />
       <strong><?php echo $issue->title; ?></strong>
       <blockquote>
          <?php echo $issue->description; ?>
       </blockquote>
       <br /><br />
       To reply to this issue you may either reply to this email, or login <?php echo CHtml::link('here', $this->createAbsoluteUrl('issue/update', array('id' => $issue->id))); ?>.
       <br /><br />
       Thank you,<br />
       Issue Tracking System
    </div>
    
  3. 最后,我们需要创建一个视图来通知用户他们的问题已被解决。在views/email/中打开resolved.php并添加以下内容:

    --------------- DO NOT EDIT BELOW THIS LINE ---------------
    <div class="email">
       Hello <?php echo $user->name; ?>,<br /><br />
    
       This is a notification that a new issue (#<?php echo $issue->id; ?>) has been resolved.<br /><br />
    
       Thank you,<br />
       Issue Tracking System
    </div>
    

测试我们的应用程序

由于域名example.com不是一个有效的发送电子邮件的域名,因此为自己创建一个具有有效电子邮件地址的新用户,以该用户身份登录,并创建几个问题。对于您创建的每个问题,都会向您发送一封新电子邮件,通知您问题已被创建。此外,任何支持者或管理员更新问题都会通过电子邮件通知当前的支持者,并提供更新内容。最后,如果您有支持者或管理员解决问题,您将收到一封电子邮件通知您问题已被解决。

一旦您验证了所有功能正常,我们就可以继续使用 SendGrid 处理和解析传入的电子邮件。

处理传入电子邮件解析

虽然处理传入电子邮件解析的方法有很多种,但其中最简单的方法之一是将电子邮件发送给第三方,然后由第三方为我们解析内容,并将其作为$_POST请求发送到我们应用程序的公开端点。这正是 SendGrid 为我们所做的事情。然而,在我们开始使用 SendGrid 之前,我们需要对我们的域名 DNS 服务器和 SendGrid 账户进行一些更改。

发送电子邮件到 SendGrid

为了将我们的电子邮件定向到 SendGrid 以通过,我们首先需要更改我们的 DNS 设置。按照我们的示例域名chapter4.example.com,我们首先需要登录到我们的 DNS 主机,并在子域名中添加一个新的邮件交换(MX)记录。具体来说,我们需要添加一个优先级为10的 MX 记录到mx.sendgrid.net。在大多数 DNS 系统中,该记录如下所示:

chapter4     IN     MX     10     mx.sendgrid.net.

或者,如果您使用像 CloudFlare 这样的服务来处理您的 DNS,您的条目可能如下所示:

发送电子邮件到 SendGrid

注意

根据您的 DNS 提供商,DNS 设置可能需要 24 到 48 小时才能传播。在离开此步骤之前,请验证 MX 记录是否已添加并传播,可以使用命令行工具,如 DIG 或免费的在线网络工具。

调整 SendGrid 解析设置

更新您的 DNS 设置后,您接下来需要更新您的 SendGrid 解析 API 设置,以便 SendGrid 知道将您的电子邮件发送到何处。导航到www.sendgrid.com/developer/reply,然后填写以下解析设置页面并提交记录:

调整 SendGrid 解析设置

添加记录后,你应该在页面底部看到确认信息。一旦完成,你现在可以向*@chapter4.example.com发送电子邮件,SendGrid 将解析它并将它转发到我们的IssueControlleractionEmailUpdate()方法。

注意

你可以在sendgrid.com/docs/API_Reference/Webhooks/parse.html了解更多关于 SendGrid 解析 API webhook 的信息。

通过电子邮件创建和更新问题

现在我们已经设置了 DNS 设置和 SendGrid 账户,我们需要添加必要的功能来通过电子邮件创建和更新问题。然后,我们创建的操作也将在我们数据库中为创建问题的任何新用户创建新用户:

  1. 配置好 SendGrid 后,我们的actionEmailUpdate()方法将在有人向我们的应用程序发送电子邮件时收到一个POST请求。一旦到达,我们将需要的所有信息都将包含在$_POST变量中。然而,其中一些信息可能不容易访问。例如,电子邮件地址将以"Example User" <test@chapter4.example.com>的形式到达,这对我们来说并不太有用。为了使这封电子邮件更有用,我们需要在IssueController中创建一个实用函数,将信息拆分如下:

    private function _parseEmailAddress($raw)
    {
       $name = "";
       $email = trim($raw, " '\"");
    
       if (preg_match("/^(.*)<(.*)>.*$/", $raw, $matches))
       {
          array_shift($matches);
          $name = trim($matches[0], " '\"");
          $email = trim($matches[1], " '\"");
       }
    
       return array(
          "name" => $name,
          "email" => $email,
          "full" => $name . " <" . $email . ">"
       );
    }
    
  2. 然后,在我们的actionEmailUpdate()方法中,我们首先将检索以下信息:

    $from = $this->_parseEmailAddress($_POST['from']);
       $subject = $_POST['subject'];
    
  3. 然后,我们需要搜索电子邮件的主题以找到我们问题的 ID。在我们发送的电子邮件中,主题的格式为[问题编号#<ID>] <info>。请看以下代码:

    $idString = NULL;
    preg_match('/\[Issue #.*?\]/', $subject, $idString);
    $id = str_replace(']', '', str_replace('[Issue #', '', (isset($idString[0]) ? $idString[0] : 0)));
    
  4. 然后,我们需要在我们的系统中找到具有该电子邮件地址的用户。如果我们无法找到该用户,我们需要使用该电子邮件地址创建一个新用户:

    $user = User::model()->findByAttributes(array('email' => $from['email']));
    
    if ($user == NULL)
    {
       $user = new User;
       $user->attributes = array(
          'name' => $from['name'],
          'email' => $from['email'],
          'password' => 'changeme9',
          'role_id' => 1
       );
    
       if (!$user->save())
          return true;
    }
    
  5. 然后,我们需要定位具有该 ID 的问题。如果不存在具有该 ID 的问题,或者该问题不属于我们正在处理的那位用户,我们应该创建一个新的问题,而不是更新现有的问题:

    $issue = Issue::model()->findByPk($id);
    
    // If the user or ID are NULL, or that email address doesn't belong to that customer, Create a new issue
    if ($issue == NULL || $id == NULL || $issue->customer_id != $user->id)
    {
       // create the issue, save it, then return - no further work needs to be done.
       $issue = new Issue;
       $issue->_isEmailCreate = true;
    
       $issue->attributes = array(
          'title' => $subject,
          'description' => $_POST['text'],
          'customer_id' => $user->id
       );
    
       $issue->save();
       return true;
    }
    
  6. 最后,如果我们有一个好的用户和问题,我们应该应用更新。在这个时候,我们将拆分电子邮件的内容,只包括特殊标记之上的内容在我们的更新中。这减少了我们需要存储在数据库中的数据量,并保持我们的界面看起来干净、清晰,没有电子邮件的杂乱。

    $body = explode('--------------- DO NOT EDIT BELOW THIS LINE ---------------', $_POST['text']);
    $body = $body[0];
    
    // Set the update
    $update = new Update;
    $update->author_id = $user->id;
    $update->issue_id = $issue->id;
    $update->update = $body;
    $update->isEmailUpdate = true;
    
    $update->save();
    return true;
    

现在我们应用程序可以接收电子邮件,回复你之前收到的其中一封电子邮件。过一会儿,你将能够导航到该问题,并看到你通过电子邮件发送的更新确实已经应用。或者,你也可以向你的应用程序发送新的电子邮件。过一会儿,将创建一个新的问题,应用程序将通过电子邮件通知你已创建新问题。

摘要

在本章中,我们涵盖了大量的内容!我们讨论了如何在应用程序内部创建和管理用户,发送关于特定事件的电子邮件,以及如何接收电子邮件并将这些信息整合到我们的应用程序中。我们还为我们的用户添加了角色,并使我们的应用程序只对具有特定角色的用户的某些操作做出响应。

在继续之前,想想您如何改进这个应用程序,并尝试实现这些改进。例如,您可以将其修改为让模型而不是硬编码的值来回答访问规则表达式。或者,您可以给应用程序添加新的状态,并在这些状态发生变化时发送不同的电子邮件。想想所有可以使这个应用程序对最终用户更易用的方法。

在添加了一些新功能之后,请查阅位于www.yiiframework.com/doc/的 Yii 文档,以帮助您更好地理解我们在本章中使用的一些方法和属性。

在下一章中,我们将扩展我们的知识,以实现一个类似于 Twitter 的微型博客平台。在我们的微型博客平台上,我们将为最终用户添加注册和密码重置系统,并允许我们的最终用户管理自己的账户。一旦您准备好了,翻到下一页,准备好深入探索 Yii!

第五章。创建一个微博平台

对于我们的下一个项目,我们将开发一个类似于 Twitter 的可扩展微博平台。这个平台将允许用户与他人分享内容,在他们的分享中提及其他用户,并查看他们分享的时间线。此外,用户将能够注册、管理和更改某些账户详情,例如他们的电子邮件地址和密码。最后,我们的平台将允许用户与其他外部社交网络,如 Twitter,分享内容。

到本章结束时,我们将拥有一个社交网络,允许我们分享内容并管理我们的账户,如下面的截图所示:

创建微博平台

我们的用户还将能够直接回复和分享他们发布的单个帖子,如下面的截图所示:

创建微博平台

前提条件

在我们开始之前,有一些事情我们需要设置并确保它们能正常工作:

  • 由于我们将从我们的应用程序发送和接收电子邮件,我们需要一个注册并活跃的域名。如果您还没有一个正在工作的域名,您可以从域名注册商那里购买一个,例如 www.namecheap.comwww.name.com,或 www.gandi.net

  • 接下来,您需要一个具有公开 IP 地址的 Web 服务器。这将允许将电子邮件发送到我们的应用程序。许多云虚拟专用服务器VPS)提供商以低月费或时费提供此类服务。这些服务包括 www.digitalocean.comwww.linode.com,和 www.rackspace.com/cloud/servers

  • 为了在我们的应用程序中发送电子邮件,我们还将利用免费的 SendGrid 开发者账户,该账户可以在 www.sendgrid.com/developers 上设置。

  • 在本章中,我们还将再次使用 MySQL 的最新版本(在撰写本文时,它是 MySQL 5.6)。请确保您的 MySQL 服务器已在您的服务器上设置并运行。

  • 对于这个项目,我们还将通过 Composer 管理我们的依赖项,您可以从 getcomposer.org/ 下载并安装它。

  • 最后,您需要一个 Twitter 开发者账户,您可以从 dev.twitter.com/ 获取。这个账户将允许我们通过 Twitter 的 OAuth API 以登录用户身份分享我们的内容。

一旦您获得了列出的项目,在您使用的域名上创建一个子域名并将其指向您的服务器。在本章中,我将使用 chapter5.example.com 来指代这个子域名。一旦一切设置完毕,并且您的服务器对该域名做出响应,我们就可以开始了。

描述项目

我们的微博平台可以分为两个主要组件:

  • 将关注其他用户并创建、分享和点赞内容的用户

  • 用户创建的基于文本的分享

用户

我们应用程序的第一个组件是执行我们应用程序中所有任务的用户集。对于这个应用程序,我们将主要重用我们在第四章中扩展的用户数据库和身份验证系统,即开发问题跟踪应用程序。在本章中,我们将扩展users数据库表,并添加几个新的关系,如粉丝和点赞。

粉丝

在这个应用程序中,用户将能够关注其他用户并被其他用户关注。这种关系将使用户能够通过显示其他用户最近创建的内容来保持与其他用户的同步。此外,它将使用户知道有多少人关注他们,并了解他们对网络的影响程度。对于这个应用程序,我们的followers表将只包含那些正在关注或被其他用户关注的用户的键。我们的数据库表将如下所示:

ID INTEGER PRIMARY KEY
follower_id INTEGER
followee_id INTEGER
created INTEGER
updated INTEGER

点赞

在这个应用程序中,用户还可以表示他们喜欢某个特定的分享。类似于我们的followers表,likes表将只包含usersshares表的键。我们的数据库表将如下所示:

ID INTEGER PRIMARY KEY
user_id INTEGER
share_id INTEGER
created INTEGER
updated INTEGER

分享

我们应用程序的第二个组件将是用户创建的分享。为了我们的目的,我们将定义分享为可以包含独特标记的文本片段,例如用于提及其他用户的@符号,以及用于标记分享的#字符。分享也可以是对另一个分享的回复,这将允许它们在分享的查看页面上查看。最后,分享可以被重新分享,用户可以将其他用户的分享分享给他们的网络。我们的数据库表将如下所示:

ID INTEGER PRIMARY KEY
text STRING
author_id INTEGER
reshare_id INTEGER
reply_id INTEGER
created INTEGER
updated INTEGER

初始化项目

到现在为止,您应该已经熟悉了从头创建项目。为了提供一个共同的起点,本章的项目资源中包含了一个骨架项目。这个骨架项目包括我们开始所需的必要迁移、数据文件、控制器和视图。我们将在本章中使用该登录系统进行身份验证。将骨架项目从项目资源文件夹复制到您的 Web 服务器,并按照本章开头概述的配置,使其响应chapter5.example.com,然后执行以下步骤以确保一切设置正确:

  1. 调整assetsprotected/runtime文件夹的权限,以便您的 Web 服务器可以写入。

  2. 接下来,创建我们的应用程序将使用的 MySQL 用户和数据库表。如果你不想修改提供的主配置文件,以下 MySQL 命令将为你创建数据库和用户:

    CREATE USER 'ch5_socialii'@'localhost' IDENTIFIED BY ''ch5_socialii'';
    CREATE DATABASE IF NOT EXISTS  `'ch5_socialii'` ;
    GRANT ALL PRIVILEGES ON  `'ch5\_socialii'` . * TO  ''ch5_socialii''@'localhost';
    FLUSH PRIVILEGES;
    
  3. 接下来,我们需要运行初始迁移,然后导入位于protected/data文件夹中的示例数据。这些示例数据将使我们能够在应用程序运行后立即登录并开始使用它。导航到项目根目录,然后运行以下命令:

    php protected/yiic.php migrate up --interactive=0
    mysql –u ch5_socialii –pch5_socialii ch5_socialii < protected/data/combined.sql
    
    
  4. 然后,我们需要更新位于protected/config/params.php文件,并添加我们的 SendGrid 信息。你的用户名和密码将与你的 SendGrid 用户名和密码相对应。按照我们的示例域名,将from地址设置为socialii@chapter5.example.com

  5. 最后,我们需要安装必要的 Composer 依赖项:

    composer install
    
    

到目前为止,你应该能够在浏览器中打开http://chapter5.example.com并看到以下页面:

初始化项目

制作更好的 Yii 引导文件

你可能注意到的一件事是,我们不必声明 Yii 框架的位置,我们的网站才能工作。这是因为我们在composer.json文件中将 Yii 框架作为依赖项包含在内,如下所示:

"yiisoft/yii": "1.1.14"

将 Yii 作为依赖项包含在我们的项目中而不是在引导程序中硬编码,有以下几个好处:

  • 将其作为 Composer 依赖项包含在我们的引导程序中意味着我们不必在将代码推送到服务器之前在服务器上安装 Yii 框架

  • 我们现在可以自动化我们的部署流程,并确保我们的开发环境中的依赖项与生产环境中的依赖项相匹配

  • 本项目使用的代码现在与其他可能也使用 Yii 框架的项目分开

  • 最后,这种分离使我们能够在不担心如何将 Yii 框架部署到我们的服务器的情况下升级 Yii 或使用 Yii 的不同分支

我们还对 Bootstrap 文件进行了一些改进和更改,以便于我们开发和调试。让我们看看index.php文件中的更改:

  1. 首先,我们包含我们的配置文件:

    $config=require dirname(__FILE__).'/protected/config/main.php';
    defined('DS') or define('DS', DIRECTORY_SEPARATOR);
    
  2. 接下来,我们将把YII_DEBUGYII_TRACE设置为在protected/config/main.php文件中定义的变量。这将允许我们切换调试模式和跟踪级别,而无需修改index.php中的代码:

    defined('YII_DEBUG') or define('YII_DEBUG',isset($config['params']['debug']) ? $config['params']['debug'] : false);
    defined('YII_TRACE_LEVEL') or define('YII_TRACE_LEVEL',isset($config['params']['trace']) ? $config['params']['trace'] : 0);
    
  3. protected/config/main.php文件中,我们可以通过设置params[debug]params[trace]来切换这些变量:

    'params' => array(
       'includes' => require __DIR__ . '/params.php',
       'debug' => true,
       'trace' => 3
    )
    
  4. 然后,我们将加载我们的 Composer 依赖项。根据YII_DEBUG是否设置,加载yii.phpyiilite.php。对于大多数配置,以及与 APC Cache 或 Zend OPcache 结合使用时,yiilite.php应该可以提高你应用程序的性能:

    require_once(__DIR__ . '/vendor/autoload.php');
    require(__DIR__.DS.'vendor'.DS.'yiisoft'.DS.'yii'.DS.'framework'.DS.(YII_DEBUG ? 'yii.php' : 'yiilite.php'));
    

    注意

    如果您想了解更多关于 yiilite 的信息,请查看官方 Yii 文档www.yiiframework.com/doc/guide/1.1/en/topics.performance#using-x-9x

  5. 接下来,我们将自动启用日志记录,并在调试模式下将错误报告设置为最大值。这将使我们能够轻松地查看错误发生时的完整堆栈跟踪,并获得有关应用程序中发生情况的详细日志消息。此选项将有助于开发,而不会在生产环境中加载:

    if (YII_DEBUG && YII_TRACE_LEVEL == 3)
    {
       error_reporting(-1);
       ini_set('display_errors', 'true');
    
       // Enable WebLogRouteLogging
       $config['preload'][] = 'log';
       $config['components']['log']['routes'][0]['enabled'] = true;
    }
    
  6. 为了使前面的步骤生效,我们需要定义一个我们想要使用的日志记录方法。在我们的开发环境中,使用 CWebLogRoute 是合理的,这样我们就可以在我们的浏览器中看到我们的日志消息。为了启用此路由,我们将在位于 protected/config/main.php 文件中的组件部分添加以下内容:

    'log' => array(
       'class' => 'CLogRouter',
          'routes' => array(
          array(
             'class' => 'CWebLogRoute',
             'levels' => 'error, warning, trace, info',
             'enabled' => false
          )
       )
    )
    

    注意

    Yii 提供了多种不同的日志记录方法,您可以在生产环境和开发环境中使用。要了解更多关于日志记录的信息,请查看官方 Yii 文档www.yiiframework.com/doc/guide/1.1/en/topics.logging

  7. 最后,我们将引导我们的应用程序:

    Yii::createWebApplication($config)->run();
    

允许用户管理他们的信息

在前面的章节中,我们的用户除了与内容互动之外,几乎无法做任何事情。在本章中,我们将扩展基础用户模型,使他们能够注册我们的应用程序,安全地激活他们的账户,如果他们忘记密码,可以重置密码,以及更改他们的电子邮件地址。

升级我们的 UserIdentity 类

在实现之前提到的功能之前,我们需要确保我们能够适当地处理我们的用户,而无需向我们的数据库请求有关当前登录用户的一些基本信息。为此,我们将在位于 protected/components/UserIdentity.php 文件中添加一些信息,如下所示,在 authenticate() 方法的突出部分中:

public function authenticate()
{
   $record = User::model()->findByAttributes(array('email'=>$this->username));

   if ($record == NULL)
      $this->errorCode = YII_DEBUG ? this->errorCode=self::ERROR_USERNAME_INVALID : self::ERROR_UNKNOWN_IDENTITY;
   else if (password_verify($this->password, $record->password))
   {
      $this->errorCode = self::ERROR_NONE;
      $this->_id        = $record->id;
      $this->setState('email', $record->email);
      $this->setState('role', $record->role_id);
      $this->setState('username', $record->username);
      $this->setState('name', $record->name);
   }
   else
      $this->errorCode = YII_DEBUG ? self::ERROR_PASSWORD_INVALID : self::ERROR_UNKNOWN_IDENTITY;

   return !$this->errorCode;
}

定义用户关系

接下来,我们想要确保我们的关系设置正确,以便我们可以知道哪些数据与我们的用户相关联。这包括分享、关注者和被关注者。在我们的 protected/models/User.php 文件中,确保以下内容设置到我们的 relations() 方法中:

return array(
   'followees' => array(self::HAS_MANY, 'Follower', 'followee_id'),
   'followers' => array(self::HAS_MANY, 'Follower', 'follower_id'),
   'shares' => array(self::HAS_MANY, 'Share', 'author_id'),
   'role' => array(self::BELONGS_TO, 'Role', 'role_id'),
);

我们还将在 relations() 方法中添加一个新的关系类型,以便我们可以快速检索用户拥有的分享数、关注者和被关注者的数量。这种关系类型称为 STAT,其行为与 HAS_MANY 关系相同,但它在数据库级别执行计数并返回一个数字,而不是返回对象数组:

'followeesCount' => array(self::STAT, 'Follower', 'followee_id'),
'followersCount' => array(self::STAT, 'Follower', 'follower_id'),
'sharesCount' => array(self::STAT, 'Share', 'author_id')

通过使用STAT关系,当我们想知道一个用户有多少关注者时,可以减少对数据库的压力。在一个用户数量较少的小型数据库中,HAS_MANY关系并不十分显著;然而,当处理数千个用户时,反复执行HAS_MANY查询会导致返回大量结果,这可能导致我们的应用程序耗尽内存并崩溃。

确定一个用户是否关注另一个用户

我们需要对模型中的关系进行的最后一个更改是添加一个快速方法,以便我们能够确定当前登录的用户是否关注另一个用户。我们将在稍后使用此信息来调整视图中的显示内容。将以下方法添加到位于protected/models/User.php文件中:

public static function isFollowing($id=NULL)
{
   if ($id == NULL || Yii::app()->user->isGuest)
      return false;

   $following = Follower::model()->findByAttributes(array('follower_id' => Yii::app()->user->id, 'followee_id' => $id));

   return $following != NULL;
}

实现安全的注册过程

创建安全 Web 应用的一个更困难的部分是确保在我们网站上注册的用户确实是他们声称的用户。通常,这是通过向用户发送包含唯一一次性令牌的电子邮件来完成的。如果用户能够使用这个安全令牌访问我们的网站,我们可以假设他们是真实的用户,并且他们有权访问电子邮件地址。通过采用这种验证方法,我们可以确保在我们网站上注册的用户是他们所声称的用户,并且他们选择与我们应用程序互动。

尽管我们可以在控制器中直接处理大部分此功能,并使用户模型因不必要的函数而膨胀,但我们将选择CFormModel作为此任务的工具。在这本书中,我们只使用了CFormModel来处理LoginForm模型,这是我们用来登录用户的。在继续前进之前,让我们深入了解CFormModel是什么,并探讨我们如何使用它。

CFormModelCActiveRecord非常相似,因为它扩展了CModel并继承了CActiveRecord的许多方法,如attributeLabelsattributesrulesCFormModelCActiveRecord的主要区别在于CFormModel用于从 HTML 表单收集信息,提交给CFormModel的数据是被处理的,而不是存储和操纵在数据库中。通过利用从CModel继承的方法,我们可以干净且容易地使用CFormModel来验证输入,并减少控制器和模型中的代码杂乱。

注意

要了解更多关于CFormModel的信息,请查看官方 Yii 文档www.yiiframework.com/doc/api/1.1/CFormModel

要开始,在protected/models/RegistrationForm.php中创建一个新文件,并将其添加以下内容:

<?php class RegistrationForm extends CFormModel {}

下一步如下:

  1. 我们将在类中放置的第一个项目是我们的属性。这些模型属性是公开的,可以从我们的控制器中设置:

    public $email;
    public $name;
    public $password;
    public $username;
    
  2. 接下来,我们将定义这些属性的标签:

    public function attributeLabels()
    {
       return array(
          'email' => 'Your Email Address',
          'name' => 'Your Full Name',
          'password' => 'Your Password',
          'username' => 'Your Username'
       );
    }
    
  3. 然后,我们需要设置我们的验证规则。对于新用户,我们希望验证所有属性都已设置,电子邮件地址是有效的,密码至少有 8 个字符长,并且用户尝试注册的用户名尚未被占用:

    public function rules()
    {
       return array(
          array('email, username, name, password', 'required'),
          array('password', 'length', 'min'=>8),
          array('email', 'email'),
          array('username', 'validateUsername') ,
          array('email', 'verifyEmailIsUnique')
       );
    }
    
  4. 由于 Yii 没有提供原生的用户名验证器,因此我们需要定义自己的 validateUsername() 方法,该方法将对我们的数据库进行简单的存在性检查:

    public function validateUsername($attributes, $params)
    {
       $user = User::model()->findByAttributes(array('username' => $this->username));
    
       if ($user === NULL)
          return true;
       else
       {
          $this->addError('username', 'That username has already been registered');
          return false;
       }
    }
    
  5. 我们还希望定义一个验证器来确保我们的电子邮件地址尚未被占用:

    public function verifyEmailIsUnique($attributes, $params)
    {
       $user = User::model()->findByAttributes(array('email' => $this->email));
    
       if ($user === NULL)
          return true;
       else
       {
          $this->addError('email', 'That email address has already been registered');
          return false;
       }
    }
    

    注意

    注意到当验证失败时,我们不仅返回了 false,还向我们的模型添加了一个错误。我们这样做有三个原因:为了增强用户体验并确保用户知道出了什么问题,为了确保当抛出错误时 CModelvalidate() 方法失败(除非调用 $this->addError(),否则它将返回 true),以及为了确保我们可以独立于表单运行这些验证器。

  6. 根据优先级,我们将创建的最后一个方法是 save() 方法,它将执行验证,向用户发送验证电子邮件,并将新记录插入到我们的数据库中。为了实现这一点,首先创建一个名为 save() 的新方法:

    public function save()
    

    然后,在方法内部,首先执行验证:

    if (!$this->validate())
    return false;
    

    然后,创建一个新的 User 对象:

    $user = new User;
    $user->attributes = array(
       'email' => $this->email,
       'name' => $this->name,
       'password' => $this->password,
       'username' => str_replace(' ', '',$this->username),
       'activated' => 0
    );
    

    然后,尝试保存用户并发送包含激活详情的电子邮件地址给该用户:

    if ($user->save())
    {
       // Send an email to the user
       $sendgrid = new SendGrid(Yii::app()->params['includes']['sendgrid']['username'], Yii::app()->params['includes']['sendgrid']['password']);
       $email    = new SendGrid\Email();
    
       $email->setFrom(Yii::app()->params['includes']['sendgrid']['from'])
          ->addTo($user->email)
          ->setSubject("Activate Your Socialii Account")
          ->setText('Activate Your Socialii Account')
          ->setHtml(Yii::app()->controller->renderPartial('//email/activate', array('user' => $user), true));
    
       // Send the email
       $sendgrid->send($email);
    
       // Return true if we get to this point
       return true;
    }
    
  7. 接下来,我们需要更新我们的用户模型,以便在用户首次创建时设置激活密钥。为了生成激活密钥,我们将使用包含在我们的 composer.json 文件中的库,该库可以安全地生成字符串:

    public function beforeSave()
    {
       if ($this->isNewRecord)
       {
          $this->generateActivationKey();
          $this->role_id = 1;
       }
    
       return parent::beforeSave();
    }
    
    public function generateActivationKey()
    {
       $factory = new CryptLib\Random\Factory;
       $this->activation_key = $factory->getHighStrengthGenerator()->generateString(16);
       return $this->activation_key;
    }
    
  8. 现在,我们可以在 protected/controllers/ 目录下的 UserController.php 文件中添加一个注册动作,允许用户使用我们的网站注册。由于大部分工作已经在我们的表单中完成,我们只需要从 $_POST 请求中收集数据,将其应用于模型,并在模型上调用 save() 方法。为了提供更好的用户体验,我们还可以尝试使用用户的新凭据自动登录:

    public function actionRegister()
    {
       // Authenticated users shouldn't be able to register
       if (!Yii::app()->user->isGuest)
       $this->redirect($this->createUrl('timeline/index'));
    
       $form = new RegistrationForm;
       if (isset($_POST['RegistrationForm']))
       {
          $form->attributes = $_POST['RegistrationForm'];
    
          // Attempt to save the user's information
          if ($form->save())
          {
             // Try to automagically log the user in, if we fail though just redirect them to the login page
             $model = new LoginForm;
             $model->attributes = array(
                'username' => $form->email,
                'password' => $form->password
             );
    
             if ($model->login())
             {
                // Set a flash message associated to the new Yii::app()->user
                Yii::app()->user->setFlash('sucess', 'You successfully registred an account!');
    
                // Then redirect to their timeline
                $this->redirect($this->createUrl('timeline/index'));
             }
             else
                $this->redirect($this->createUrl('site/login'));
          }
       }
    
       $this->render('register', array('user' => $form));
    }
    
  9. 然后,从项目资源文件夹中,将以下视图文件复制到您的项目中:protected/views/user/register.phpprotected/views/email/activate.phpprotected/views/site/index.php。现在,您可以从 site/indexuser/register 路由注册一个新的账户到您的网站。

  10. 最后,在 protected/controllers/ 目录下的 UserController.php 文件中创建一个新的方法 actionActivate(),该方法将实际激活我们的用户。为此,我们将简单地验证发送给我们的路由中的 ID 参数是否与文件中记录的用户匹配:

    public function actionActivate($id=NULL)
    {
       if ($id == NULL)
          throw new CHttpException(400, 'Activation ID is missing');
    
       $user = User::model()->findByAttributes(array('activation_key' => $id));
    
       if ($user == NULL)
          throw new CHttpException(400, 'The activation ID you supplied is invalid');
    
       // Don't allow activations of users who have a password reset request OR have a change email request in
       // Email Change Requests and Password Reset Requests require an activated account
       if ($user->activated == -1 || $user->activated == -2)
          throw new CHttpException(400, 'There was an error fulfilling your request');
    
       $user->activated = 1;
       $user->password = NULL;           // Don't reset the password
       $user->activation_key = NULL;     // Prevent reuse of their activation key
    
       if ($user->save())
       {
          $this->render('activate');
          Yii::app()->end();
       }
    
       throw new CHttpException(500, 'An error occurring activating your account. Please try again later');
    }
    

我们还可以在我们的主页上重用我们刚刚创建的表单,允许用户从那里登录或注册新账户。由于我们已经复制了视图,我们只需要调整SiteControlleractionIndex()方法:

public function actionIndex()
{
   if (!Yii::app()->user->isGuest)
      $this->redirect($this->createUrl('timeline/index'));

   $this->layout = 'main';
   $this->render('index', array('loginform' => new LoginForm, 'user' => new RegistrationForm));
}

处理忘记密码的情况

如前所示,使用CFormModel处理来自 HTML 表单的输入使得验证提交的信息并采取行动变得非常容易,同时保持我们的模型和控制器非常清晰。我们还可以再次使用CFormModel来处理用户的忘记密码请求。

为了处理忘记密码的情况,我们将要求用户提供他们用于注册账户的电子邮件地址。接下来,我们将验证我们是否有存档的电子邮件地址,然后发送一个包含一次性令牌的电子邮件给用户,该令牌将允许他们安全地重置密码。首先,在protected/models中创建一个名为ForgotForm.php的新文件,并将其添加以下内容:

<?php class ForgotForm extends CFormModel {}

下一步如下:

  1. 首先,声明我们表单的公共属性:

    public $email;
    public function attributeLabels()
    {
       return array(
          'email' => 'Your Email Address'
       );
    }
    
  2. 我们还将为我们的用户模型声明一个私有属性,我们将在整个模型中重用它:

    private $_user;
    
  3. 接下来,我们将声明我们的验证规则和自定义验证器:

    public function rules()
    {
       return array(
          array('email', 'required'),
          array('email', 'email'),
          array('email', 'checkUser'),
       );
    }
    
    public function checkUser($attribute,$params)
    {
       $this->_user = User::model()->findByAttributes(array('email' => $this->email));
    
       if ($this->_user == NULL)
       {
          $this->addError('email', 'There is no user in our system with that email address.');
          return false;
       }
    
       return true;
    }
    
  4. 然后,我们将声明我们的save()方法,该方法将向用户发送电子邮件并指示他们已请求重置密码:

    public function save()
    {
       if (!$this->validate())
          return false;
    
       // Set the activation details
       $this->_user->generateActivationKey();
       $this->_user->activated = -1;
    
       if ($this->_user->save())
       {
          $sendgrid = new SendGrid(Yii::app()->params['includes']['sendgrid']['username'], Yii::app()->params['includes']['sendgrid']['password']);
          $email    = new SendGrid\Email();
    
          $email->setFrom(Yii::app()->params['includes']['sendgrid']['from'])
             ->addTo($this->_user->email)
             ->setSubject('Reset Your Socialii Password')
             ->setText('Reset Your Socialii Password')
             ->setHtml(Yii::app()->controller->renderPartial('//email/forgot', array('user' => $this->_user), true));
    
          // Send the email
          $sendgrid->send($email);
    
          return true;
       }
       else
          $this->addError('email', 'Unable to send reset link. This is likely a temporary error. Please try again in a few minutes.');
    
       return false;
    }
    
  5. 然后,在protected/controllers/UserController.php中创建一个操作来处理表单提交:

    public function actionForgot()
    {
       $form = new ForgotForm;
    
       if (isset($_POST['ForgotForm']))
       {
          $form->attributes = $_POST['ForgotForm'];
    
          if ($form->save())
          {
             $this->render('forgot_success');
             Yii::app()->end();
          }
       }
    
       $this->render('forgot', array('forgotform' => $form));
    }
    
  6. 最后,从项目资源文件夹中复制protected/views/user/forgot.phpprotected/views/user/forgot_success.phpprotected/views/email/forgot.php到您的应用程序中。

重置忘记的密码

一旦用户获得了我们发送给他们的一次性令牌,我们就可以允许用户安全地将他们的密码更改为他们想要的任何内容。首先,在protected/models中创建一个名为PasswordResetform.php的新文件,并添加以下内容:

<?php class PasswordResetForm extends CFormModel {}

下一步如下:

  1. 首先,声明此表单的公共属性:

    public $password;
    public $pasword_repeat;
    public $user;
    
  2. 然后,添加验证规则。用户的新密码应该与注册时相同的要求。由于我们要求输入两次密码,我们将使用比较验证器来比较这两个密码。此验证器将第一个属性与attribute_repeat属性进行比较:

    public function rules()
    {
       return array(
          array('password', 'length', 'min' => 8),
          array('password, password_repeat, user', 'required'),
          array('password', 'compare', 'compareAttribute' => 'password_repeat'),
       );
    }
    
  3. 然后,添加用于重置用户密码的save()方法:

    public function save()
    {
       if (!$this->validate())
          return false;
    
       $this->user->password = $this->password;
    
       // Verify that this activation key can't be used again
       $this->user->activated = 1;
       $this->user->activation_key = NULL;
    
       if ($this->user->save())
          return true;
    
       return false;
    }
    
  4. 然后,创建我们的控制器操作:

    public function actionResetPassword($id = NULL)
    {
       if ($id == NULL)
          throw new CHttpException(400, 'Missing Password Reset ID');
    
       $user = User::model()->findByAttributes(array('activation_key' => $id));
    
       if ($user == NULL)
          throw new CHttpException(400, 'The password reset id you supplied is invalid');
    
       $form = new PasswordResetForm;
    
       if (isset($_POST['PasswordResetForm']))
       {
          $form->attributes = array(
             'user' => $user,
             'password' => $_POST['PasswordResetForm']['password'],
             'password_repeat' => $_POST['PasswordResetForm']['password_repeat']
          );
    
          if ($form->save())
          {
             $this->render('resetpasswordsuccess');
             Yii::app()->end();
          }
       }
    
       $this->render('resetpassword', array(
          'passwordresetform' => $form,
          'id' => $id
       ));
    }
    
  5. 最后,从项目资源文件夹中复制protected/views/user/resetpassword.phpprotected/views/user/resetpassword_success.php到您的应用程序中。

允许用户管理他们的详细信息

到目前为止,我们现在可以登录,注册账户,如果我们忘记了密码,还可以重置密码。现在,让我们着手允许用户管理他们自己的详细信息。这包括允许他们更改密码、电子邮件地址以及我们在注册过程中收集的其他信息。步骤如下:

  1. 我们将首先在 protected/models 中再次创建一个新的 CFormModel,名为 ProfileForm.php

    <?php class ProfileForm extends CFormModel {}
    
  2. 然后,我们将添加我们的属性和标签:

    public $email;
    public $password;
    public $name;
    public $newpassword = NULL;
    public $newpassword_repeat = NULL;
    private $_user;
    public function attributeLabels()
     {
        return array(
           'email'               => 'Your New Email Address',
           'password'            => 'Your Current Password',
           'name'                => 'Your Name',
           'newpassword'         => 'Your NEW password',
           'newpassword_repeat'  => 'Your NEW password (again)'
        );
     }
    
  3. 然后,我们将添加我们的基本验证规则:

    public function rules()
    {
       return array(
          array('email, name, password', 'required'),
          array('newpassword', 'length', 'min' => 8),
          array('email', 'email'),
          array('password', 'verifyPassword'),
          array('newpassword', 'compare', 'compareAttribute' => 'newpassword_repeat')
       );
    }
    
  4. 在允许用户更改任何信息(包括他们的密码和电子邮件地址)之前,我们将要求他们输入他们的当前密码。这将验证他们是否控制着账户:

    public function verifyPassword($attribute,$params)
    {
       // Only allow change requests from the currently logged inuser
       $this->_user = User::model()->findByPk(Yii::app()->user->id);
    
       // User doesn't exist. Something bad has happened
       if ($this->_user == NULL)
          return false;
    
       // NULL the new password if it isn't set
       if ($this->newpassword == '' || $this->newpassword == NULL)
          $this->newpassword == NULL;
    
       // Validate the password
       if (!password_verify($this->password, $this->_user->password))
       {
          $this->addError('password', 'The password you entered is invalid');
          return false;
       }
       return true;
    }
    
  5. 然后,我们将添加我们的 save() 方法,该方法将更新用户的信息:

    public function save()
    {
       if (!$this->validate())
          return false;
    
       // Set the user attributes
       $this->_user->attributes = array(
          // If the email submitted is different than the current email, change the new_email field
          'new_email' => $this->email == $this->_user->email ? NULL : $this->email,
    
          // Set the new password if validation passes
          'password' => $this->newpassword == NULL ? NULL : $this->newpassword,
          'name' => $this->name
       );
    
       // Save the user's information
       if ($this->_user->save())
       {
          // If the user's password has changed, send the user an email so that they can be aware of it
          if ($this->newpassword != NULL && $this->password != $this->newpassword)
             $this->sendPasswordChangeNotification();
    
          // If the user entered a NEW email address, and we haven't already sent them a change email notification
          // Send them a change email notification
          if ($this->email != $this->_user->_oldAttributes['email'] && $this->_user->activated != -2)
             $this->sendEmailChangeNotification();
    
          return true;
       }
    
       return false;
    }
    
  6. save() 方法中,我们声明了两个新方法:sendPasswordChangeNotification()sendEmailChangeNotification()。这两个方法将在事件发生时向用户发送电子邮件:

    private function sendPasswordChangeNotification()
    {
       $sendgrid = new SendGrid(Yii::app()->params['includes']['sendgrid']['username'], Yii::app()->params['includes']['sendgrid']['password']);
       $email    = new SendGrid\Email();
    
       $email->setFrom(Yii::app()->params['includes']['sendgrid']['from'])
          ->addTo($this->_user->email)
          ->setSubject("Your Socialii Password Has Been Changed")
          ->setText('Your Socialii Password Has Been Changed')
          ->setHtml(Yii::app()->controller->renderPartial('//email/passwordchange', array('user' => $this->_user), true));
    
       // Send the email
       return $sendgrid->send($email);
    }
    
  7. 第二种方法,sendEmailChangeNotification() 当用户的电子邮件地址发生变化时向用户发送电子邮件。这允许我们在开始在我们的应用程序中使用它之前验证他们的新电子邮件地址:

    private function sendEmailChangeNotification()
    {
       // Change the user's activation status for the verification link
       $this->_user->activated = -2;
       $this->_user->activation_key = $this->_user->generateActivationKey();
    
       // Save the user's information
       if ($this->_user->save())
       {
          $sendgrid = new SendGrid(Yii::app()->params['includes']['sendgrid']['username'], Yii::app()->params['includes']['sendgrid']['password']);
          $email    = new SendGrid\Email();
    
          $email->setFrom(Yii::app()->params['includes']['sendgrid']['from'])
             ->addTo($this->_user->new_email)
             ->setSubject("Verify Your New Email Address")
             ->setText('Verify Your New Email Address')
             ->setHtml(Yii::app()->controller->renderPartial('//email/verify', array('user' => $this->_user), true));
    
          // Send the email
          return $sendgrid->send($email);
       }
    
       return false;
    }
    
  8. 然后,在我们的 UserController 中,我们将定义我们的 actionIndex() 方法,该方法将收集这些信息:

    public function actionIndex()
    {
       $user = User::model()->findByPk(Yii::app()->user->id);
       $form = new ProfileForm;
       if (isset($_POST['ProfileForm']))
       {
          $form->attributes = $_POST['ProfileForm'];
          $form->newpassword_repeat = $_POST['ProfileForm']['newpassword_repeat'];
    
          if ($form->save())
             Yii::app()->user->setFlash('success', 'Your information has been successfully changed');
          else
             Yii::app()->user->setFlash('danger', 'There was an error updating your information');
       }
    
       $this->render('index', array(
          'user' => $user,
          'profileform' => $form
       ));
    }
    
  9. 最后,我们需要将 protected/views/user/index.phpprotected/view/email/passwordchange.phpprotected/views/email/verify.php 从我们的项目资源文件夹复制到我们的项目中。

验证新的电子邮件地址

现在,我们的用户可以更改自己的信息,而无需通过我们。在我们关闭 UserController 之前,还有一些其他方法需要实现。

更改用户电子邮件地址的一种安全方式是将新电子邮件地址存储在我们的数据库中的一个临时表或列中,然后向该电子邮件地址发送一个验证电子邮件(这是我们已经在 ProfileForm 类中实现的)。这允许我们表明我们知道用户想要更改密码,但我们要求他们证明他们可以访问新的电子邮件地址。我们发送给他们的电子邮件包含一个安全的激活令牌和一个链接到 actionVerify() 方法,该方法将验证令牌是否属于用户,然后将新的电子邮件地址移动到我们数据库中的主要电子邮件地址字段。我们可以按照以下方式实现 actionVerify() 方法:

public function actionVerify($id=NULL)
{
   if ($id == NULL)
      throw new CHttpException(400, 'Activation ID is missing');

   $user = User::model()->findByAttributes(array('activation_key' => $id));

   if ($user == NULL)
   throw new CHttpException(400, 'The verification ID you supplied is invalid');

   $user->attributes = array(
      'email' => $user->new_email,
      'new_email' => NULL,
      'activated' => 1,
      'activation_key' => NULL
   );

   // Save the information
   if ($user->save())
   {
      $this->render('verify');
      Yii::app()->end();
   }

   throw new CHttpException(500, 'There was an error processing your request. Please try again later');
}

我们将为这个控制器实现的最后操作将允许用户关注和取消关注另一个用户。我们将在本章后面的视图中使用这些操作。现在,按照以下方式实现这些操作:

public function actionFollow($id=NULL)
{
   if ($id == NULL)
      throw new CHttpException(400, 'You must specify the user you wish to follow');

   if ($id == Yii::app()->user->id)
      throw new CHttpException(400, 'You cannot follow yourself');

   $follower = new Follower;
   $follower->attributes = array(
      'follower_id' => Yii::app()->user->id,
      'followee_id' => $id
   );

   if ($follower->save())
      Yii::app()->user->setFlash('success', 'You are now  following ' . User::model()->findByPk($id)->name);

   // Redirect back to where they were before
   $this->redirect(Yii::app()->request->urlReferrer);
}

public function actionUnFollow($id=NULL)
{
   if ($id == NULL)
   throw new CHttpException(400, 'You must specify the user you wish to unfollow');

   if ($id == Yii::app()->user->id)
      throw new CHttpException(400, 'You cannot unfollow yourself');

   $follower = Follower::model()->findByAttributes(array('follower_id' => Yii::app()->user->id, 'followee_id' => $id));

   if ($follower != NULL)
   {
      if ($follower->delete())
         Yii::app()->user->setFlash('success', 'You are no longer following ' . User::model()->findByPk($id)->name);
   }

   // Redirect back to where they were before
   $this->redirect(Yii::app()->request->urlReferrer);
}

在关闭此控制器之前,请确保 accessRules() 方法设置正确:

public function accessRules()
{
   return array(
   array('allow',
      'actions' => array('register', 'forgot', 'verify', 'activate', 'resetpassword'),
      'users' => array('*')
      ),
      array('allow',
         'actions' => array('index', 'follow', 'unfollow'),
         'users'=>array('@'),
      ),
      array('deny',  // deny all users
         'users'=>array('*'),
      ),
   );
}

查看分享的时间线

显示新内容最简单的方法就是简单地列出它,以便显示最新的项目。在我们的时间线页面上,我们希望为用户提供分享东西、查看他们正在查看的用户的信息(例如共享数量、关注者和被关注者)以及查看用户最近分享的内容的能力。为此,我们将利用从我们的主时间线视图异步加载的CListView。这将允许我们稍后通过简单地向我们将要创建的端点发出GET请求来重用此视图。在我们的TimelineController.php文件中,位于protected/controllers/,实现actionIndex()方法:

public function actionIndex($id = NULL)
{
   // If the ID is not set, set this to the currently logged in user.
   if ($id == NULL)
   {
      if (Yii::app()->user->isGuest)
         $this->redirect($this->createUrl('site/login'));

      $id = Yii::app()->user->username;
   }

   // Get the user's information
   $user = User::model()->findByAttributes(array('username' => $id));
   if ($user == NULL)
      throw new CHttpException(400, 'Unable to find a user with that ID');

   $this->render('index', array(
      'user' => $user,
      'share' => new Share,
      'id' => $user->id
   ));
}

在这个动作中,我们所做的一切就是从路由中检索用户 ID(在这种情况下,用户的用户名),然后将一些信息传递到我们的视图中。从项目资源文件夹中,将位于protected/views/timeline/index.php文件复制到您的项目中。让我们看看这个文件中一些更有趣的部分。

在这个文件中,首先要注意的是我们只是简单地使用CActiveForm来显示新的共享容器。此外,在这个文件的底部,我们实现了一些 JavaScript 来进行一些基本的表单验证检查,以便在异步提交时清除文本字段,调整我们拥有的共享数量,最后将新的共享添加到我们的共享列表顶部。

第二个要注意的是,我们实现了条件关注和取消关注按钮链接,允许我们的用户简单地点击一个链接来关注或取消关注特定用户:

<?php if (Yii::app()->user->isGuest): ?>
   <?php echo CHtml::link('Login to follow ' . $user->name, $this->createUrl('site/login'), array('class' => 'btn btn-primary')); ?>
      <br /><br />
   <?php else: ?>
      <?php if (!User::isFollowing($id)): ?>
         <?php echo CHtml::link('Follow This User', $this->createUrl('user/follow/', array('id' => $id)), array('class' => 'btn btn-success')); ?>
      <?php else: ?>
         <?php echo CHtml::link('Stop Following This User', $this->createUrl('user/unfollow/', array('id' => $id)), array('class' => 'btn btn-danger')); ?>
   <?php endif; ?>
<?php endif; ?>

在这个文件中最后要注意的是我们使用我们在用户模型中之前设置的计数关系:

<a type="button" class="btn btn-primary" disabled>Followers: <?php echo $user->followeesCount; ?></a>
<a type="button" class="btn btn-primary" disabled>Following: <?php echo $user->followersCount; ?></a>
<a type="button" class="btn btn-primary" disabled>Shares: <span class="share-count"><?php echo $user->sharesCount; ?></span></a>

最后,我们通过注册一个异步回调来获取适当的共享来加载此用户的共享,无论我们正在查看哪个用户的共享:

<?php Yii::app()->clientScript->registerScript('loadshares', '$.get("' . $this->createUrl('share/getshares', array('id' => $id)) . '", function(data) { $(".shares").html(data); }); '); ?>

获取共享

现在,让我们实现我们的动作,该动作将显示我们的共享。这个动作的行为将根据我们是在查看我们的时间线还是另一个用户的时间线而略有不同。在我们的ShareController.php文件中,位于protected/controllers/,实现actionGetShares,如下所示:

public function actionGetShares($id=NULL) {}

下一步如下:

  1. 由于这是一个异步回调,我们不希望从我们的布局中渲染任何内容:

       $this->layout = false;
    
  2. 接下来,我们将抛出一个错误,如果未提供用户并且我们没有登录,或者如果我们登录并且有人给了我们一个 ID,我们将用户设置为 ourselves:

       if ($id == NULL)
       {
          if (Yii::app()->user->isGuest)
             throw new CHttpException(400, 'Cannot retrieve shares for that user');
    
          $id = Yii::app()->user->id;
       }
    
  3. 然后,我们将实现CListView,它将从我们的GET参数中检索数据:

       $myFollowers = array();
    
       // CListView for showing shares
       $shares = new Share('search');
       $shares->unsetAttributes();
    
       if(isset($_GET['Share']))
          $shares->attributes=$_GET['Share'];
    
  4. 当查看另一个用户的时间线时,我们只关心他们与世界共享的共享。然而,当我们查看我们的时间线时,我们希望查看我们的共享以及我们正在关注的用户的共享。我们可以这样实现控制器部分:

       // If this is NOT the current user, then only show stuff that belongs to this user
       if ($id != Yii::app()->user->id)
          $shares->author_id = $id;
       else
       {
          // Alter the criteria to do a search of everyone the current user is following
          $myFollowers[] = Yii::app()->user->id;
    
          $followers = Follower::model()->findAllByAttributes(array('follower_id' => Yii::app()->user->id));
          if ($followers != NULL)
          {
             foreach ($followers as $follower)
             $myFollowers[] = $follower->followee_id;
          }
       }
    
       $this->render('getshares', array('shares' => $shares, 'myFollowers' => $myFollowers));
    }
    
  5. 然后,我们需要在protected/views/shares/中实现我们的getshares.php视图文件,作为CListView。注意,我们正在将$myFollowers作为自定义参数传递给 Share 模型的search()方法:

    <?php $this->widget('zii.widgets.CListView', array(
        'dataProvider'=>$shares->search($myFollowers),
        'itemView'=>'share',
        'emptyText' => '<div class="center">This user hasn\'t shared anything yet!</div>',
        'template' => '{items}{pager}',
        'afterAjaxUpdate' => 'js:function() { init(); }
        ',
        'pager' => array(
            'header' => ' ',
            'selectedPageCssClass' => 'active',
            'htmlOptions' => array('class' => 'pagination')
        )
    ));
    
    Yii::app()->clientScript->registerScript('init', '
    function init() {
        $(".fa-heart").click(function() {
            var id = $(this).parent().parent().parent().attr("data-attr-id");
            var self = this;
            $.post("' . $this->createUrl('share/like') . '/" + id, function(data) {
                $(self).toggleClass("liked");
            });
        });
    
        $(".fa-mail-forward").click(function() {
            var id = $(this).parent().parent().parent().attr("data-attr-id");
            var self = this;
            $.post("' . $this->createUrl('share/re-share') . '/" + id, function(data) {
                $(self).toggleClass("liked");
            });
        });
    }
    
    init();
    ');
    
  6. 然后,在我们的模型中,我们将调整我们的search()方法,以便它有条件地加载适当的数据:

    public function search($items = array())
    {
       $criteria=new CDbCriteria;
    
       $criteria->compare('id',$this->id);
       $criteria->compare('text',$this->text,true);
       $criteria->compare('reply_id',$this->reply_id);
       $criteria->compare('created',$this->created);
    
       if (empty($items))
     $criteria->compare('author_id',$this->author_id);
     else
     $criteria->addInCondition('author_id', $items);
    
       $criteria->order = 'created DESC';
       return new CActiveDataProvider($this, array(
          'criteria' => $criteria,
       ));
    }
    
  7. 最后,我们可以通过将项目资源文件夹中的protected/views/share/share.php复制到我们的项目中来实现我们的单个分享视图。

在这个文件中,我们将实现一些自定义逻辑,以便将哈希标签(#)和@提及显示为链接。这将使我们能够在数据库中存储未格式化的文本,这意味着我们可以调整视图的工作方式,而无需修改我们的数据。我们还将以 Markdown 格式渲染我们的文本,以便我们的用户可以添加链接或其他自定义格式,但防止他们尝试 XSS 注入:

<?php
   $data->text = preg_replace("/#([A-Za-z0-9\/\.]*)/", "<a target=\"_new\" href=\"" . Yii::app()->controller->createAbsoluteUrl('timeline/search') ."?q=$1\">#$1</a>", $data->text);
   $data->text = preg_replace("/@([A-Za-z0-9\/\.]*)/", "<a href=\"" . Yii::app()->controller->createAbsoluteUrl('timeline/index'). "/$1\">@$1</a>", $data->text);
   $md = new CMarkdownParser;
   echo $md->safeTransform($data->text);

分享新内容

到目前为止,如果我们数据库中有分享,我们就能看到它们。所以让我们专注于分享新内容!从我们的控制器中,处理分享的动作只是简单地加载一个新的 Share 模型并填充它。看看以下代码:

public function actionCreate()
{
   $share = new Share;

   if (isset($_POST['Share']))
   {
      $share->attributes = array(
         'text' => $_POST['Share']['text'],
         'reply_id' => isset($_POST['Share']['reply_id']) ? $_POST['Share']['reply_id'] : NULL,
         'author_id' => Yii::app()->user->id
      );

      // Share the content
      if ($share->save())
      {
         $this->renderPartial('share', array('data' => $share));
         Yii::app()->end();
      }
   }

   throw new CHttpException(500, 'There was an error sharing your content');
}

然而,分享内容的真正力量在于我们的 Share 模型中的beforeSave()方法。从这里,我们处理模型中可能发生的所有提及,并向所有在分享中被提及的人发送电子邮件。代码如下:

public function afterSave()
{
   preg_match_all('/@([A-Za-z0-9\/\.]*)/', $this->text, $matches);
   $mentions = implode(',', $matches[1]);

   if (!empty($matches[1]))
   {
      $criteria = new CDbCriteria;
      $criteria->addInCondition('username', $matches[1]);
      $users = User::model()->findAll($criteria);

      foreach ($users as $user)
      {
         $sendgrid = new SendGrid(Yii::app()->params['includes']['sendgrid']['username'], Yii::app()->params['includes']['sendgrid']['password']);
         $email    = new SendGrid\Email();

         $email->setFrom(Yii::app()->params['includes']['sendgrid']['from'])
            ->addTo($user->email)
            ->setSubject("You've Been @mentioned!")
            ->setText("You've Been @mentioned!")
            ->setHtml(Yii::app()->controller->renderPartial('//email/mention', array('share' => $this, 'user' => $user), true));

         // Send the email
         $sendgrid->send($email);
      }
   }

   return parent::afterSave();
}

重新分享

由于我们的模型中已经实现了所有内容,我们可以轻松地现在作为一个新的控制器行为在protected/controllers/ShareController.php中实现重新分享。重新分享允许用户分享另一个用户分享的内容,同时仍然给予原始分享的用户应有的信用。在我们的控制器中,我们将加载我们想要重新分享的分享,将其作者更改为我们,然后指出这是另一个分享的重新分享。

首先,让我们创建一个loadModel()实用方法:

private function loadModel($id=NULL)
{
    if ($id == NULL)
        throw new CHttpException(400, 'Missing Share ID');

    return Share::model()->findByPk($id);
}

然后,我们将实现如描述项目部分所述的重新分享功能:

public function actionReshare($id=NULL)
{
   // Load the share model
   $share = $this->loadModel($id);

   // You can't reshare your own stuff
   if ($share->author_id == Yii::app()->user->id)
   return false;

   // You can't reshare stuff you've already reshared
   $reshare = Share::model()->findByAttributes(array(
      'author_id' => Yii::app()->user->id,
      'reshare_id' => $id
   ));

   if ($reshare !== NULL)
   return false;

   // Create a new Share as a reshare
   $model = new Share;

   // Assign the shared attributes
   $model->attributes = $share->attributes;

   // Set the reshare other to the current user
   $model->author_id = Yii::app()->user->id;

   // Propogate the reshare if this isn't original
   if ($model->reshare_id == 0 || $model->reshare_id == NULL)
   $model->reshare_id = $share->id;

   // Then save the reshare, return the response. Yii will set a 200 or 500 response code automagically if false
   return $model->save();
}

点赞和取消点赞分享

接下来,我们将实现用户对特定分享进行点赞和取消点赞所需的行为和方法。点赞的唯一限制是用户不能对同一个分享点赞超过一次。

我们可以在ShareController中实现点赞的动作如下:

public function actionLike($id=NULL)
{
   $share = $this->loadModel($id);
   if ($share->isLiked())
      return $share->unlike();

   return $share->like();
}

然后,在我们的 Share 模型中,我们将实现检查用户是否已经点赞某个行为的方法:

public function isLiked()
{
   $like = Like::model()->findByAttributes(array(
      'user_id' => Yii::app()->user->id,
      'share_id' => $this->id
   ));

   return $like != NULL;
}

然后,我们将实现like()方法:

public function like()
{
   $like = Like::model()->findByAttributes(array(
      'user_id' => Yii::app()->user->id,
      'share_id' => $this->id
   ));

   // Share is already liked, return true
   if ($like != NULL)
      return true;

   $like = new Like;
   $like->attributes = array(
      'share_id' => $this->id,
      'user_id' => Yii::app()->user->id
   );

   // Save the like
   return $like->save();
}

最后,我们将实现unlike()方法:

public function unlike()
{
   $like = Like::model()->findByAttributes(array(
      'user_id' => Yii::app()->user->id,
      'share_id' => $this->id
   ));

   // Item is not already liked, return true
   if ($like == NULL)
      return true;

   // Delete the Like
   return $like->delete();
}

查看分享

到目前为止,我们可以对分享做任何事情,除了深入查看一个分享并查看分享的所有回复。让我们实现actionView()方法,以便我们的用户可以查看特定的分享。在ShareController中,我们将实现如下:

public function actionView($id=NULL)
{
   $share = $this->loadModel($id);

   if ($share == NULL)
      throw new CHttpException(400, 'No share with that ID was found');

   $this->render('view', array(
      'share' => $share,
      'replies' => Share::model()->findAllByAttributes(array('reply_id' => $id), array('order' => 'created DESC')),
      'reply' => new Share
   ));
}

然后,我们将从我们的项目资源文件夹中复制 protected/views/share/view.php 到项目中。在我们的视图中,我们现在可以分享一些内容并点击分享上的眼睛图标以查看更多详细信息。

搜索分享

任何应用程序最重要的部分之一是能够搜索和发现新的内容。对于这个应用程序,我们将实现一个搜索方法,允许用户搜索内容和用户。为此,我们将检查我们的搜索方法中的查询字符串是否包含 @ 字符。如果包含,我们将对该用户执行第二次搜索,并在视图中显示该用户的信息。我们将按以下方式实现该方法:

  1. 我们将首先实现 actionSearch() 如下:

    public function actionSearch() {}
    
  2. 然后,我们将从我们的 $_GET 参数中检索查询字符串并定义我们模型的作用域:

    $query = isset($_GET['q']) ? $_GET['q'] : NULL;
    $users = $shares = NULL;
    
  3. 然后,只要存在要运行的查询,我们将创建两个 CDbCriteria 对象;一个用于用户,另一个用于分享:

    if ($query != NULL)
    {
       $userCriteria = new CDbCriteria;
       $searchCriteria = new CDbCriteria;
    }
    
  4. 在这个 if 括号内,我们将首先检查查询字符串中是否有任何提及,通过使用 preg_match_all

       preg_match_all('/@([A-Za-z0-9\/\.]*)/', $query, $matches);
       $mentions = implode(',', $matches[1]);
    
  5. 如果有任何结果,我们将构建一个查询以找到查询中提到的所有用户,然后,我们将从我们的查询字符串中删除该标准:

       if (!empty($matches[1]))
       {
          $userCriteria->addInCondition('username', $matches[1]);
          $users = User::model()->findAll($userCriteria);
          foreach ($matches[1] as $u)
             $query = str_replace('@'.$u,'',$query);
       }
    
  6. 然后,我们将对 Share 模型的 text 字段执行 LIKE 查询:

    $searchCriteria->addSearchCondition('text', $query);
    $searchCriteria->limit = 30;
    $shares = Share::model()->findAll($searchCriteria);
    
  7. 然后,我们将渲染我们的视图:

    $this->render('search', array(
       'users' => $users,
       'shares' => $shares
    ));
    
  8. 最后,我们需要将我们的视图文件从 protected/views/timeline/search.php 复制到我们的项目文件夹中。

使用 HybridAuth 在 Twitter 上分享

由于我们的应用程序还没有大量的追随者,因此允许我们的用户在他们网站上生成的内容分享到其他地方是很重要的。传播特定网站或服务的一个好方法是利用 Twitter。与 Twitter 集成的一种方法是通过利用他们的 OAuth API。这将允许我们以特定用户身份进行身份验证并在点击按钮时代表他们发布内容。

为了做到这一点,我们将利用 HybridAuth。HybridAuth 是一个开源库,允许开发者与多个第三方社交网络集成,并使开发者能够使他们的应用程序更加社交。就我们的目的而言,我们将利用 HybridAuth 来代表一个特定的用户(当然是在他们的许可下)并在他们请求时代表他们提交内容。

注意

如果你想了解更多关于 HybridAuth 的信息,请查看官方文档:hybridauth.sourceforge.net/

设置 Twitter 应用程序

在我们开始使用 HybridAuth 之前,我们首先需要设置一个 Twitter 应用程序并获取我们的 OAuth 凭证。这些凭证将允许我们的应用程序安全地与 Twitter 通信,并使我们能够登录并代表我们的用户进行发布。步骤如下:

注意

什么是 OAuth?OAuth 是一个开放标准,用于身份验证,并为客户端应用程序(例如我们在本应用程序中构建的应用程序)提供对服务器资源的安全委托访问,代表该所有者,在这种情况下,是 Twitter。通过使用 OAuth,我们可以安全地与服务器通信,而无需将我们的用户凭据传输到我们的应用程序。在我们的应用程序中,我们将使用 HybridAuth 来处理与 Twitter OAuth 端点交互的大部分工作。有关 OAuth 是什么以及它是如何工作的更多信息,请查看oauth.net/about/

  1. 首先,打开您的网络浏览器,导航到apps.twitter.com/,并使用您的 Twitter 凭据登录。

  2. 一旦完成认证,点击页面右上角的创建新应用按钮。

  3. 在此页面上,填写下一张截图所示的字段。调整网站 URL 和回调 URL以匹配您在应用程序中使用的设置。请注意,您提供给 Twitter 的端点必须是公开可访问的。设置 Twitter 应用

  4. 在下一页上,点击设置选项卡,勾选允许此应用程序用于登录 Twitter复选框,然后点击页面底部的更新设置按钮。

  5. 然后,点击权限选项卡,将访问级别更改为读取和写入并保存表单。

配置 HybridAuth

配置好我们的 Twitter 应用程序后,我们现在需要安装和配置 HybridAuth。幸运的是,HybridAuth 可以作为 Composer 依赖项使用,因此我们可以通过将以下内容添加到composer.json文件的 require 部分来将它的源代码包含到我们的项目中:

"hybridauth/hybridauth": "2.2.0.*@dev"

下一步如下:

  1. 从您的命令行运行composer update命令:

    composer update
    
    
  2. 您应该看到以下类似的输出:

    Loading composer repositories with package information
    Updating dependencies (including require-dev)
     - Installing hybridauth/hybridauth (2.2.0.x-dev 5774600)
     Cloning 57746000e5b2f96469b229b366e56eb70ab7bf20
    
    Writing lock file
    Generating autoload files
    
    
  3. 接下来,我们将配置 HybridAuth,使其知道使用哪些信息。打开protected/config/params.php,并在我们的 SendGrid 信息之后添加以下内容:

    'hybridauth' => array(
       'baseUrl' => '',
       'base_url' => '',
       'providers' => array(
          'Twitter' => array(
             'enabled' => true,
             'keys' => array(
                'key' => '<twiter_key>',
                'secret' => '<twitter_secret>'
             )
          )
       )
    )
    
  4. 然后,从我们的 Twitter 应用程序的API 密钥选项卡中检索您的 Twitter API 密钥和 Twitter 密钥,如下一张截图所示,并在您的配置文件中将<twitter_key><twitter_secret>替换为它们:

    注意

    您的 Twitter OAuth 密钥和密钥是机密信息,应将其从您的 DCVS 提供者中移除。如果您怀疑您的 OAuth 凭据已被泄露,应立即重新生成您的 API 密钥。这将防止潜在的攻击者获得登录和代表您的用户发推的能力。

配置 HybridAuth

注意

HybridAuth 可以使用几个不同的选项进行配置。如果您对在hybridauth.sourceforge.net/userguide/Configuration.html实现其他提供者的社交分享感兴趣,请务必查看一些示例。

实现 HybridAuth 社交登录和分享

现在我们应用程序已经有了 Twitter 的 OAuth 凭证,我们可以实现社交登录和分享功能:

  1. 首先,调整我们的accessRules()方法,仅允许经过身份验证的用户在 Twitter 上分享内容:

    array('allow',
       'actions' => array('create', 'reshare', 'like', 'delete', 'hybrid'),
       'users' => array('@')
    ),
    
  2. 然后,实现actionHybrid()方法:

    public function actionHybrid($id=NULL) {}
    
  3. 我们将首先查找一些特定的 HybridAuth $_GET参数,并在检测到这两个参数中的任何一个时调用Hybrid_Endpoint::process()

    if (isset($_GET['hauth_start']) || isset($_GET['hauth_done']))
        Hybrid_Endpoint::process();
    
  4. 然后,我们将下一部分包裹在一个try/catch块中,以捕获 HybridAuth 在遇到错误时可能抛出的任何错误:

    try {
    } catch (Exception $e) {
        $this->redirect($this->createUrl('timeline/index'));
    }
    
  5. 在我们的try/catch块中,我们将加载我们在params.php文件中设置的配置,并设置 HybridAuth 在我们应用程序内部使用的基 URL。这个基 URL 应该对应于 HybridAuth 将被调用的位置:

    $config = Yii::app()->params['includes']['hybridauth'];
    $config['baseUrl'] = $config['base_url'] = $this->createAbsoluteUrl('share/hybrid');
    
  6. 我们将使用我们的配置初始化 HybridAuth:

    $hybridauth = new Hybrid_Auth($config, array());
    
  7. 然后,我们将创建一个 HybridAuth 适配器,以便我们与 Twitter 进行通信:

    $adapter = $hybridauth->authenticate('Twitter');
    
  8. 接下来,我们应该检查adapter是否已连接到 Twitter:

    if ($adapter->isUserConnected()) {}
    
  9. 在这个if块中,我们应该加载我们想要在 Twitter 上分享的分享内容:

    $share = $this->loadModel($id);
    
  10. 然后,在 Twitter 上分享我们的内容:

    $response = $adapter->setUserStatus($share->text . ' | #Socialii ' . $this->createAbsoluteUrl('share/view', array('id' => $id)));
    Yii::app()->user->setFlash('success', 'Your status has been shared to Twitter');
    $this->redirect(Yii::app()->user->returnUrl);
    

现在,如果您在我们的网站上分享某些内容,然后点击该分享的 Twitter 图标;您将被重定向到 Twitter 进行登录,如下面的截图所示:

实现 HybridAuth 社交登录和分享

登录后,您需要授权我们的应用程序更新我们的 Twitter 个人资料,如下面的截图所示:

实现 HybridAuth 社交登录和分享

然后,我们的内容将代表我们在 Twitter 上分享,如下一个截图所示。此外,如果我们再次在我们的应用程序中点击 Twitter 按钮,我们的内容将自动为我们分享到 Twitter,而无需我们再次对 Twitter 进行身份验证。

实现 HybridAuth 社交登录和分享

摘要

哇,我们在本章中涵盖了相当多的内容!我们扩展了用户身份验证和管理,包括如果用户忘记密码时的安全激活和密码重置,并允许我们的用户通过适当的验证和通知安全地更改他们的电子邮件地址和密码。此外,我们使用CFormModel实现了所有这些操作,这使得我们能够干净地将处理这些操作的逻辑隔离在表单中而不是控制器中。最后,我们实现了异步的CListViews,并利用 HybridAuth 使用我们的 OAuth 凭证在 Twitter 上分享。

本章中我们开发的用户组件可以轻松用于和适应几乎任何需要用户认证和管理的应用。在下一章中,我们将利用这些组件构建一个全规模的内容管理系统,这将使我们能够上传内容和照片,并允许我们与他人共享这些内容。我们将构建的 CMS 也将是 SEO 优化的,并包括可以提交给搜索引擎的动态内容别名和网站地图功能。在进入下一章之前,请务必查阅www.yiiframework.com/doc/api/上的 Yii 类参考,并回顾本章中我们使用的所有类。然后,当你准备好时,前往下一章,在那里你将构建一个 CMS!

第六章. 构建内容管理系统

对于我们的下一个项目,我们将开发一个可扩展的多用户内容管理系统,这将允许我们的用户创建和更新博客文章,并使他们能够对这些博客文章进行评论。除了重新利用我们在以前的应用程序中开发的一些功能外,这个系统还将针对搜索引擎的最佳定位进行优化。此外,这个系统将具有社交登录功能,允许用户从第三方社交网络提供商注册和登录。我们还将探索在我们的应用程序中使用主题,这将使我们能够以最小的努力更改应用程序的表现层。

当我们完成时,我们的 CMS 将如下所示:

构建内容管理系统

前提条件

在我们开始之前,有一些事情我们需要设置并使其工作:

  • 再次强调,我们需要一个具有公开 IP 地址的 Web 服务器。这将允许将电子邮件发送到我们的应用程序。许多云虚拟专用服务器 (VPS) 提供商都提供低月费或按小时计费的服务。这些服务包括 www.digitalocean.comwww.linode.comwww.rackspace.com/cloud/servers

  • 为了在我们的应用程序中发送电子邮件,我们再次将利用一个免费的 SendGrid 开发者账户,该账户可以在 www.sendgrid.com/developers 上设置。

  • 在本章中,我们再次将使用最新的 MySQL 版本(在撰写本文时为 MySQL 5.6)。请确保你的 MySQL 服务器已经设置并运行在你的服务器上。

    注意

    你想尝试更具挑战性的内容吗?完成这个项目后,尝试找出你需要对这个应用程序进行哪些更改才能使其与 Postgres 而不是 MySQL 兼容。

  • 对于这个项目,我们再次将通过 Composer 管理我们的依赖项,你可以从 getcomposer.org/ 下载和安装它。

  • 我们还将使用 Disqus,这是一个第三方评论系统,我们将将其集成以在我们的网站上显示评论。对于这个项目,你需要注册一个 www.disqus.com 的账户。

  • 最后,你需要一个 Twitter 开发者账户,可以从 dev.twitter.com/ 获取。这个账户将允许我们通过 Twitter 的 OAuth API 启用我们应用程序的社交登录功能。

一旦你获得了列出的前提条件,在你的域名上创建一个子域名并将其指向你的服务器。在本章中,我将使用 chapter6.example.com 来指代这个子域名。在一切设置完毕并且你的服务器对该域名做出响应后,我们就可以开始了。

描述项目

我们的 CMS 可以分解为几个不同的组件:

  • 负责查看和管理内容的用户

  • 要管理的内客

  • 我们内容要放入的类别

  • 元数据帮助我们进一步定义我们的内容和用户

  • 搜索引擎优化

用户

我们应用程序的第一个组成部分是用户,他们将执行我们应用程序中的所有任务。对于这个应用程序,我们将大量重用我们在第五章“创建一个微博平台”中扩展的user数据库和认证系统。在本章中,我们将通过允许社交认证来增强这一功能。我们的 CMS 将允许用户通过在 Twitter 上注册来注册新账户;注册后,CMS 将允许他们通过在 Twitter 上登录来登录我们的应用程序。

为了让我们能够知道一个用户是否是社交认证用户,我们必须对我们的数据库和认证方案进行一些修改。首先,我们需要一种方式来指示用户是否是社交认证用户。而不是在我们的数据库中硬编码一个isAuthenticatedViaTwitter列,我们将创建一个新的数据库表,称为user_metadata,它将是一个简单的表,包含用户的 ID、一个唯一键和一个值。这将允许我们存储有关我们用户的其他信息,而无需每次我们想要进行更改时都明确更改我们的用户数据库表:

ID INTEGER PRIMARY KEY
user_id INTEGER
key STRING
value STRING
created INTEGER
updated INTEGER

我们还需要修改我们的UserIdentity类,以允许社交认证用户登录。为此,我们将扩展这个类来创建一个RemoteUserIdentity类,它将使用 Twitter(或任何其他与 HybridAuth 合作的第三方来源)提供给我们的 OAuth 代码,而不是通过用户名和密码进行认证。

内容

我们 CMS 的核心是我们将管理的内客。对于这个项目,我们将管理可以附加额外元数据的简单博客帖子。每个帖子将有一个标题、正文、作者、类别、唯一的 URI 或缩略名,以及一个指示它是否已发布的标志。我们这个表的数据库结构将如下所示:

ID INTEGER PRIMARY KEY
title STRING
body TEXT
published INTEGER
author_id INTEGER
category_id INTEGER
slug STRING
created INTEGER
updated INTEGER

每个帖子还将有一个或多个元数据列,将进一步描述我们将创建的帖子。我们可以使用这个表(我们将称之为content_metadata)来让我们的系统自动为我们存储有关每个帖子的信息,或者我们自己添加信息到我们的帖子中,从而消除每次我们想要向内容添加新属性时都需要不断迁移数据库的需求:

ID INTEGER PRIMARY KEY
content_id INTEGER
key STRING
value STRING
created INTEGER
updated INTEGER

类别

每个帖子都将与我们的系统中的一个类别相关联。这些类别将帮助我们进一步细化我们的帖子。就像我们的内容一样,每个类别都将有自己的缩略名。在保存帖子或类别之前,我们需要验证缩略名是否已被使用。我们的表结构将如下所示:

ID INTEGER PRIMARY KEY
name STRING
description TEXT
slug STRING
created INTEGER
updated INTEGER

搜索引擎优化

我们应用程序的最后一个核心组件是对搜索引擎的优化,以便我们的内容可以快速索引。SEO 很重要,因为它增加了我们在搜索引擎和其他营销材料上的可发现性和可用性。在我们的应用程序中,我们将执行一些操作来提高我们的 SEO:

  • 我们将添加的第一个 SEO 增强功能是一个sitemap.xml文件,我们可以将其提交给流行的搜索引擎以进行索引。搜索引擎可以非常快速地索引我们的sitemap.xml文件,这意味着我们的内容将更快地出现在搜索引擎中。

  • 我们将添加的第二个增强功能是我们之前讨论过的 slugs。Slugs 允许我们从 URL 直接指示特定帖子是关于什么的。因此,我们不必有像http://chapter6.example.com/content/post/id/5这样的 URL,我们可以有像http://chapter6.example.com/my-awesome-article这样的 URL。这类 URL 允许搜索引擎和我们的用户在甚至不看内容本身的情况下了解我们的内容,例如当用户在浏览他们的书签或浏览搜索引擎时。

初始化项目

为了给我们提供一个共同的起点,本章的项目资源中包含了一个骨架项目。这个骨架项目中包含了必要的迁移、数据文件、控制器和视图,以便我们开始开发。此外,骨架项目中还包含了我们在第五章“创建一个微博平台”中工作的用户认证类。将这个骨架项目复制到您的 Web 服务器上,按照本章开头概述的配置,使其响应chapter6.example.com,然后执行以下步骤以确保一切设置正确:

  1. 调整assetsprotected/runtime文件夹的权限,以便它们可以被您的 Web 服务器写入。

  2. 在本章中,我们将再次使用 MySQL 的最新版本(在撰写本文时为 MySQL 5.6)。请确保您的 MySQL 服务器已在您的服务器上设置并运行。然后,为我们的项目创建一个用户名、密码和数据库名,并相应地更新您的protected/config/main.php文件。为了简单起见,您可以使用ch6_cms作为每个值。

  3. 安装我们的 Composer 依赖项:

    Composer install
    
    
  4. 运行migrate命令并安装我们的模拟数据:

    php protected/yiic.php migrate up --interactive=0
    psql ch6_cms -f protected/data/postgres.sql
    
    
  5. 最后,将您的 SendGrid 凭据添加到protected/config/params.php文件中:

    'sendgrid' => array(
    'username' => '<username>',
       'password' => '<password>',
       'from' => 'noreply@ch6.home.erianna.net'
    )
    

如果一切加载正确,你应该会看到一个类似于以下内容的 404 页面:

初始化项目

探索骨架项目

实际上,在后台有很多不同的操作来使这个工作即使这是一个 404 错误。在我们开始任何开发之前,让我们看看在protected/components文件夹中提供的几个类。

从通用类扩展模型

我们提供的第一类是一个名为CMSActiveRecord的 ActiveRecord 扩展,所有我们的模型都将从此类派生。此类允许我们减少每个类中需要编写的代码量。目前,我们将简单地添加CTimestampBehavior和我们在前几章中使用的afterFind()方法,以存储在需要比较更改属性与新属性时所需的老属性:

class CMSActiveRecordCMSActiveRecord extends CActiveRecord
{
   public $_oldAttributes = array();

   public function behaviors()
   {
      return array(
         'CTimestampBehavior' => array(
            'class'          => 'zii.behaviors.CTimestampBehavior',
            'createAttribute'    => 'created',
            'updateAttribute'    => 'updated',
            'setUpdateOnCreate' => true
         )
      );
   }

   public function afterFind()
   {
      if ($this !== NULL)
         $this->_oldAttributes = $this->attributes;
      return parent::afterFind();
   }
}

为别名创建自定义验证器

由于ContentCategory类都有别名,我们需要为每个类添加一个自定义验证器,以确保别名没有被帖子或类别使用。为此,我们有一个名为CMSSlugActiveRecord的类,它扩展了CMSActiveRecord并具有一个validateSlug()方法,我们将按以下方式实现它:

class CMSSLugActiveRecord extends CMSActiveRecord
{
   public function validateSlug($attributes, $params)
   {
      // Fetch any records that have that slug
      $content = Content::model()->findByAttributes(array('slug' => $this->slug));
      $category = Category::model()->findByAttributes(array('slug' => $this->slug));

      $class = strtolower(get_class($this));

      if ($content == NULL && $category == NULL)
         return true;
      else if (($content == NULL && $category != NULL) || ($content != NULL && $category == NULL))
      {
         $this->addError('slug', 'That slug is already in use');
         return false;
      }
      else
      {
         if ($this->id == $$class->id)
            return true;
      }

      $this->addError('slug', 'That slug is already in use');
      return false;
   }
}

此实现仅检查数据库中是否存在具有该别名的任何项。如果没有找到,或者当前项是正在修改的项,则验证器将返回 true。否则,它将在slug属性中添加一个错误并返回 false。我们的内容模型和类别模型都将从此类扩展。

使用主题进行视图管理

与大型应用程序一起工作的最大挑战之一是在不锁定功能到我们的视图中的情况下更改其外观。进一步将我们的业务逻辑与我们的展示逻辑分离的一种方法就是使用主题。在 Yii 中使用主题,我们可以通过利用Yii::app()->setTheme('themename')方法简单地动态更改应用程序的展示层。一旦调用此方法,Yii 将查找themes/themename/views目录中的视图文件,而不是protected/views目录。在本章的其余部分,我们将向位于themes文件夹中的自定义主题main添加视图。为了全局设置此主题,我们将创建一个名为CMSController的自定义类,所有我们的控制器都将从此类扩展。目前,我们的主题名称将硬编码在我们的应用程序中。然而,这个值可以很容易地从数据库中检索,这样我们就可以通过缓存或数据库值动态更改主题,而不是在控制器中更改它。请查看以下代码行:

class CMSController extends CController
{
   public function beforeAction($action)
   {
      Yii::app()->setTheme('main');
      return parent::beforeAction($action);
   }
}

注意

或者,您可以使用官方文档中概述的protected/config/main.php文件中的theme属性www.yiiframework.com/doc/guide/1.1/en/topics.theming。虽然在那里操作主题很简单,但它要求我们的最终用户了解如何操作 PHP 数组。如果您打算允许您的最终用户操作他们网站的主题,建议您通过Yii::app()->setTheme从缓存或数据库值以编程方式操作。确保查看文档以获取有关使用主题的更多信息。

真正的动态路由

在我们之前的应用程序中,我们有许多长而无聊的 URL,其中包含大量的 ID 和参数。这些 URL 提供了糟糕的用户体验,并阻止搜索引擎和用户一眼就能知道内容是什么,这反过来又会损害我们在许多搜索引擎上的 SEO 排名。为了解决这个问题,我们将对我们的UrlManager类进行重大修改,以允许真正的动态路由,这意味着每次我们创建或更新一篇帖子或一个类别时,我们的 URL 规则都将被更新。

告诉 Yii 使用我们的自定义 UrlManager

在我们可以开始处理我们的控制器之前,我们需要创建一个自定义的UrlManager来处理我们内容的路由,这样我们就可以通过其别名来访问我们的内容。步骤如下:

  1. 我们需要做的第一个更改是更新我们的protected/config/main.php文件中的components部分。这将告诉 Yii 使用哪个类作为UrlManager组件:

    'urlManager' => array(
             'class'          => 'application.components.CMSUrlManager',
            'urlFormat'      => 'path',
            'showScriptName' => false
    )
    
  2. 接下来,在我们的protected/components文件夹中,我们需要创建CMSUrlManager.php

    class CMSUrlManager extends CUrlManager {}
    
  3. CUrlManager 通过填充一个规则数组来工作。当 Yii 启动时,它将触发processRules()方法来确定应该执行哪个路由。我们可以重载这个方法来注入我们自己的规则,这将确保我们想要执行的操作被执行。

  4. 要开始,让我们首先定义一组我们想要加载的默认路由。以下代码片段中定义的路由将允许我们在搜索和主页上进行分页,为我们的sitemap.xml文件提供一个静态路径,并为 HybridAuth 提供用于社交认证的路由:

    public $defaultRules    = array(
       '/sitemap.xml'           => '/content/sitemap',
       '/search/<page:\d+>'     => '/content/search',
       '/search'                => '/content/search',
       '/blog/<page:\d+>'       => '/content/index',
       '/blog'                  => '/content/index',
       '/'                      => '/content/index',
       '/hybrid/<provider:\w+>' => '/hybrid/index',
    );
    
  5. 然后,我们将实现我们的processRules()方法:

    protected function processRules() {}
    
  6. CUrlManager已经有一个公开属性,我们可以通过它来修改规则,所以我们将把我们的规则注入到这个属性中。rules属性是可以在我们的配置文件中访问的相同属性。由于processRules()在每次页面加载时都会被调用,我们还将利用缓存,这样我们的规则就不需要每次都生成。我们将首先尝试从我们的缓存中加载任何预先生成的规则,这取决于我们是否处于调试模式:

    $this->rules = !YII_DEBUG ? Yii::app()->cache->get('Routes') : array();
    

    如果我们获取的规则已经设置好了,我们将直接返回它们;否则,我们将生成规则,将它们放入我们的缓存中,然后附加我们在前几章中使用的基准 URL 规则:

    if ($this->rules == false || empty($this->rules))
    {
       $this->rules = array();
       $this->rules = $this->generateClientRules();
       $this->rules = CMap::mergearray($this->addRssRules(), $this->rules);
    
       Yii::app()->cache->set('Routes', $this->rules);
    }        
    
    $this->rules['<controller:\w+>/<action:\w+>/<id:\w+>'] = '<controller>/<action>';
    $this->rules['<controller:\w+>/<action:\w+>'] = '<controller>/<action>';
    
    return parent::processRules();
    
  7. 为了抽象起见,在我们的processRules()方法中,我们使用了两个我们需要创建的方法:generateClientRules,它将生成内容和类别的规则,以及addRSSRules,它将为每个类别生成 RSS 路由。

    第一个方法generateClientRules()只是加载我们之前定义的默认规则,以及从我们的内容和类别生成的规则,这些规则由generateRules()方法填充:

    private function generateClientRules()
    {
       $rules = CMap::mergeArray($this->defaultRules, $this->rules);
       return CMap::mergeArray($this->generateRules(), $rules);
    }
    
    private function generateRules()
    {
       return CMap::mergeArray($this->generateContentRules(), $this->generateCategoryRules());
    }
    
  8. 我们刚刚定义的 generateRules() 方法实际上调用了构建我们路由的方法。每个路由都是一个键值对,其形式如下:

    array(
        '<slug>' => '<controller>/<action>/id/<id>'
    )
    

    内容规则将包括一个已发布的条目。请看以下代码:

    private function generateContentRules()
    {
       $rules = array();
       $criteria = new CDbCriteria;
       $criteria->addCondition('published = 1');
    
       $content = Content::model()->findAll($criteria);
       foreach ($content as $el)
       {
          if ($el->slug == NULL)
             continue;
    
          $pageRule = $el->slug.'/<page:\d+>';
          $rule = $el->slug;
    
          if ($el->slug == '/')
             $pageRule = $rule = '';
    
          $pageRule = $el->slug . '/<page:\d+>';
       $rule = $el->slug;
    
       $rules[$pageRule] = "content/view/id/{$el->id}";
       $rules[$rule] = "content/view/id/{$el->id}";
       }
    
       return $rules;
    }
    
  9. 我们的分类规则将包括我们数据库中的所有分类。请看以下代码:

    private function generateCategoryRules()
    {
       $rules = array();
       $categories = Category::model()->findAll();
       foreach ($categories as $el)
       {
          if ($el->slug == NULL)
             continue;
    
          $pageRule = $el->slug.'/<page:\d+>';
          $rule = $el->slug;
    
          if ($el->slug == '/')
             $pageRule = $rule = '';
    
          $pageRule = $el->slug . '/<page:\d+>';
       $rule = $el->slug;
    
       $rules[$pageRule] = "category/index/id/{$el->id}";
       $rules[$rule] = "category/index/id/{$el->id}";
       }
    
       return $rules;
    }
    
  10. 最后,我们将添加允许 RSS 阅读器读取整个网站或特定分类的所有内容的 RSS 规则,如下所示:

    private function addRSSRules()
    {
       $categories = Category::model()->findAll();
       foreach ($categories as $category)
          $routes[$category->slug.'.rss'] = "category/rss/id/{$category->id}";
    
       $routes['blog.rss'] = '/category/rss';
       return $routes;
    }
    

注意

CUrlManager 有许多不同的组件。如果您有任何问题,请确保参考 www.yiiframework.com/doc/api/1.1/CUrlManager 中的 Yii 类参考。

显示和管理内容

现在,Yii 已经知道如何路由我们的内容,我们可以开始工作,显示和管理它。首先,在 protected/controllers 中创建一个新的控制器,命名为 ContentController,它继承自 CMSController。请看以下代码行:

class ContentController extends CMSController {}

首先,我们将定义我们的 accessRules() 方法和我们将要使用的默认布局。以下是方法:

public $layout = 'default';

public function filters()
{
   return array(
      'accessControl',
   );
}

public function accessRules()
{
   return array(
      array('allow',
         'actions' => array('index', 'view', 'search'),
         'users' => array('*')
      ),
      array('allow',
         'actions' => array('admin', 'save', 'delete'),
         'users'=>array('@'),
         'expression' => 'Yii::app()->user->role==2'
      ),
      array('deny',  // deny all users
         'users'=>array('*'),
      ),
   );
}

渲染网站地图

我们将要实现的第一种方法是我们的网站地图行为。在 ContentController 中创建一个新的方法,命名为 actionSitemap()

public function actionSitemap() {}

需要执行的步骤如下:

  1. 由于网站地图以 XML 格式提供,我们将首先在 protected/config/main.php 文件中禁用 WebLogRoute。这将确保当搜索引擎尝试索引它时,我们的 XML 能够通过验证:

    Yii::app()->log->routes[0]->enabled = false;
    
  2. 然后,我们将发送适当的 XML 头部,禁用布局的渲染,并刷新任何可能被排入队列发送到浏览器的所有内容:

    ob_end_clean();
    header('Content-type: text/xml; charset=utf-8');
    $this->layout = false;
    
  3. 接下来,我们将加载所有已发布的条目和分类,并将它们发送到我们的网站地图视图:

    $content = Content::model()->findAllByAttributes(array('published' => 1));
    $categories = Category::model()->findAll();
    
    $this->renderPartial('sitemap', array(
       'content'      => $content,
       'categories'   => $categories,
       'url'          => 'http://'.Yii::app()->request->serverName . Yii::app()->baseUrl
    ));
    
  4. 最后,我们有两种渲染此视图的方法。我们既可以将其作为主题的一部分放在 themes/main/views/content/sitemap.php 中,也可以将其放在 protected/views/content/sitemap.php 中。由于网站地图的结构不太可能改变,让我们将其放在 protected/views 文件夹中:

    <?php echo '<?xml version="1.0" encoding="UTF-8"?>'; ?>
    <urlset >
       <?php foreach ($content as $v): ?>
          <url>
             <loc><?php echo $url .'/'. htmlspecialchars(str_replace('/', '', $v['slug']), ENT_QUOTES, "utf-8"); ?></loc>
             <lastmod><?php echo date('c', strtotime($v['updated']));?></lastmod>
             <changefreq>weekly</changefreq>
             <priority>1</priority>
          </url>
       <?php endforeach; ?>
       <?php foreach ($categories as $v): ?>
          <url>
             <loc><?php echo $url .'/'. htmlspecialchars(str_replace('/', '', $v['slug']), ENT_QUOTES, "utf-8"); ?></loc>
             <lastmod><?php echo date('c', strtotime($v['updated']));?></lastmod>
             <changefreq>weekly</changefreq>
             <priority>0.7</priority>
          </url>
       <?php endforeach; ?>
    </urlset>
    

    注意

    尽管我们已经告诉 Yii 在主题内查找视图文件,但如果它无法在 themes 文件夹内找到视图文件,它仍然会在 protected/views 文件夹中查找。这个特性允许我们将不会改变的观点(如网站地图和 RSS 源)与将实际呈现给用户的观点分开。

您现在可以在浏览器中加载 http://chapter6.example.com/sitemap.xml 来查看网站地图。在您的网站上线之前,请确保将此文件提交给搜索引擎以便它们进行索引。

显示内容列表视图

接下来,我们将实现显示所有内容和一个特定帖子所需的行为。我们将首先提供我们帖子的分页视图。由于 CListView 和内容模型的 search() 方法已经提供了这个功能,我们可以利用这些类来生成和显示这些数据:

  1. 首先,打开protected/models/Content.php并修改search()方法的返回值,如下所示。这将确保 Yii 的分页在我们的CListView中使用正确的变量,并告诉 Yii 每页要加载多少结果。

    return new CActiveDataProvider($this, array(
    'criteria'    =>$criteria,
    'pagination'  => array(
    'pageSize'    => 5,
    'pageVar'     =>'page'
    )
    ));
    
  2. 接下来,实现带有$page参数的actionIndex()方法。我们之前已经告诉了我们的UrlManager如何处理这个问题,这意味着我们将获得用于分页的相当不错的 URI(例如,/blog/blog/2/blog/3等等):

    public function actionIndex($page=1)
    {
       // Model Search without $_GET params
       $model = new Content('search');
       $model->unsetAttributes();
       $model->published = 1;
    
       $this->render('//content/all', array(
          'dataprovider' => $model->search()
       ));
    }
    
  3. 然后,在themes/main/views/content/all.php中创建一个视图;这将显示dataProvider内的数据:

    <?php $this->widget('zii.widgets.CListView', array(
        'dataProvider'=>$dataprovider,
        'itemView'=>'//content/list',
        'summaryText' => '',
        'pager' => array(
           'htmlOptions' => array(
              'class' => 'pager'
           ),
           'header' => '',
           'firstPageCssClass'=>'hide',
           'lastPageCssClass'=>'hide',
           'maxButtonCount' => 0
        )
    ));
    
  4. 最后,从项目资源文件夹中复制themes/main/views/content/all.php,以便我们的视图可以渲染。

由于我们的数据库已经填充了一些示例数据,你可以立即开始尝试结果,如下面的截图所示:

显示内容列表视图

通过 ID 显示内容

由于我们的路由规则已经设置,显示我们的内容非常简单。我们只需要搜索一个传递给视图动作的已发布的模型并渲染它:

public function actionView($id=NULL)
{
   // Retrieve the data
   $content = Content::model()->findByPk($id);
   // beforeViewAction should catch this
   if ($content == NULL || !$content->published)
      throw new CHttpException(404, 'The article you specified does not exist.');
   $this->render('view', array(
      'id'   => $id,
      'post' => $content
   ));
}

在将themes/main/views/content/view.php从项目资源文件夹复制到你的项目中后,你将能够从主页点击进入特定的帖子。在其当前形式下,这个动作引入了一个有趣的副作用,可能会对我们的搜索引擎优化排名产生负面影响——相同的条目现在可以从两个 URI 访问。例如,http://chapter6.example.com/content/view/id/1http://chapter6.example.com/quis-condimentum-tortor现在都会显示相同的帖子。幸运的是,修复这个错误相当简单。由于我们的 slugs 的目的是提供更具描述性的 URI,我们将简单地阻止用户从非 slugged URI 访问视图:

我们将通过创建一个名为beforeViewAction()的新方法来实现这一点,该方法接受条目 ID 作为参数,并在调用actionView()方法后立即被调用。这个私有方法将简单地检查CHttpRequest中的 URI 以确定actionView是如何被访问的,如果它不是通过我们漂亮的 slugs,则返回 404:

private function beforeViewAction($id=NULL)
{
   // If we do not have an ID, consider it to be null, and throw a 404 error
   if ($id == NULL)
      throw new CHttpException(404,'The specified post cannot be found.');

   // Retrieve the HTTP Request
   $r = new CHttpRequest();

   // Retrieve what the actual URI
   $requestUri = str_replace($r->baseUrl, '', $r->requestUri);

   // Retrieve the route
   $route = '/' . $this->getRoute() . '/' . $id;
   $requestUri = preg_replace('/\?(.*)/','',$requestUri);

   // If the route and the uri are the same, then a direct access attempt was made, and we need to block access to the controller
   if ($requestUri == $route)
      throw new CHttpException(404, 'The requested post cannot be found.');

    return str_replace($r->baseUrl, '', $r->requestUri);
}

然后,在我们的actionView开始之后,我们可以同时设置正确的返回 URL,并阻止未通过 slug 访问的内容,如下所示:

Yii::app()->user->setReturnUrl($this->beforeViewAction($id));

使用 Disqus 在我们的 CMS 中添加评论

目前,我们的内容仅具有信息性质——我们无法让我们的用户与我们交流他们对条目的看法。为了鼓励参与,我们可以在我们的 CMS 中添加一个评论系统,以进一步与读者互动。我们不必编写自己的评论系统,可以利用 Disqus 提供的评论,Disqus 是一个免费的三方评论系统。即使通过 Disqus,评论也是通过 JavaScript 实现的,我们可以为它创建一个自定义的小部件包装器,以便在我们的网站上显示评论。步骤如下:

  1. 首先,按照前言部分概述的先决条件登录您在本章开头创建的 Disqus 账户。然后,导航到disqus.com/admin/create/并填写表单字段,如提示和以下截图所示:使用 Disqus 添加我们的 CMS 评论

  2. 然后,在protected/config/params.php文件中添加一个disqus部分,包含您的网站shortname

    'disqus' => array(
        'shortname' => 'ch6disqusexample',
    )
    
  3. 接下来,在protected/components中创建一个新的小部件DisqusWidget.php。这个小部件将在我们的视图中加载,并由我们的内容模型填充:

    class DisqusWidget extends CWidget {}
    
  4. 首先,指定我们的视图将能够注入的公共属性,如下所示:

    public $shortname = NULL;
    
    public $identifier = NULL;
    
    public $url = NULL;
    
    public $title = NULL;
    
  5. 然后,重载init()方法以加载 Disqus JavaScript 回调,并将 JavaScript 变量填充到小部件中,如下所示:

    public function init()
    {
       parent::init();
       if ($this->shortname == NULL)
          throw new CHttpException(500, 'Disqus shortname is required');
    
       echo "<div id='disqus_thread'></div>";
       Yii::app()->clientScript->registerScript('disqus', "
           var disqus_shortname = '{$this->shortname}';
           var disqus_identifier = '{$this->identifier}';
           var disqus_url = '{$this->url}';
           var disqus_title = '{$this->title}';
    
            /* * * DON'T EDIT BELOW THIS LINE * * */
            (function() {
                var dsq = document.createElement('script'); dsq.type = 'text/javascript'; dsq.async = true;
                dsq.src = '//' + disqus_shortname + '.disqus.com/embed.js';
                (document.getElementsByTagName('head')[0] || document.getElementsByTagName('body')[0]).appendChild(dsq);
            })();
       ");
    }
    
  6. 最后,在我们的themes/main/views/content/view.php文件中,按照以下方式加载小部件:

    <?php $this->widget('DisqusWidget', array(
          'shortname'  => Yii::app()->params['includes']['disqus']['shortname'],
          'url'        => $this->createAbsoluteUrl('/'.$post->slug),
          'title'     => $post->title,
          'identifier' => $post->id
       )); ?>
    

现在,当您加载任何给定帖子时,Disqus 评论也将与该帖子一起加载。试试看吧!

使用 Disqus 添加我们的 CMS 评论

注意

使用 Disqus 作为服务提供商的最大好处是,它使我们能够专注于产品的集成,而不是原始实现。通过每次需要服务时不必重新发明轮子,我们可以节省大量时间和金钱。然而,当依赖第三方时,请注意,服务提供商可能第二天就不存在了。虽然不太可能,但大型服务提供商可能一夜之间就会倒闭,因此请准备好制定计划以替换或替代您集成到应用程序中的任何第三方服务。

搜索内容

接下来,我们将实现一个搜索方法,以便我们的用户可以搜索帖子。为此,我们将实现CActiveDataProvider的一个实例,并将该数据传递到我们的themes/main/views/content/all.php视图以进行渲染和分页:

public function actionSearch()
{
    $param = Yii::app()->request->getParam('q');

    $criteria = new CDbCriteria;

    $criteria->addSearchCondition('title',$param,'OR');
    $criteria->addSearchCondition('body',$param,'OR');

    $dataprovider = new CActiveDataProvider('Content', array(
        'criteria'=>$criteria,
        'pagination' => array(
            'pageSize' => 5,
            'pageVar'=>'page'
        )
    ));

    $this->render('//content/all', array(
      'dataprovider' => $dataprovider
   ));
}

由于我们的视图文件已经存在,我们现在可以在我们的 CMS 中搜索内容。

注意

在前面的章节中,我们可能会简单地查询$_POST数组以获取我们的搜索参数。使用Yii::app()->request->getParam()方法从CHttpRequest类检索这些变量的更 Yii 的方式是。但是,请注意,此方法同时操作$_GET$_POST参数。如果相同的参数通过两种 HTTP 方法发送(在可能的情况下应避免这样做),则仅返回$_GET方法。确保阅读www.yiiframework.com/doc/api/1.1/CHttpRequestCHttpRequest类参考页面以获取更多信息。

管理内容

接下来,我们将实现一组基本的管理工具,这将使我们能够创建、更新和删除条目:

  1. 我们将首先定义我们的loadModel()方法和actionDelete()方法:

    private function loadModel($id=NULL)
    {
       if ($id == NULL)
          throw new CHttpException(404, 'No category with that ID exists');
    
       $model = Content::model()->findByPk($id);
    
       if ($model == NULL)
          throw new CHttpException(404, 'No category with that ID exists');
    
       return $model;
    }
    
    public function actionDelete($id)
    {
       $this->loadModel($id)->delete();
    
       $this->redirect($this->createUrl('content/admin'));
    }
    
  2. 接下来,我们可以实现我们的管理视图,这将使我们能够查看系统中所有的内容并创建新的条目。在使用此视图之前,请确保将项目资源文件夹中的themes/main/views/content/admin.php文件复制到您的项目中:

    public function actionAdmin()
    {
       $model = new Content('search');
    
       $model->unsetAttributes();
    
       if (isset($_GET['Content']))
          $model->attributes = $_GET;
    
       $this->render('admin', array(
          'model' => $model
       ));
    }
    
  3. 最后,我们将实现一个保存视图来创建和更新条目。保存内容将简单地通过我们的内容模型的验证规则。我们将添加的唯一覆盖是确保作者被分配给编辑条目的用户。在使用此视图之前,请确保将项目资源文件夹中的themes/main/views/content/save.php文件复制到您的项目中:

    public function actionSave($id=NULL)
    {
       if ($id == NULL)
          $model = new Content;
       else
          $model = $this->loadModel($id);
    
       if (isset($_POST['Content']))
       {
          $model->attributes = $_POST['Content'];
    
          $model->author_id = Yii::app()->user->id;
    
          if ($model->save())
          {
             Yii::app()->user->setFlash('info', 'The articles was saved');
             $this->redirect($this->createUrl('content/admin'));
          }
       }
    
       $this->render('save', array(
          'model' => $model
       ));
    }
    

到目前为止,您现在可以使用以下表格中提供的凭据登录系统并开始管理条目:

用户名 密码
user1@example.com test
user2@example.com test

查看和管理分类

现在,让我们转向查看和管理分类。如前所述,每个分类将通过一个专门的路由访问,并且只会显示该分类内的内容。我们将首先在protected/controllers/CategoryController.php中定义我们的默认访问规则和布局名称:

public $layout = 'default';

public function filters()
{
   return array(
      'accessControl',
   );
}

public function accessRules()
{
   return array(
      array('allow',
         'actions' => array('index', 'view', 'rss'),
         'users' => array('*')
      ),
      array('allow',
         'actions' => array('admin', 'save', 'delete'),
         'users'=>array('@'),
         'expression' => 'Yii::app()->user->role==2'
      ),
      array('deny',  // deny all users
         'users'=>array('*'),
      ),
   );
}

在分类中查看条目

在每个分类中显示条目几乎与显示所有条目相同,因此我们可以如下实现我们的索引操作。请注意,传递给此方法的参数只是从我们之前生成的路由中传递过来的。

public function actionIndex($id=1, $page=1)
{
   $category = $this->loadModel($id);

   // Model Search without $_GET params
   $model = new Content('search');
   $model->unsetAttributes();

   $model->attributes = array(
      'published' => 1,
      'category_id' => $id
   );

   $_GET['page'] = $page;

   $this->render('//content/all', array(
      'dataprovider' => $model->search()
   ));
}

查看分类的 RSS 源

在特定分类中查看条目的另一种方法是通过 RSS 源。RSS 源是一个非常流行的媒介,允许用户订阅您的内容,并在不访问每个网站的情况下定期收到更新通知。我们显示分类条目在 RSS 源中的操作如下:

public function actionRss($id=NULL)
{
   Yii::app()->log->routes[0]->enabled = false;

   ob_end_clean();
   header('Content-type: text/xml; charset=utf-8');

   $this->layout = false;

   $criteria = new CDbCriteria;

   if ($id != NULL)
      $criteria->addCondition("category_id = " . $id);

   $criteria->order = 'created DESC';
   $data = Content::model()->findAll($criteria);

   $this->renderPartial('rss', array(
      'data'   => $data,
      'url'   => 'http://'.Yii::app()->request->serverName . Yii::app()->baseUrl
   ));
}

然后,将以下内容添加到您的protected/views/category/rss.php文件中:

<?php echo '<?xml version="1.0" encoding="UTF-8" ?>'; ?>
<rss version="2.0" >
   <channel>
      <atom:link href="<?php echo $url.Yii::app()->request->requestUri; ?>" rel="self" type="application/rss+xml" />
      <title><?php echo Yii::app()->name; ?></title>
      <link><?php echo $url; ?></link>
      <description><?php echo Yii::app()->name; ?> Blog</description>
      <language>en-us</language>
      <pubDate><?php echo date('D, d M Y H:i:s T'); ?></pubDate>
      <lastBuildDate><?php echo date('D, d M Y H:i:s T'); ?></lastBuildDate>
      <docs>http://blogs.law.harvard.edu/tech/rss</docs>

      <?php foreach ($data as $k=>$v): ?>
         <item>
            <title><?php echo htmlspecialchars(str_replace('/', '', $v['title']), ENT_QUOTES, "utf-8"); ?></title>
            <link><?php echo $url.'/'.htmlspecialchars(str_replace('/', '', $v['slug']), ENT_QUOTES, "utf-8"); ?></link>
            <description>
               <?php
                  $md = new CMarkdownParser;
                  echo htmlspecialchars(strip_tags($md->transform($v['body'])), ENT_QUOTES, "utf-8");
               ?>
            </description>
            <category><?php echo htmlspecialchars(Category::model()->findByPk($v['category_id'])->name,  ENT_QUOTES, "utf-8"); ?></category>
            <author><?php echo User::model()->findByPk($v['author_id'])->email; ?> (<?php echo User::model()->findByPk($v['author_id'])->username; ?>)</author>
            <pubDate><?php echo date('D, d M Y H:i:s T', strtotime($v['created'])); ?></pubDate>
            <guid><?php echo $url.'/'.htmlspecialchars(str_replace('/', '', $v['slug']), ENT_QUOTES, "utf-8"); ?></guid>
         </item>
      <?php endforeach; ?>
   </channel>
</rss>

现在,如果您导航到3,您可以查看所有未分类条目的 RSS 源。每个分类都将有自己的 RSS 源,使用户能够订阅他们感兴趣的内容,而不是您网站上所有内容。

管理分类

接下来,我们需要实现我们分类的管理:

  1. 我们将从loadModel()actionDelete()方法开始:

    public function actionDelete($id)
    {
       $this->loadModel($id)->delete();
    
       $this->redirect($this->createUrl('content/admin'));
    }
    
    public function loadModel($id=NULL)
    {
       if ($id == NULL)
          throw new CHttpException(404, 'No category with that ID exists');
    
       $model = Category::model()->findByPk($id);
    
       if ($model == NULL)
          throw new CHttpException(404, 'No category with that ID exists');
    
       return $model;
    }
    
  2. 然后,我们将实现管理操作。请确保将项目资源文件夹中的themes/main/views/category/admin.php文件复制。请查看以下代码:

    public function actionAdmin()
    {
       $model = new Category('search');
       $model->unsetAttributes();
    
       if (isset($_GET['Category']))
          $model->attributes = $_GET;
    
       $this->render('admin', array(
          'model' => $model
       ));
    }
    
  3. 最后,我们将实现save()方法。请确保将项目资源文件夹中的themes/main/views/category/save.php文件复制。请查看以下代码:

    public function actionSave($id=NULL)
    {
       if ($id == NULL)
          $model = new Category;
       else
          $model = $this->loadModel($id);
    
       if (isset($_POST['Category']))
       {
          $model->attributes = $_POST['Category'];
    
          if ($model->save())
          {
             Yii::app()->user->setFlash('info', 'The category was saved');
             $this->redirect($this->createUrl('category/admin'));
          }
       }
    
       $this->render('save', array(
          'model' => $model
       ));
    }
    

我们现在已经完成了我们内容管理系统的基础部分。我们为 CMS 构建的结构虽然极其简单,但为我们提供了很大的灵活性,使我们能够以最小的努力进行扩展。

使用 HybridAuth 进行社交认证

在上一章,第五章,创建一个微博平台,我们使用了 HybridAuth 作为用户登录到 Twitter 并分享内容的工具。在本章中,我们使用 HybridAuth 在我们的 CMS 中注册账户,并登录到我们的 CMS 而无需输入用户名和密码。为了实现这一点,我们将创建三个新的表单,一个新的UserIdentity类,以及一个控件,这将使我们能够利用 HybridAuth 提供的所有提供者。

然而,在我们开始任何编码之前,我们首先需要创建一个新的 Twitter 应用程序,类似于我们在第五章中创建的,即创建一个微博平台。这将使我们能够在编写代码时专注于开发而不是配置。一旦你的 Twitter 应用程序创建完成并且权限设置好,将一个hybridauth部分添加到你的protected/config/params.php文件中,包含你的 OAuth 密钥和令牌:

'hybridauth' => array(
   'providers' => array(
      'Twitter' => array(
         'enabled' => true,
         'keys' => array(
            'key' => '<key>',
            'secret' => '<secret>
         )
      )
   )
)

注意

完成后,你将能够添加 HybridAuth 文档中列出的任何受支持的 HybridAuth 提供者,该文档位于hybridauth.sourceforge.net/userguide.html

验证远程身份

对于我们的应用程序,我们需要从社交网络注册用户,从社交网络验证用户到我们数据库中的用户,并将现有用户链接到一个社交身份。我们还将让用户在我们的系统中进行身份验证。为了实现这一点,我们将创建三个单独的表单,RemoteRegistrationFormRemoteLinkAccountFormRemoteIdentityForm,它们将作为我们的远程用户LoginForm。我们还将创建一个RemoteUserIdentity类,我们将使用它来验证用户进入我们的系统。让我们开始吧。

远程注册

我们需要创建的第一个类是RemoteRegistrationForm。这个表单将允许我们使用用户的社交身份信息来注册用户。步骤如下:

  1. 要开始,请在protected/models中创建一个新的类RemoteRegistrationForm.php,其定义如下。为了简化问题,我们将重用RegistrationForm类中已经可用的许多功能。看看以下代码行:

    class RemoteRegistrationForm extends RegistrationForm {}
    
  2. 然后,我们将指定两个额外的属性,即我们从稍后创建的控制器中提供的 HybridAuth 适配器以及我们正在验证的提供者名称。我们还将为这些属性设置验证器以确保它们被设置。请注意,我们正在使用CMap类的mergeArray()方法来利用已经存在的验证规则:

    public $adapter;
    
    public $provider;
    
    public function rules()
    {
        return CMap::mergeArray(parent::rules(), array(
            array('adapter, provider', 'required')
        ));
    }
    
  3. 最后,我们将重载我们的save()方法,以便将提供者名称和 OAuth 令牌写入我们的数据库。当我们创建RemoteIdentityForm类时,我们将利用这些元数据:

    public function save()
    {
        // If the parent form saved and validated
        if (parent::save())
        {
            // Then bind the identity to this user permanently
            $meta = new UserMetadata;
            $meta->attributes = array(
                'user_id' => $this->_user->id,
                'key' => $this->provider.'Provider',
                'value' => (string)$this->adapter->identifier
            );
    
            // Save the associative object
            return $meta->save();
        }
    
        return false;
    }
    

将社交身份链接到现有账户

为了帮助将社交身份链接到我们系统中的现有账户,我们将创建一个新的类,称为RemoteLinkAccountForm。此表单将提示已登录用户输入他们的密码以验证其身份,然后将 HybridAuth 提供的 OAuth 令牌绑定到该用户,以便他们将来可以使用社交身份登录。步骤如下:

  1. 要开始,请在protected/models中创建一个新的类,名为RemoteLinkAccountForm.php,其定义如下:

    class RemoteLinkAccountForm extends CFormModel {}
    
  2. 然后,我们将定义我们想要收集的公共属性并为其创建验证器。我们还将定义一个私有属性以存储我们想要将我们的社交身份链接到的用户信息。请查看以下代码行:

    public $password;
    
    public $adapter;
    
    public $provider;
    
    private $_user;
    
    public function rules()
    {
        return array(
            array('password, adapter, provider', 'required'),
            array('password', 'validateUserPassword')
        );
    }
    
  3. 由于安全原因,我们只想让经过认证的用户能够将社交身份链接到他们的账户。为了验证我们正在处理账户所有者,我们将提示用户输入他们的密码。为了验证他们的密码,我们将创建一个自定义验证器,称为validateUserPassword。请查看以下代码行:

    public function validateUserPassword($attributes, $params)
    {
        $this->_user = User::model()->findByPk(Yii::app()->user->id);
    
        if ($this->_user == NULL)
        {
            $this->addError('password', 'Unable to identify user.');
            return false;
        }
    
        $result = password_verify($this->password, $this->_user->password);
    
        if ($result == false)
        {
            $this->addError('password', 'The password you entered is invalid.');
            return false;
        }
    
        return true;
    }
    
  4. 最后,我们创建一个save()方法,将社交身份信息保存到我们的user_metdata表中,前提是用户能够验证他们的身份:

    public function save()
    {
        if (!$this->validate())
            return false;
    
        $meta = new UserMetadata;
        $meta->attributes = array(
            'user_id' => $this->_user->id,
            'key' => $this->provider.'Provider',
            'value' => (string)$this->adapter->identifier
        );
    
        // Save the associative object
        return $meta->save();
    }
    

使用社交身份进行认证

要使用社交身份进行认证,我们需要创建一个类似于我们的LoginForm的表单;然而,它将接受提供者名称和我们在其中工作的 HybridAuth 适配器作为输入,而不是用户名和密码。步骤如下:

  1. 首先,在protected/models中创建一个新的表单,名为RemoteIdentityForm.php

    class RemoteIdentityForm extends CFormModel {}
    
  2. 如前所述,我们将收集提供者名称和 HybridAuth 适配器,而不是用户名和密码,因此让我们声明这些属性。我们还将声明属性以存储用户信息(如果存在)以及RemoteUserIdentity类,我们将最终使用该类进行身份验证:

    public $adapter;
    
    public $provider;
    
    private $_identity;
    
    public $_user;
    
  3. 然后,我们将定义我们的验证规则并创建一个自定义验证器,它将检索我们系统中的适当用户。这将防止未经授权的用户在没有首先通过与用户账户链接的社交网络进行身份验证的情况下认证到我们的 CMS:

    public function rules()
    {
        return array(
            array('adapter, provider', 'required'),
            array('adapter', 'validateIdentity')
        );
    }
    
    public function validateIdentity($attributes, $params)
    {
        // Search the database for a user with that information
        $metadata = UserMetadata::model()->findByAttributes(array(
            'key' => $this->provider.'Provider',
            'value' => (string)$this->adapter->identifier
        ));
    
        // Return an error if we didn't find them
        if ($metadata == NULL)
        {
            $this->addError('adapter', 'Unable to determine local user for identity');
            return false;
        }
    
        // Otherwise load that user
        $this->_user = User::model()->findByPk($metadata->user_id);
        if ($this->_user == NULL)
        {
            $this->addError('adapter', 'Unable to determine local user for identity');
            return false;
        }
    
        // And return true
        return true;
    }
    
  4. 然后,我们将创建一个authenticate()方法,其行为将与我们的LoginForm中的authenticate()方法相同;然而,它将使用RemoteUserIdentity类而不是UserIdentity类。请查看以下代码:

    public function authenticate()
    {
        if (!$this->validate())
            return false;
    
        // Load the RemoteUserIdentity model, and return if we successfully could authenticate against it
        $this->_identity = new RemoteUserIdentity($this->adapter, $this->provider, $this->_user);
        return $this->_identity->authenticate();
    }
    
  5. 最后,我们将创建一个login()方法,实际上将我们的用户登录到我们的 CMS:

    public function login()
    {
        if (!$this->authenticate())
            return false;
    
        if($this->_identity->errorCode===RemoteUserIdentity::ERROR_NONE)
       {
          $duration = 3600*24*30;
            Yii::app()->user->allowAutoLogin = true;
            Yii::app()->user->login($this->_identity,$duration);
            return true;
       }
       else
          return false;
    }
    

从远程身份创建 Yii CWebUser 对象

在将所有内容链接在一起之前,我们还需要创建一个RemoteUserIdentity类。此类将检索我们表单中的所有信息;如果经过验证,它将以与我们的UserIdentity类相同的方式将用户登录到我们的 CMS。步骤如下:

  1. 要开始,请在protected/components目录中创建一个名为RemoteUserIdentity.php的新类。

    class RemoteUserIdentity extends CUserIdentity {}
    
  2. 然后,定义我们从构造函数中收集的属性,如下所示:

    public $adapter;
    
    public $provider;
    
    public $_user;
    
    public function __construct($adapter, $provider, $user)
    {
        $this->adapter  = $adapter;
        $this->provider = $provider;
        $this->_user    = $user;
    }
    
  3. 我们还应该定义一种方法来检索存储在我们系统中的用户 ID。我们将遵循UserIdentity类中设定的模式以保持一致性:

    private $_id;
    
    public function getId()
    {
        return $this->_id;
    }
    
  4. 最后,我们将创建一个authenticate()方法来设置我们的CWebUser状态。由于我们需要检查数据是否可供我们使用,因此提供给我们的信息应该已经过验证:

    public function authenticate($force=false)
    {
        // Set the error code first
        this->errorCode = self::ERROR_UNKNOWN_IDENTITY;
    
        // Check that the user isn't NULL, or that they're not in a locked state
        if ($this->_user == NULL)
            $this->errorCode = Yii_DEBUG ? self::ERROR_USERNAME_INVALID : self::ERROR_UNKNOWN_IDENTITY;
    
        // The user has already been provided to us, so immediately log the user in using that information
        $this->errorCode = self::ERROR_NONE;
    
        $this->_id       = $this->_user->id;
        $this->setState('email', $this->_user->email);
        $this->setState('role', $this->_user->role_id);
    
        return !$this->errorCode;
    }
    

整合所有内容

在所有必要的组件就绪后,我们现在可以创建我们的控制器,该控制器将使用 HybridAuth 处理身份验证组件。步骤如下:

  1. 首先,在protected/controllers目录中创建一个名为HybridController.php的新控制器:

    class HybridController extends CMSController {}
    
  2. 接下来,我们将创建三个属性来保存 HybridAuth 适配器、提供者名称以及我们从 Twitter 获取的用户个人资料:

    protected $_provider;
    
    private $_adapter = NULL;
    
    private $_userProfile = NULL;
    

    我们还将创建自定义的获取器和设置器方法来设置适配器,如下所示:

    public function setAdapter($adapter)
    {
        return $this->_adapter = $adapter;
    }
    
    public function getAdapter()
    {
        return $this->_adapter;
    }
    
  3. 然后,我们将添加一个块来从用户登录的社交网络中检索用户的个人资料信息:

    public function getUserProfile()
    {
        if ($this->_userProfile == NULL)
            $this->_userProfile = $this->getAdapter()->getUserProfile();
    
        return $this->_userProfile;
    }
    
  4. 然后,为了从 URI 获取并设置提供者的名称,需要使用以下代码:

    public function setProvider($provider=NULL)
    {
        // Prevent the provider from being NULL
        if ($provider == NULL)
            throw new CException("You haven't supplied a provider");
    
        // Set the property
        $this->_provider = $provider;
    
        return $this->_provider;
    }
    
    public function getProvider()
    {
        return $this->_provider;
    }
    
  5. 通过参考 HybridAuth 文档(hybridauth.sourceforge.net/userguide/Configuration.html),我们可以确定 HybridAuth 初始化时所需的变量。我们可以在控制器内部动态填充这些信息,而不是在配置文件中硬编码所有这些信息。这种方法将确保我们的基本 URL 始终设置正确,并且将日志信息发送到正确的位置。它还有一个额外的好处,即只有在启用YII_DEBUG时才记录日志,这意味着在调试时我们只需更改配置文件一次,而不是多次更改。请看以下代码:

    public function getConfig()
    {
        return array(
            'baseUrl' => Yii::app()->getBaseUrl(true),
            'base_url' => Yii::app()->getBaseUrl(true) . '/hybrid/callback', // URL for Hybrid_Auth callback
            'debug_mode' => YII_DEBUG,
            'debug_file' => Yii::getPathOfAlias('application.runtime.hybridauth').'.log',
            'providers' => Yii::app()->params['includes']['hybridauth']['providers']
        );
    }
    
  6. 接下来,我们将定义我们的actionIndex()。此操作将作为 HybridAuth 的初始化 URL 和我们的社交网络的回调 URL。在此操作中,我们将设置提供者并启动 HybridAuth 过程:

    public function actionIndex($provider=NULL)
    {
        // Set the provider
        $this->setProvider($provider);
    
        if (isset($_GET['hauth_start']) || isset($_GET['hauth_done']))
            Hybrid_Endpoint::process();
    
        try {
           $this->hybridAuth();
        } catch (Exception $e) {
            throw new CHttpException(400, $e->getMessage());
        }
    }
    

    注意

    在内部,每当 HybridAuth 在处理远程网络时遇到错误时,它都会抛出一个异常。为了防止我们的应用程序泄露过多信息,我们可以简单地通知用户发生了错误。

  7. 然后,我们将定义我们之前开始使用的hybridauth()方法。我们首先初始化 HybridAuth 对象,并设置适配器(如果尚未设置):

    private function hybridAuth()
    {
        // Preload some configuration options
        if (strtolower($this->getProvider()) == 'openid')
       {
          if (!isset($_GET['openid-identity']))
             throw new CException("You chose OpenID but didn't provide an OpenID identifier");
          else
             $params = array("openid_identifier" => $_GET['openid-identity']);
       }
       else
          $params = array();
    
       $hybridauth = new Hybrid_Auth($this->getConfig());
    
        if (!$this->adapter)
            $this->setAdapter($hybridauth->authenticate($this->getProvider(),$params));
    }
    

    注意

    我们为我们的适配器声明了一个自定义的 getter 和 setter,这样我们就可以在我们的流程中只加载它一次。这将防止我们在单个请求中达到 Twitter API 的速率限制。

  8. 在这一点上,HybridAuth 将执行几个不同的重定向,以验证用户是否对其系统进行了认证。当请求返回给我们时,我们将能够验证用户是否连接到我们的适配器。如果没有,可以安全地抛出异常。请看以下代码:

    if ($this->adapter->isUserConnected())
    {
        // We'll add our actions here...
    }
    else
        throw new CHttpException(403, 'Failed to establish remote identity');
    
  9. 在我们的if语句中,我们将尝试使用我们之前创建的RemoteIdentityForm类来认证用户。如果我们能够认证用户,我们将显示一个闪存消息并将用户重定向到主页。如果我们不能认证用户,我们将显示LinkAccountForm类(如果用户在我们的系统中进行了认证但未进行社交认证),或者显示RemoteRegistrationForm类,以便用户可以在我们的 CMS 中注册新账户:

    if ($this->authenticate())
    {
        Yii::app()->user->setFlash('success', 'You have been successfully logged in!');
    
        $this->redirect(Yii::app()->getBaseUrl(true));
    }
    else
    {
        if (!Yii::app()->user->isGuest)
            $this->renderLinkForm();
        else
            $this->renderRegisterForm();
    }
    
  10. 我们的authenticate()方法将简单地返回RemoteIdentityForm login()方法的调用结果:

    private function authenticate()
    {
        $form = new RemoteIdentityForm;
        $form->attributes = array(
            'adapter'  => $this->getUserProfile(),
            'provider' => $this->getProvider()
        );
    
        return $form->login();
    }
    
  11. 如果用户已经在我们的 CMS 中进行了认证但尚未使用此提供者进行认证,我们假设他们想要将他们的社交网络身份链接到他们的登录信息;因此,我们将展示RemoteLinkAccountForm并提示他们输入密码。然后,请确保将themes/main/views/users/linkaccount.php从项目资源文件夹复制到您的项目中:

    private function renderLinkForm()
    {
        $form = new RemoteLinkAccountForm;
    
        if (Yii::app()->request->getParam('RemoteLinkAccountForm'))
        {
            // Populate the model
            $form->Attributes = Yii::app()->request->getParam('RemoteLinkAccountForm');
            $form->provider   = $this->getProvider();
            $form->adapter    = $this->getUserProfile();
    
            if ($form->save())
            {
                if ($this->authenticate())
                {
                    Yii::app()->user->setFlash('success', 'You have been successfully logged in');
                    $this->redirect($this->createAbsoluteUrl('content/index'));
                }
            }
        }
    
        // Reuse the register form
        $this->render('//user/linkaccount', array('model' => $form));
    }
    
  12. 最后,如果用户未登录到我们的 CMS,我们将显示我们的RemoteRegisterForm并重新利用themes/main/views/user/register.php视图:

    private function renderRegisterForm()
    {
        $form = new RemoteRegistrationForm;
    
        if (Yii::app()->request->getParam('RemoteRegistrationForm'))
        {
            // Populate the model
            $form->attributes = Yii::app()->request->getParam('RemoteRegistrationForm');
            $form->provider   = $this->getProvider();
            $form->adapter    = $this->getUserProfile();
    
            if ($form->save())
            {
                if ($this->authenticate())
                {
                    Yii::app()->user->setFlash('success', 'You have been successfully logged in');
                    $this->redirect($this->createUrl('content/index'));
                }
            }
        }
    
        // Reuse the register form
        $this->render('//user/register', array('user' => $form));
    }
    

现在我们已经一切准备就绪,我们可以测试我们的社交认证。对于第一次测试,请从我们的 CMS 登出,导航到http://chapter6.example.com/site/login,然后点击底部的使用 Twitter 登录链接。点击链接,并输入您的 Twitter 凭据。在重定向后,您应该会看到一个注册表单,您可以在此输入您的新账户信息,如下面的截图所示:

整合一切

输入您的新用户信息,然后点击注册。如果成功,您将以您刚刚创建的用户身份登录到 CMS,并且我们在上一章中创建的激活电子邮件将发送到该电子邮件地址。现在,如果您从 CMS 登出并使用登录 Twitter链接登录,您将自动登录到 CMS,而无需输入用户名和密码。

在验证使用社交身份注册有效后,请从 CMS 登出,然后使用之前提供的凭据以user1@example.com的身份登录。登出 Twitter 后,导航至http://chapter6.example.com/hybrid/twitter。使用与之前登录不同的账户登录 Twitter 后,系统会提示您输入当前密码,如下面的截图所示:

整合一切

输入密码后,您的社交身份将与您的账户关联,您将能够通过 Twitter 登录,而无需输入用户名和密码。

探索其他 HybridAuth 提供者

由于我们实现了我们的控制器,我们可以轻松且无缝地向我们的protected/config/params.php文件中的hybridauth部分添加额外的提供者,而无需修改系统中任何其他代码。请务必查看位于hybridauth.sourceforge.net/userguide.html#index的 HybridAuth 用户指南,以获取有关如何与其他第三方提供者(如 Google+和 Facebook)集成的更多信息,并尝试一下!

摘要

哇,我们确实在本章中实现了很多功能。在本章中,我们创建了一个非常健壮且可重用的内容管理系统,它具有内容和类别功能。我们还通过操作我们的CUrlManager类来生成完全动态和干净的 URI,进一步深入研究了 Yii 框架。我们还介绍了使用 Yii 内置的主题,通过简单地更改配置值来动态更改网站的前端外观。最后,我们学习了如何与第三方社交网络集成,以提供无缝集成的社交登录功能。

在下一章中,我们将重用本章构建的大量代码,以进一步将应用程序的管理功能与表示逻辑分离。我们还将通过学习如何创建模块来深入了解 Yii 框架。在继续下一章之前,请务必查阅位于www.yiiframework.com/doc/api/的 Yii 类参考,并回顾本章中使用的所有类。然后,当您准备好时,前往下一章,让我们为我们的 CMS 构建一个自定义仪表板模块!

第七章:创建 CMS 的管理模块

对于我们的下一个项目,我们将基于我们在第六章中构建的内容管理系统进行扩展,构建内容管理系统,通过将管理功能迁移到模块中。将此功能迁移到模块中将使管理行为与应用程序的表示层解耦。此更改还将使我们能够在不修改主应用程序的情况下开发和部署管理更改。

我们完成的项目将如下所示:

创建 CMS 的管理模块

先决条件

由于我们将扩展我们在第六章中完成的工作,构建内容管理系统,本章的唯一先决条件是前一章的完成源代码。您可以自己构建项目,或者可以使用前一章项目资源文件夹中提供的完成源代码。

什么是模块?

在 Yii 中,模块是独立的包,它们独立于 Yii 应用程序运行,但必须位于现有应用程序或模块中。模块还可以根据我们的需求与核心应用程序进行不同程度的集成。在许多方面,模块与 Yii 应用程序相同,因为它们都有控制器、模型、视图、配置和组件。这种功能使我们能够独立于主应用程序部署和管理代码。如果我们在多个项目中重用模块,这也为我们提供了更高的可用性。对于我们的应用程序,我们将使用模块仅将应用程序的管理与表示层分离,并独立部署我们的应用程序,而无需修改主应用程序代码。

注意

更多关于 Yii 模块的信息可以在官方 Yii 指南中找到,位于www.yiiframework.com/doc/guide/1.1/en/basics.module

描述项目

我们的仪表板模块可以分解为几个组件:

  • 初始化和配置仪表板模块

  • 启用模块的自定义路由

  • 将管理功能从我们的应用程序移出并放入模块中

  • 添加文件上传功能

  • 模块部署

初始化模块

本项目的第一个组件将是创建和配置我们的模块,使其与我们的主要应用程序集成。我们将通过修改主配置文件以及创建我们将使用的模块的基本结构来实现这一点。我们还将介绍如何独立于主应用程序管理我们的模块资源。

使用模块进行路由

在 Yii 框架中,默认路由是通过模块名称与在 CUrlManager 中指定的默认路由组合来定义的。不幸的是,Yii 并没有提供原生功能来定义我们自己的自定义路由,而无需修改 CUrlManager 中指定的路由。为了绕过这个限制,我们将修改我们定义在 第六章,构建内容管理系统,中的 CMSURLManager,以便我们可以独立于我们的主应用程序存储和配置路由。完成之后,我们将在 protected/modules/<module>/config/ 文件中有一个 routes.php 文件;这将包含我们模块的所有自定义路由,并且将与我们的主应用程序集成,而不会改变应用程序的行为。

将管理功能移入模块

本项目的第三个组成部分将涉及将管理功能从我们的控件移动到模块的控制器中。这还将包括将上一章创建的主题中的表示层移动到模块本身中。为了增加安全性和用户体验,我们还将修改我们的模块如何处理未认证用户和未经授权用户的错误。

添加文件上传功能

为了使我们的内容管理系统更加灵活,我们还将添加一个文件上传功能,这将允许我们从内容页面上传文件并将它们存储在我们的数据库中。我们还将实现必要的功能,以便在文件管理器中查看这些文件,以及删除它们。

模块部署

最后,我们将介绍不同的部署选项,我们可以使用这些选项轻松独立于主应用程序部署我们的模块。通过结合使用 Git 和 Composer,我们可以以对我们所使用的项目类型最有意义的方式部署我们的模块。

初始化项目

对于这个项目,我们将从上一章,第六章,构建内容管理系统,结束的地方开始。为了您的方便,本章的项目资源文件夹中包含了一个骨架项目,其中包含我们将开始的基础。首先,将源代码复制到一个新文件夹中,并确保它可以在不同的 URL 上访问。在本章中,我将使用 http://chapter7.example.com 作为我们的示例 URL。按照上一章提供的说明导入数据库并更新数据库配置后,您应该能看到我们博客的首页:

初始化项目

创建模块

现在我们已经设置了应用程序,我们可以开始创建我们的模块。我们将从在 protected/modules 目录中创建基本文件夹结构开始:

protected/
   [...]
   modules/
      /dashboard
         assets/
         components/
         config/
         controllers/
         views/
            layouts/
            user/
            category/
            filemanager/
            default/

如您所见,我们模块的基本结构与我们的主应用程序相同。有了我们的文件夹结构,我们现在需要创建DashboardModule类,这样我们就可以告诉 Yii 它需要加载什么。步骤如下:

  1. 首先,在protected/modules/dashboard目录下创建一个名为DashboardModule.php的新文件,并包含以下定义:

    <?php class DashboardModule extends CWebModule {}
    
  2. 然后,为模块创建一个init()方法:

    public function init() {}
    
  3. 在模块中,我们希望设置layoutPath,这样我们的模块就知道为我们的视图提供什么布局:

    $this->layoutPath = Yii::getPathOfAlias('dashboard.views.layouts');
    
  4. 我们还希望告诉我们的模块自动导入我们将存储类的components目录的内容:

    $this->setImport(array(
       'dashboard.components.*',
    ));
    

    这将告诉 Yii 的自动加载器自动加载components文件夹中的类。这是 Yii 在加载protected/config/main.php文件导入部分注册的类时所使用的相同行为。

  5. 最后,我们希望为我们的模块设置一些自定义组件——主要是错误处理器——这样我们就可以以不同于主应用程序中发生错误的方式处理模块内的错误:

    Yii::app()->setComponents(array(
       'errorHandler' => array(
           'errorAction'  => 'dashboard/default/error',
       )
    ));
    

我们接下来需要创建两个新的类;第一个将是一个控制器组件,我们模块中的所有控制器都将从这个组件扩展,第二个将是一个默认控制器,当没有指定路由时将会被访问。在protected/modules/dashboard/components/目录下,创建一个名为DashboardController.php的新文件,并包含以下定义。一旦我们将模块注册到 Yii 中,我们将会向这个组件添加更多信息:

<?php class DashboardController extends CMSController {}

然后,在protected/modules/dashboard/controllers目录下创建DefaultController.php。我们还将指定我们的actionIndex()方法,这样一旦我们将模块注册到 Yii 中,我们就可以看到一些内容:

<?php class DefaultController extends DashboardController
{
   public function actionIndex()
   {
      echo "Hello World!";
   }
}

在 Yii 中注册模块

在我们能在我们的模块中看到任何内容之前,我们首先需要告诉 Yii 我们的模块。为此,我们只需在protected/config/main.php文件中的模块部分指定模块名称:

<?php return array(
   [...]
'modules' => array(
      'dashboard'
),
[...]
);

现在,如果您导航到http://chapter7.example.com/dashboard,您应该看到显示的文本Hello World。这是在 Yii 中注册模块的最简单方法。不幸的是,这种方法要求我们每次想要使用新模块时都要更改我们的配置文件,这意味着每次我们使用新模块时都必须更改应用程序代码。另一种加载我们的模块的方法是创建一个protected/config/modules.php文件,并在模块部分注册它。这允许我们简单地更改应用程序外部的缓存设置,而无需修改配置文件中的代码。

为了做到这一点,首先更改protected/config/目录下的main.php文件中的模块部分,使其看起来如下:

<?php return array(
[...]
'modules' => require_once __DIR__ . DIRECTORY_SEPARATOR . 'modules.php',
[...]
);

然后,在 protected/config/ 中创建一个 modules.php 文件。我们将首先声明 modules 目录的位置,以及我们的生成配置文件应该缓存的地点:

<?php

// Set the scan directory
$directory = __DIR__ . DIRECTORY_SEPARATOR . '..' . DIRECTORY_SEPARATOR . 'modules';
$cachedConfig = __DIR__.DIRECTORY_SEPARATOR.'..'.DIRECTORY_SEPARATOR.'runtime'.DIRECTORY_SEPARATOR.'modules.config.php';

然后,我们将检查是否已存在缓存文件。如果存在,我们将直接返回它:

// Attempt to load the cached file if it exists
if (file_exists($cachedConfig))
    return require_once($cachedConfig);

如果缓存文件不存在,我们将遍历 protected/modules 目录中的所有文件夹,以检索所有模块名称并将它们推送到一个数组中。由于一些 Yii 模块需要额外的配置,我们将告诉我们的加载器将 protected/modules/<module>/config/ 中的 main.php 中的任何内容注入为模块使用的选项。当我们已经编译出所有要加载的模块列表时,我们将将其作为序列化数组写入我们 protected/runtime 目录中的一个文件:

else
{
    // Otherwise generate one, and return it
    $response = array();

    // Find all the modules currently installed, and preload them
    foreach (new IteratorIterator(new DirectoryIterator($directory)) as $filename)
    {
        // Don't import dot files
        if (!$filename->isDot())
        {
            $path = $filename->getPathname();

            if (file_exists($path.DIRECTORY_SEPARATOR.'config'.DIRECTORY_SEPARATOR.'main.php'))
                $response[$filename->getFilename()] = require($path.DIRECTORY_SEPARATOR.'config'.DIRECTORY_SEPARATOR.'main.php');
            else
                array_push($response, $filename->getFilename());
        }
    }

    $encoded = serialize($response);
    file_put_contents($cachedConfig, '<?php return unserialize(\''.$encoded.'\');');

    // return the response
    return $response;
}

生成的文件如下所示,并返回到我们的 protected/config/main.php 文件:

<?php return unserialize('a:1:{i:0;s:9:"dashboard";}');

如果我们想添加一个新模块,我们只需删除 protected/runtime/ 中的 module.config.php 文件。第一次请求系统时,将立即重新生成更新后的文件。

虽然在磁盘操作方面稍微昂贵一些,但这种方法加载模块使我们能够通过将它们添加到 modules 目录中,仅通过添加到 modules 目录来动态加载 Yii 的模块。它还消除了我们需要对应用程序进行的任何更改,以便添加新模块,这意味着在添加新模块时,我们不太可能向主应用程序引入新的行为或错误。

向模块添加自定义路由

虽然 Yii 会免费执行很多模块路由,但我们必须将我们的路由添加到 protected/config/main.php 中的 CUrlManager 配置中,以便我们的模块有任何自定义路由。虽然执行起来很容易,但这种方法并没有充分地将模块和应用程序配置分离。为了克服 Yii 中的这个限制,我们需要修改我们在上一章中创建的 CMSUrlManager 类,以检索我们定义的自定义模块路由。这使得我们可以将路由作为模块的一部分而不是作为应用程序的一部分来编写。步骤如下:

  1. 首先在 protected/modules/dashboard/config/ 中创建一个新的文件,名为 routes.php,其中包含以下内容。对于此模块,我们将定义一个自定义路由,以便我们的保存操作可以从以下位置加载:

    <?php return array(
       '/dashboard/<controller:\w+>/save' => '/dashboard/<controller>/save',
    );
    

    注意

    这个例子纯粹是为了说明如何向模块添加自定义路由,因为 Yii 本身不支持它。

  2. 在定义了自定义路由后,我们将更新 CMSUrlManager 以自动导入这些规则。打开 CMSUrlManager.php 文件,位于 protected/components/,并将以下内容添加到 processRules() 方法的 if 块中:

    $this->rules = CMap::mergearray($this->addModuleRules(), $this->rules);
    
  3. 我们最终将定义一个 addModuleRules() 方法,它将在所有已安装的模块中搜索 config/ 下的 routes.php 文件,并将它们注册到 Yii 中:

    private function addModuleRules()
    {
        // Load the routes from cache
        $moduleRoutes = array();
        $directories = glob(Yii::getPathOfAlias('application.modules') . '/*' , GLOB_ONLYDIR);
    
        foreach ($directories as $dir)
        {
            $routePath = $dir .DS. 'config' .DS. 'routes.php';
            if (file_exists($routePath))
            {
                $routes = require_once($routePath);
                foreach ($routes as $k=>$v)
                    $moduleRoutes[$k] = $v;
            }
        }
    
        return $moduleRoutes;
    }
    

现在,我们的仪表板模块将能够处理非标准路由,而无需更新主应用程序内的配置文件。

创建控制器

现在我们已经将应用程序注册到 Yii 并定义了自定义路由,我们可以开始处理控制器。首先,我们应该处理 DashboardController 组件,以便我们的控制器自动继承一些常见的行为。步骤如下:

  1. 在我们的 DashboardController.php 组件中,我们首先应该定义我们的 accessRules() 方法。这将确保只有管理员可以访问仪表板:

    public function filters()
    {
       return array(
          'accessControl'
       );
    }
    
    public function accessRules()
    {
       return array(
          array('allow',  // allow authenticated admins to perform any action
             'users'=>array('@'),
          ),
          array('deny',  // deny all users
             'users'=>array('*'),
             'deniedCallback' => array($this, 'actionError')
          ),
       );
    }
    
  2. 接下来,我们将定义我们将在整个模块中使用的 default 布局:

    public $layout='default';
    
  3. 然后,我们将创建一个自定义错误操作,这将阻止未经认证的用户和未经授权的用户访问我们的模块。默认情况下,如果 Yii 遇到未经授权的错误,它将简单地返回一个 403 错误。我们的错误操作将通过将未经认证的用户重定向到登录页面(带有下一个 $_GET 参数,以便他们在认证后可以返回他们想要去的确切页面)来提高用户体验。另一方面,如果用户只是未经授权,它将显示适当的错误并拒绝他们访问:

    public function actionError()
    {
        if (Yii::app()->user->isGuest)
           return $this->redirect($this->createUrl('/site/login?next=' . Yii::app()->request->requestUri));
    
        if($error=Yii::app()->errorHandler->error)
        {
            if(Yii::app()->request->isAjaxRequest)
                echo $error['message'];
            else
                $this->render('error', array('error' => $error));
        }
    }
    
  4. 要完成这个任务,请重定向 $_GET 参数。我们还需要修改位于 protected/controllers/SiteController.php 文件,以便它知道如何处理该参数。只需将重定向替换为以下内容:

    $this->redirect(Yii::app()->request->getParam('next', $this->createAbsoluteUrl('content/index')));
    
  5. 最后,我们需要实现一种独立于主应用程序管理我们资产的方法。许多模块实现简单地将资产添加到全局可用的 assets 文件夹中。这种实现方式使得确保所有模块痕迹都被移除变得非常困难。管理模块资产的一个更简单的方法是为所有我们的模块特定资产创建一个文件夹,然后,使用 CAssetManager 独立于我们的应用程序发布该文件夹。这样,如果我们对模块资产进行任何更改,它们都不会影响我们的主应用程序。在我们的 SiteController 中,我们应该定义以下方法:

    public function getAsset()
    {
       return Yii::app()->assetManager->publish(YiiBase::getPathOfAlias('application.modules.dashboard.assets'), true, -1, YII_DEBUG);
    }
    

    由于此方法是一个获取器,并且它返回资产发布的路径,因此我们可以从布局文件中调用它,如下所示(使用应从项目资源文件夹复制到模块 assets 文件夹的 dashboard.css 文件):

    Yii::app()->clientScript->registerCssFile($this->getAsset().'/dashboard.css');
    

将功能迁移到模块

现在我们已经设置了模块,我们可以开始将应用程序控制器和主题中的功能移动到仪表板模块中。我们将讨论每个模型所需的所有内容:分类、内容和用户。

迁移内容管理

在下一节中,我们将把上一章中构建的所有管理功能迁移到我们的新模块中:

  1. 从我们的ContentController开始,我们首先希望从protected/controllers/中的ContentController.php文件中删除actionAdmin()actionSave()actionDelete()方法。

  2. 接下来,我们应该从我们的ContentController中删除我们刚刚删除的操作的访问控制属性。恢复的accessRules()方法应如下所示:

    public function accessRules()
    {
       return array(
          array('allow',
             'actions' => array('index', 'view', 'search'),
             'users' => array('*')
          ),
          array('deny',  // deny all users
             'users'=>array('*'),
          ),
       );
    }
    
  3. 在我们的ContentController去除了管理行为之后,我们可以开始将功能移动到protected/modules/dashboard/controllers/中的DefaultController.php文件,我们将使用它作为我们的ContentController。我们将首先将我们的accessRules()方法添加到DefaultController中。由于我们希望继承在components/中定义的DashboardController.php中的规则,我们将使用CMap::mergeArray()来合并父规则与我们的新定义的规则:

    注意

    命名约定让你感到困惑吗?如果你不想在DefaultController中存储与内容相关的功能,你可以在DashboardModule中设置$defaultController属性为content。这将覆盖 Yii 的默认行为。

    public function accessRules()
    {
       return CMap::mergeArray(parent::accessRules(), array(
          array('allow',
             'actions' => array('index', 'save', 'delete'),
             'users'=>array('@'),
             'expression' => 'Yii::app()->user->role==2'
          ),
          array('deny',  // deny all users
             'users'=>array('*'),
          )
       ));
    }
    
  4. 然后,我们将重新定义我们的loadModel()方法:

    private function loadModel($id=NULL)
    {
       if ($id == NULL)
          throw new CHttpException(404, 'No category with that ID exists');
    
       $model = Content::model()->findByPk($id);
    
       if ($model == NULL)
          throw new CHttpException(404, 'No category with that ID exists');
    
       return $model;
    }
    
  5. 然后,我们将定义我们的actionDelete()方法:

    public function actionDelete($id)
    {
       $this->loadModel($id)->delete();
    
       $this->redirect($this->createUrl('/dashboard'));
    }
    
  6. 然后,我们将编写一个索引方法来显示数据库中的所有内容条目:

    public function actionIndex()
    {
       $model = new Content('search');
       $model->unsetAttributes();
    
       if (isset($_GET['Content']))
          $model->attributes = $_GET;
    
       $this->render('index', array(
          'model' => $model
       ));
    }
    
  7. 最后,我们将编写一个方法来创建新的内容条目和编辑现有内容条目:

    public function actionSave($id=NULL)
    {
       if ($id == NULL)
          $model = new Content;
       else
          $model = $this->loadModel($id);
    
       if (isset($_POST['Content']))
       {
          $model->attributes = $_POST['Content'];
          $model->author_id = Yii::app()->user->id;
    
          if ($model->save())
          {
             Yii::app()->user->setFlash('info', 'The articles was saved');
             $this->redirect($this->createUrl('/dashboard'));
          }
       }
    
       $this->render('save', array(
          'model' => $model
       ));
    }
    
  8. 接下来,我们应该将位于protected/modules/dashboard/views/default/save.php文件从我们的项目资源文件夹复制到我们的模块中。如果你还没有这样做,请将位于protected/modules/dashboard/views/layouts/default.php布局文件复制到你的项目中。

  9. 最后,我们需要确保我们的index视图文件已正确更新,以便它链接到适当的控制器操作。如果你只是简单地从主题文件中复制视图,你会注意到没有任何链接是有效的。为了纠正这些链接,我们需要更新我们的createUrl调用,使其指向我们模块的DefaultController中的save()方法,并更新CButtonColumn链接,使其指向我们的模块:

    <?php echo CHtml::link('Create New Post', $this->createUrl('/dashboard/default/save'), array('class' => 'btn btn-primary')); ?>
    <?php $this->widget('zii.widgets.grid.CGridView', array(
        'dataProvider'=>$model->search(),
        'htmlOptions' => array(
            'class' => 'table-responsive'
        ),
        'itemsCssClass' => 'table table-striped',
        'columns' => array(
           'id',
           'title',
           'published' => array(
              'name' => 'Published',
              'value' => '$data->published==1?"Yes":"No"'
           ),
           'author.username',
           array(
                'class'=>'CButtonColumn',
                'viewButtonUrl'=>'Yii::app()->createUrl("/".$data["slug"])',
                'deleteButtonUrl'=>'Yii::app()->createUrl("/dashboard/default/delete", array("id" =>  $data["id"]))',
                'updateButtonUrl'=>'Yii::app()->createUrl("/dashboard/default/save", array("id" =>  $data["id"]))',
            ),
        ),
        'pager' => array(
           'htmlOptions' => array(
              'class' => 'pager'
           ),
           'header' => '',
           'firstPageCssClass'=>'hide',
           'lastPageCssClass'=>'hide',
           'maxButtonCount' => 0
        )
    ));
    

现在我们已经完成,我们将能够从单个界面查看我们 CMS 中的所有文章,删除它们,编辑它们,并导航到前端视图,如下面的截图所示:

迁移内容管理

分类迁移

我们的用户和分类控制器的更改将非常相似——让我们逐一处理。步骤如下:

  1. 从我们的CategoryController开始,我们首先希望从protected/controllers/中的CategoryController.php文件中删除actionAdmin()actionSave()actionDelete()方法。

  2. 接下来,我们应该从我们的CategoryController中删除我们刚刚删除的操作的访问控制属性。恢复的accessRules()方法应如下所示:

    public function accessRules()
    {
       return array(
          array('allow',
             'actions' => array('index', 'view', 'search'),
             'users' => array('*')
          ),
          array('deny',  // deny all users
             'users'=>array('*'),
          ),
       );
    }
    
  3. 我们在protected/modules/dashboard/controllers/目录下的CategoryController.php文件中的新accessRules()方法将如下所示:

    public function accessRules()
    {
       return CMap::mergeArray(parent::accessRules(), array(
          array('allow',
             'actions' => array('index', 'save', 'delete'),
             'users'=>array('@'),
             'expression' => 'Yii::app()->user->role==2'
          ),
          array('deny',  // deny all users
             'users'=>array('*'),
          )
       ));
    }
    
  4. 接下来,我们将使用更新的重定向重新实现所有管理操作,从我们的actionIndex()方法开始:

    public function actionIndex()
    {
       $model = new Category('search');
       $model->unsetAttributes();
    
       if (isset($_GET['Category']))
          $model->attributes = $_GET;
    
       $this->render('index', array(
          'model' => $model
       ));
    }
    
  5. 然后,我们将重新实现保存方法并修改它以在我们的模块中工作:

    public function actionSave($id=NULL)
    {
       if ($id == NULL)
          $model = new Category;
       else
          $model = $this->loadModel($id);
    
       if (isset($_POST['Category']))
       {
          $model->attributes = $_POST['Category'];
    
          if ($model->save())
          {
             Yii::app()->user->setFlash('info', 'The category was saved');
             $this->redirect($this->createUrl('/dashboard/category'));
          }
       }
    
       $this->render('save', array(
          'model' => $model
       ));
    }
    
  6. 然后,我们在我们的模块中重新实现删除方法并更新重定向:

    public function actionDelete($id)
    {
       $this->loadModel($id)->delete();
    
       $this->redirect($this->createUrl('/dashboard/category'));
    }
    
  7. 最后,我们将更新loadModel()方法,使其在没有我们的模块的情况下也能工作:

    private function loadModel($id=NULL)
    {
       if ($id == NULL)
          throw new CHttpException(404, 'No category with that ID exists');
    
       $model = Category::model()->findByPk($id);
    
       if ($model == NULL)
          throw new CHttpException(404, 'No category with that ID exists');
    
       return $model;
    }
    
  8. 然后,将位于protected/modules/dashboard/views/category/index.php视图文件和位于protected/modules/dashboard/views/category/save.php视图文件从项目资源文件夹复制到我们的模块中。

  9. 注意,我们再次更新了我们的CButtonColumn链接,使其指向我们的模块而不是之前定义的首页路由:

    array(
        'class'=>'CButtonColumn',
        'viewButtonUrl'=>'Yii::app()->createUrl("/".$data["slug"])',
       'deleteButtonUrl'=>'Yii::app()->createUrl("/dashboard/category/delete", array("id" =>  $data["id"]))',
       'updateButtonUrl'=>'Yii::app()->createUrl("/dashboard/category/save", array("id" =>  $data["id"]))',
    ),
    

我们最终的分类管理界面将如下所示,并且其行为将与我们的内容管理界面完全相同:

迁移分类

实现用户管理

在上一章中,我们没有实现用户管理的 UI;现在让我们继续实现这个功能,以便我们的仪表板模块完全包含所有管理功能。步骤如下:

  1. 首先,在protected/modules/dashboard/controllers中创建一个新的控制器UserController.php,其定义如下:

    <?php class UserController extends DashboardController {}
    
  2. 接下来,我们将为这个控制器定义我们的accessRules()方法:

    public function accessRules()
    {
       return CMap::mergeArray(parent::accessRules(), array(
          array('allow',
             'actions' => array('index', 'save', 'delete'),
             'users'=>array('@'),
             'expression' => 'Yii::app()->user->role==2'
          ),
          array('deny',  // deny all users
             'users'=>array('*'),
          )
       ));
    }
    
  3. 然后,我们将实现一个loadModel()实用方法:

    private function loadModel($id=NULL)
    {
       if ($id == NULL)
          throw new CHttpException(404, 'No category with that ID exists');
    
       $model = User::model()->findByPk($id);
    
       if ($model == NULL)
          throw new CHttpException(404, 'No category with that ID exists');
    
       return $model;
    }
    
  4. 接下来,我们将更新我们的删除操作,使其在我们的模块内正确重定向:

    public function actionDelete($id)
    {
       $this->loadModel($id)->delete();
    
       $this->redirect($this->createUrl('/dashboard/user'));
    }
    
  5. 然后,我们将重新实现索引操作,以显示所有用户的列表:

    public function actionIndex()
    {
       $model = new User('search');
       $model->unsetAttributes();
    
       if (isset($_GET['User']))
          $model->attributes = $_GET;
    
       $this->render('index', array(
          'model' => $model
       ));
    }
    
  6. 最后,我们将我们的保存方法迁移到我们的模块中。由于我们已经将用户行为的核心功能实现到了我们的User模型类中,因此我们的actionSave()方法的实现非常直接:

    public function actionSave($id=NULL)
    {
       if ($id == NULL)
          $model = new User;
       else
          $model = $this->loadModel($id);
    
       if (isset($_POST['User']))
       {
          $model->attributes = $_POST['User'];
    
          if ($model->save())
          {
             Yii::app()->user->setFlash('info', 'The user was saved');
             $this->redirect($this->createUrl('/dashboard/user'));
          }
       }
    
       $this->render('save', array(
          'model' => $model
       ));
    }
    
  7. 最后,将位于protected/modules/dashboard/views/user/index.php视图文件和位于protected/modules/dashboard/views/user/save.php视图文件从项目资源文件夹复制到您的应用程序中。再次强调,我们得到的界面与我们的内容和分类管理界面完全相同:实现用户管理

上传文件

我们将向我们的模块添加的最后一个组件是一个具有文件上传功能的文件管理器。对于这个组件,我们将创建一个专门的控制器来以分页格式查看所有上传的文件,创建几个新类来处理实际的文件上传,并对内容保存视图进行一些修改,以便我们可以将文件与特定的文章关联起来。

我们不会将所有这些功能打包到我们将要构建的 FileController 中,而是先构建三个不同的组件来处理上传文件的不同方面。第一个类 File 将代表 $_FILES['file'] 对象,并提供保存文件的函数。第二个类 FileUpload 将是我们上传文件的调用点,并将适当的数据库返回给我们。最后一个类 FileUploader 将处理 FileFileUpload 类之间的交互。这三个类将确保我们的 FileController 类保持简洁,并将使文件上传变得极其容易。

创建 File 类

我们将首先创建 File 类,这是一个简单的对象,代表 $_FILES['file'],我们将通过 POST 请求发送它。在 protected/modules/dashboard/components/ 中创建 File.php 文件:

<?php

class File {
    public function save($path)
    {
        if (!move_uploaded_file($_FILES['file']['tmp_name'], $path))
            return false;

        return true;
    }

    public function __get($name)
    {
        if (isset($_FILES['file'][$name]))
            return $_FILES['file'][$name];

        return NULL;
    }
}

为了简化,我们将把所有文件存储在我们的主应用程序根目录下的 /uploads 目录中。现在就创建这个文件夹,并确保您的 web 服务器有写入权限。

创建 FileUploader 类

我们接下来要构建的类是 FileUploader 类。这个类将处理验证,并调用我们刚刚创建的 File 类,以便将文件保存到上传目录。步骤如下:

  1. FileUploader.php 文件开始,该文件位于 protected/modules/dashboard/components/

    <?php class FileUploader {}
    
  2. 然后,定义一些私有属性作为验证器使用:

    private $allowedExtensions = array(
        'png',
        'jpeg',
        'jpg',
        'gif',
        'bmp'
    );
    
    private $sizeLimit = 10485760;
    
    private $file;
    
  3. 接下来,我们将为这个新对象创建一个构造函数,该构造函数将为验证器设置一些基本变量,并使用 $_FILES['file'] 数组创建 File 对象:

    function __construct(array $allowedExtensions = array(), $sizeLimit = 10485760)
    {
        $allowedExtensions = array_map("strtolower", $allowedExtensions);
    
        If (!empty($allowedExtensions))
            $this->allowedExtensions = $allowedExtensions;
        $this->sizeLimit = $sizeLimit;
    
        $this->checkServerSettings();
    
        $this->file = false;
        if (isset($_FILES['file']))
           $this->file = new File();
    }
    
  4. 接下来,我们将创建之前定义的 checkServerSettings() 方法。这将确保我们不会尝试上传大于我们 php.ini 文件中定义的文件:

    private function checkServerSettings()
    {
        $postSize = $this->toBytes(ini_get('post_max_size'));
        $uploadSize = $this->toBytes(ini_get('upload_max_filesize'));
    
        if ($postSize < $this->sizeLimit || $uploadSize < $this->sizeLimit){
            $size = max(1, $this->sizeLimit / 1024 / 1024) . 'M';
            $json = CJSON::encode(array(
                'error' => 'increase post_max_size and upload_max_filesize'
            ));
            die($json);
        }
    }
    
  5. 最后,我们将创建验证器,以确保文件符合我们之前设置的限制。这个类最终将返回一个数组到我们即将创建的 FileUpload 类:

    private function toBytes($str)
    {
        $val = trim($str);
        $last = strtolower($str[strlen($str)-1]);
        switch($last)
        {
            case 'g': $val *= 1024;
            case 'm': $val *= 1024;
            case 'k': $val *= 1024;
        }
        return $val;
    }
    
    public function handleUpload($uploadDirectory, $replaceOldFile = FALSE)
    {
        if (!is_writable($uploadDirectory))
            return array('error' => "Server error. Upload directory isn't writable.");
    
        if (!$this->file)
            return array('error' => 'No files were uploaded.');
    
        $size = $this->file->size;
    
        if ($size == 0)
            return array('error' => 'File is empty');
    
        $pathinfo = pathinfo($this->file->name);
        $filename = $pathinfo['filename'];
    
        //$filename = md5(uniqid());
        $ext = $pathinfo['extension'];
    
        if(!in_array(strtolower($ext), $this->allowedExtensions))
        {
            $these = implode(', ', $this->allowedExtensions);
            return array('error' =>"File has an invalid extension");
        }
    
        $filename = 'upload-'.md5($filename);
    
       if(!$replaceOldFile)
        {
            /// don't overwrite previous files that were uploaded
            while (file_exists($uploadDirectory . $filename . '.' . $ext))
                $filename .= rand(10, 99);
        }
    
        if ($this->file->save($uploadDirectory . $filename . '.' . $ext))
            return array('success'=>true,'filename'=>$filename.'.'.$ext);
        else
            return array('error'=> 'Could not save uploaded file. The upload was cancelled, or server error encountered');
    }
    

创建 FileUpload 类

我们将要创建的最后一个组件是 FileUpload 类,它将在我们的 FileUploader 类和 FileController 类之间充当中间件:

  1. 开始创建 FileUpload.php 文件,位于 protected/modules/dashboard/components/,以下定义:

    <?php class FileUpload {}
    
  2. 然后,声明一些属性和构造函数:

    private $_id = NULL;
    
    private $_response = NULL;
    
    public $_result = array();
    
    public function __construct($id)
    {
       $this->_id = $id;
        $this->_uploadFile();
    }
    
  3. 然后,我们将创建我们构造函数中调用过的 _uploadFile() 方法。这个方法将实例化一个 FileUploader 对象,并在将其传递给我们的 ContentMetadata 对象(我们将在此对象中存储文件的引用)之前执行上传操作:

    private function _uploadFile()
    {
        $path = '/';
        $folder = Yii::app()->getBasePath() .'/../uploads' . $path;
    
        $sizeLimit = Yii::app()->params['max_fileupload_size'];
        $allowedExtensions = array('jpg', 'jpeg', 'png', 'gif', 'bmp');
        $uploader = new FileUploader($allowedExtensions, $sizeLimit);
    
        $this->_result = $uploader->handleUpload($folder);
    
        if (isset($this->_result['error']))
            throw new CHttpException(500, $this->_result['error']);
        return $this->_handleResourceUpload('/uploads/' . $this->_result['filename']);
    }
    
  4. 最后,我们将创建 _handleResourceUpload() 方法。此方法将接收 FileUploader 对象返回的响应对象,如果文件成功上传,将上传文件的文件名存储到我们的数据库中,以便我们轻松管理。它还将特定文件链接到给定文章:

    private function _handleResourceUpload($value)
    {
      if ($this->_result['success'] == true)
        {
            $meta = ContentMetadata::model()->findbyAttributes(array('content_id' => $this->_id, 'key' => $this->_result['filename']));
    
            if ($meta == NULL)
                $meta = new ContentMetadata;
    
            $meta->content_id = $this->_id;
            $meta->key = $this->_result['filename'];
            $meta->value = $value;
            if ($meta->save())
            {
                $this->_result['filepath'] = $value;
                return $this->_result;
            }
            else
                throw new CHttpException(400,  'Unable to save uploaded image.');
        }
        else
        {
            return htmlspecialchars(CJSON::encode($this->_result), ENT_NOQUOTES);
            throw new CHttpException(400, $this->_result['error']);
        }
    }
    

创建文件管理器的控制器

现在我们已经实现了上传文件的功能,我们需要创建管理它的控制器动作。我们将创建三个单独的动作:一个 index 动作,其中可以查看所有文件及其关联;一个 delete 动作;以及一个 upload 动作。步骤如下:

  1. 首先,在 protected/modules/dashboard/controllers 中创建名为 FileController 的类,其定义如下:

    <?php class FileController extends DashboardController {}
    
  2. 然后,我们将定义 accessRules() 方法:

    public function accessRules()
    {
       return CMap::mergeArray(parent::accessRules(), array(
          array('allow',
             'actions' => array('index', 'upload', 'delete'),
             'users'=>array('@'),
             'expression' => 'Yii::app()->user->role==2'
          ),
          array('deny',  // deny all users
             'users'=>array('*'),
          )
       ));
    }
    
  3. 接下来,我们将定义我们的 index 动作,这将允许我们查看上传到我们 CMS 的所有文件。由于我们的 ContentMetadata 表可能包含其他属性,我们只会在具有上传键的项上进行搜索:

    public function actionIndex()
    {
       $model = new ContentMetadata('search');
       $model->unsetAttributes();
       $model->key = 'upload';
    
       if (isset($_GET['ContentMetadata']))
          $model->attributes = $_GET;
    
       $this->render('index', array(
          'model' => $model
       ));
    }
    
  4. 然后,我们将创建一个 upload 动作,该动作将调用我们的 FileUpload 类。在上传文件或出现错误后,该动作将使用相对 URI 或来自我们的 FileUploader 类的实用错误消息将用户重定向到他们原来的位置:

    public function actionUpload($id = NULL)
    {
       if ($id == NULL)
          throw new CHttpException(400, 'Missing ID');
    
       if (isset($_FILES['file']))
       {
          $file = new FileUpload($id);
    
          if ($file->_result['success'])
             Yii::app()->user->setFlash('info', 'The file uploaded to ' . $file->_result['filepath']);
          elseif ($file->_result['error'])
             Yii::app()->user->setFlash('error', 'Error: ' . $file->_result['error']);
    
       }
       else
          Yii::app()->user->setFlash('error', 'No file detected');
    
       $this->redirect($this->createUrl('/dashboard/default/save?id='.$id));
    }
    
  5. 然后,我们将创建一个 loadModel() 方法和删除动作来从我们的数据库中删除文件:

    public function actionDelete($id)
    {
       if ($this->loadModel($id)->delete())
       {
          Yii::app()->user->setFlash('info', 'File has been deleted');
          $this->redirect($this->createUrl('/dashboard/file/index'));
       }
    
       throw new CHttpException(500, 'The server failed to delete the requested file from the database. Please retry');
    }
    
    private function loadModel($id=NULL)
    {
       if ($id == NULL)
          throw new CHttpException(400, 'Missing ID');
    
       $model = ContentMetadata::model()->findByAttributes(array('id' => $id));
       if ($model == NULL)
          throw new CHttpException(400, 'Object not found');
    
       return $model;
    }
    
  6. 然后,我们将继续创建文件管理器的视图。我们将首先创建一个索引视图,它将包含一个 CListView 容器,使我们能够轻松浏览我们的图片。将以下内容添加到位于 protected/modules/dashboard/views/file/index.php

    <?php $this->widget('zii.widgets.CListView', array(
        'dataProvider'=>$model->search(),
        'itemView'=>'_file',
    ));
    
  7. 我们还将创建位于 protected/modules/dashboard/views/file/ 的相应 itemView 文件,名为 _file.php

    <div class="file">
       <a href="<?php echo $data->value; ?>"><img src="img/<?php echo $data->value; ?>" style="width: 150px; height: 150px;"/></a>
       <?php echo CHtml::link('Article ID: '. $data->content_id, $this->createUrl('/dashboard/default/save', array('id' => $data->content_id))); ?>
       <?php echo CHtml::link('Delete', $this->createUrl('/dashboard/file/delete', array('id' => $data->id)), array('class' => 'btn btn-danger')); ?>
    </div>
    
  8. 最后,我们需要更新 protected/modules/dashboard/views/default/ 中的 save.php,以便添加文件上传表单,以便上传文件:

    <?php if (!$model->isNewRecord): ?>
        <hr />
        <?php $form=$this->beginWidget('CActiveForm', array(
            'id'=>'file-upload-form',
            'action' => $this->createUrl('/dashboard/file/upload', array('id' => $model->id)),
            'htmlOptions' => array(
                'class' => 'form-horizontal',
                'role' => 'form',
                'enctype'=>'multipart/form-data'
    
            )
        )); ?>
            <div class="form-group">
                <div class="col-sm-10">
                    <input type="file" name="file" />
                </div>
            </div>
    
            <div class="row buttons">
                <?php echo CHtml::submitButton('Upload file', array('class' => 'btn btn-primary pull-right col-md-offset-1')); ?>
            </div>
    
        <?php $this->endWidget(); ?>
    <?php endif; ?>
    

现在,如果您从内容保存屏幕上传文件,文件 URL 将返回给您,以便您将其添加到您的文章中:

创建文件管理器的控制器

此外,如果您想查看上传到 CMS 的所有文件,或者您想删除一个文件,您可以在您的网页浏览器中导航到 http://chapter7.example.com/dashboard/files,或者在 protected/modules/dashboard/views/layouts/ 中的 default.php 文件侧边栏中添加一个链接,如下面的截图所示:

创建文件管理器的控制器

部署我们应用程序的策略

我们应该讨论的最后一个话题是我们希望如何将我们的新模块与应用程序一起部署。我们可以使用几种不同的部署策略,每种策略都有其自身的优缺点。在下一节中,我们将讨论几种不同策略的利弊。当您准备将模块与应用程序一起部署时,请务必仔细考虑您希望模块和应用程序如何集成。

作为应用程序部署

我们可以使用的最简单的部署策略是将我们的模块源代码直接提交到我们的主应用程序。当部署应用程序的时间到来时,我们的模块会自动包含在内。虽然这种方法非常简单和基本,但它有几个缺点。

首先也是最重要的,它将我们的模块状态与应用程序的状态绑定在一起,这使得我们在部署应用程序时无意中引入错误或不完整的特性的可能性更大。第二个缺点是它将我们的模块在任何给定时间的状态紧密耦合到我们的应用程序上。最后一个缺点是它使得独立于我们的应用程序部署模块更新变得非常困难。

作为子模块部署

第二种部署策略是将我们的模块代码提交到一个完全独立的仓库,并将其作为子模块包含到我们的项目中。这种方法不仅确保我们的项目获取到最新的代码,而且还确保我们的模块代码和应用代码得到适当的分离。使用子模块的替代方案是,每次我们想要运行部署时,简单地将模块仓库克隆到protected/modules目录。虽然这种方法很简单,但它确实增加了我们应用程序的复杂性,并要求我们深入了解 Git 子模块。此外,在确保部署不会导致停机的情况下,自动化此过程比较困难。

作为 Composer 依赖项部署

第三个策略是为我们的模块创建一个完全独立的仓库,将其作为 Composer 依赖项包含到我们的项目中,并使用composer/installers包确保模块被放置在正确的目录。虽然这种方法比其他策略复杂得多,但它确保我们的模块和应用代码保持分离。它还有将部署相关任务移回应用程序而不是模块的优点。

摘要

我们讨论了大量关于模块操作和克服它们的一些限制的信息。我们讨论了如何创建模块,如何将其与我们的应用程序集成,如何处理模块的自定义路由,如何将管理功能从常规的 Yii 应用程序迁移到我们的模块,我们还向我们的 CMS 添加了文件管理和上传功能。此外,我们还讨论了将我们的模块与应用程序一起部署的不同策略。

在下一章中,我们将为我们的应用程序创建一个 API 模块,该模块将允许网络服务和本地应用程序连接到我们的 CMS。我们将扩展本章所涵盖的主题,同时也会介绍如何覆盖几个核心 Yii 组件,使我们的 API 更加灵活且易于开发。

在继续下一章之前,请务必查阅www.yiiframework.com/doc/api/上的 Yii 类参考,并回顾本章中我们使用到的所有类。

第八章. 为 CMS 构建 API

在整本书中,我们已经涵盖了面向视图的应用程序的开发——用户可以直接与之交互的应用程序。然而,我们的面向视图的方法并不允许我们轻松地与其他服务集成或为原生应用程序提供功能。这种面向视图的方法通常会使我们陷入硬编码的功能,并使集成变得显著更加困难。然而,Yii 框架非常灵活,使我们能够构建 API 驱动应用程序而不是视图驱动应用程序。API 减少了我们需要维护的代码量;如果执行得当,它减少了当我们想要添加功能时需要更改的代码量。最终,这使我们能够更快地工作,并更适应变化。

构建一个 API 驱动的应用程序也使我们能够轻松地开发与我们的 API 一起工作的 Web 和原生客户端,从而完全将面向视图的逻辑与我们应用程序分离。在本章中,我们将讨论为了构建我们之前创建的内容管理系统(CMS)的 API 驱动模块,我们需要做什么。通过在我们的应用程序周围培养一个生态系统,我们可以为开发者和用户都提供价值,并提高我们应用程序的价值。

以下为演示:

为 CMS 构建 API

在本章中,我们将讨论为了构建我们之前创建的内容管理系统的 API 驱动模块,我们需要做什么。

前提条件

由于我们将基于我们在第七章中完成的工作进行扩展,即第七章. 为 CMS 创建管理模块,我们需要前一章的完整源代码。你可以自己构建项目,或者你可以使用前一章项目资源文件夹中可用的完整源代码。我们还需要一个 URL 请求客户端,它将允许我们向应用程序发送带有 JSON 编码数据的GETPOSTDELETE请求。你可以使用 cURL,或者你可以下载一个名为RESTClient的 Google Chrome 扩展程序,该扩展程序可在chrome.google.com/webstore/detail/rest-console/cokgbflfommojglbmbpenpphppikmonn?hl=en找到。本章中的示例将使用 RESTClient。

描述项目

在本章中,我们将为我们的内容管理系统构建一个 API 模块。这个模块的开发可以分为几个部分:

  • 配置模块

  • 扩展 Yii 以“RESTfully”渲染 JSON 或 XML 而不是视图文件

  • 处理数据输入

  • 处理用户身份验证

  • 处理异常和错误

  • 指定每个响应将返回哪些数据

  • 实现身份验证、注销和基本的 CRUD 操作

配置模块

本项目的第一个组件将包括创建和配置我们的模块,以便它与我们的主应用程序集成。由于我们在上一章中添加了无缝将模块集成到我们的应用程序的功能,本节所需的工作仅限于清除我们的模块缓存,初始化模块,并添加必要的路由。

扩展 Yii 以以 RESTful 方式渲染 JSON 或 XML

由于 Yii 框架旨在与视图文件一起工作,我们需要扩展 Yii 框架的几个组件,以便使其能够输出和渲染 JSON 或 XML 文档。我们还需要对 Yii 进行一些不同的修改,以便它能够独立处理 GETPOSTDELETE 操作。为了实现这一点,我们将创建一个新的控制器,该控制器将扩展我们在之前章节中创建的 CMSController。这将覆盖 CController 的几个关键方法,即 runAction()filterAccessControl()createAction()beforeAction()。我们还将扩展其他几个类——CInlineActionCAccessControlFilterCAccessRule——以实现我们所需的所有功能。最后,我们还将更改渲染器的工作方式,以便我们可以从我们的操作中返回数据,并让我们的基本控制器处理输出,从而减少在每个控制器中需要执行的回声数量。

处理数据输入

对于任何修改我们应用程序中数据的请求,我们需要处理将数据接受到 RESTful 端点。为了使事情简单,我们将接受 JSON 编码的数据,或者接受使用 application/x-www-form-urlencoded 或 HTML 表单字段编码的数据,以方便我们。在我们的应用程序中,我们将将这些数据源转换为可用的属性,我们可以从中修改并完成任务。

对 API 进行用户认证

在 Yii 中,用户认证和识别通常由我们的 UserIdentity 类和 cookies 处理。按照惯例,RESTful API 不发送或接受任何 cookies,这意味着我们不得不改变在应用程序中执行认证的方式。为此,我们将创建一个自定义的 AccessControlFilter,它将最初使用用户的用户名和密码来认证我们的用户。如果用户成功认证我们的 API,我们将返回一个唯一的令牌给用户,用户将使用此令牌进行所有需要认证的未来请求。此令牌和用户的电子邮件地址将通过两个自定义头 X-Auth-TokenX-Auth-Email 发送,并允许我们在 API 中识别用户,而无需他们重新发送密码信息。此令牌将存储在我们之前章节中创建的 user_metadata 表中,与我们的用户一起。

处理 API 异常

我们接下来需要处理的是错误和异常。这些将包括 Yii 自然遇到的错误,例如当找不到操作时的 404 错误,以及我们在应用程序中抛出的异常,以通知与我们的 API 交互的客户端意外错误或警告。由于我们将更改应用程序内渲染的方式,我们将简单地以与任何操作响应相同的方式重定向我们的错误。

处理数据响应

对于每个请求,我们将返回 HTTP 状态码,如果发生错误,将返回一条消息,以及一个混合内容响应属性,它将包含我们希望返回给客户端消费的所有信息。响应将如下所示:

{
   "status": <integer::http_status_code>,
   "message": "<string::null_or_error_message>",
   "response": <mixed::boolean_string_or_array_response"
}

我们还将让我们的操作返回一个方法,这将允许我们定义每个请求应该返回哪些属性。这将使我们能够只返回有限的信息,防止意外信息泄露,并使我们能够保护像密码或凭证这样的私人信息。

实现操作

我们将要处理的最后一个大问题是实现所有控制器操作。这包括我们的认证端点,所有用户操作,如注册和重置密码,以及我们三个核心数据模型:用户、类别和内容。

初始化项目

对于这个项目,我们将从第七章,为 CMS 创建管理模块,我们上次停止的地方开始。步骤如下:

  1. 为了您的方便,本章的项目资源文件夹中包含了一个骨架项目,其中包含我们将开始的基础。首先,将源代码复制到一个新文件夹中,并确保它位于与上一章中使用的不同 URL 下。在本章中,我将使用http://chapter8.example.com作为我们的示例 URL。

  2. 在使用上一章中提供的说明导入数据库并更新数据库配置后,在protected/modules中创建一个名为api的新文件夹,并创建以下目录结构:

    api/
       components/
       config/
       controllers/
    
  3. 接下来,在protected/modules/api/中创建ApiModule类,ApiModule.php,这将引导我们的模块:

    <?php
    
    class ApiModule extends CWebModule
    {
       public function init()
       {
          // import the module-level models and components
          $this->setImport(array(
             'api.components.*',
          ));
    
          Yii::app()->log->routes[0]->enabled = false;
    
          Yii::app()->setComponents(array(
                'errorHandler' => array(
                   'errorAction'  => 'api/default/error',
               )
            ));
       }
    }
    
  4. 接下来,在protected/modules/api/config/中创建routes.php,并填充以下信息:

    <?php return array(
       '/api/<controller:\w+>/<action:\w+>' => '/api/<controller>/<action>',
       '/api/<controller:\w+>/<action:\w+>/<id:\w+>' => '/api/<controller>/<action>'
    );
    

最后,在protected/runtime/目录中删除modules.config.php文件,以及protected/runtime/cache目录的内容,以清除我们在上一章中实现的模块缓存。这将确保 Yii 能够识别并缓存我们的新模块。下次我们访问 Yii 时,此文件将被重新生成,并将包含适用于我们应用程序的适当模块配置。

扩展 Yii 以返回数据

有两种方法让 Yii 渲染 JSON 或 XML 数据。第一种也是最简单的方法是创建一个 JSON 或 XML 视图文件,并从每个动作中调用$this->render('json')。虽然这很简单,但它迫使我们存储大量信息,并在每个动作中显式调用render()方法。如果我们扩展的类修改了render()方法,那么在以后想要进行更改时可能会非常麻烦。这种方法另一个问题是它将错误视为不同的响应类型。当使用这种方法抛出错误时,Yii 会希望将错误渲染为 HTML 而不是 JSON。根据我们的日志和调试级别,这可能会导致我们的 API 向客户端返回错误的数据。

更好的方法是,在每一个动作中简单地返回我们想要向客户端展示的数据,并由父控制器类处理渲染和输出。这种方法使得识别每个动作展示的数据更加容易,并确保即使在发生异常或错误的情况下,我们的 API 也能始终返回正确的数据格式。

  1. 然而,为了使这个功能正常工作,我们需要从 Yii 框架扩展几个类并修改它们,以便它们返回数据而不是输出。我们需要扩展的第一个类是CInlineActionCInlineAction代表我们控制器中的实际动作方法,并由runAction()控制器方法调用。为了使我们的 API 返回数据而不是输出,我们首先需要通过修改CInlineActionrunWithParamsInternal()方法来拦截我们动作的响应,然后将其返回给父控制器中的runAction()方法。

  2. 我们将通过创建一个新的类ApiInlineAction来扩展CInlineAction并重载runWithParamsInternal()方法。为了方便起见,我们将这段代码放在ApiInlineAction.php中,位于protected/modules/api/components/目录下:

    <?php
    class ApiInlineAction extends CInlineAction
    {
        protected function runWithParamsInternal($object, $method, $params)
        {
            $ps=array();
            foreach($method->getParameters() as $i=>$param)
            {
                $name=$param->getName();
                if(isset($params[$name]))
                {
                    if($param->isArray())
                        $ps[]=is_array($params[$name]) ? $params[$name] : array($params[$name]);
                    elseif(!is_array($params[$name]))
                        $ps[]=$params[$name];
                    else
                        return false;
                }
                elseif($param->isDefaultValueAvailable())
                    $ps[]=$param->getDefaultValue();
                else
                    return false;
            }
    
            return $method->invokeArgs($object,$ps);
        }
    }
    
  3. 接下来,我们需要创建一个基类控制器,所有我们的 API 控制器都将从这个基类扩展。这个父类将最终成为runWithParamsInternal类。首先,在protected/modules/api/components目录下创建一个新的类ApiController.php,其定义如下:

    <?php class ApiController extends CMSController {}
    
  4. 在整个类中,我们将引用私有的$_action变量,我们需要从父类中重新定义它。我们还将在此处定义状态和消息变量。这些变量将包含 HTTP 状态码以及我们想要向客户端展示的任何错误消息:

    private $_action;
    public $status = 200;
    public $message = null;
    
  5. 我们将重载runAction()方法,调用我们的输出方法而不是 Yii 的渲染方法:

    public function runAction($action)
    {
       $response = null;
        $priorAction=$this->_action;
        $this->_action=$action;
    
        if($this->beforeAction($action))
        {
           $response = $action->runWithParams($this->getActionParams());
            if($response===false)
                $this->invalidActionParams($action);
            else
                $this->afterAction($action);
        }
    
        $this->_action=$priorAction;
    
        $this->renderOutput($response);
    }
    

    注意

    关于CInlineAction有疑问?请务必查看类文档,链接为www.yiiframework.com/doc/api/1.1/CInlineAction

渲染数据

输出我们的数据的下一部分是创建我们之前调用的 renderOutput() 方法:

  1. 我们将首先定义方法。为了使其尽可能适应,我们希望有手动调用此方法的能力,并显示我们想要呈现的状态和消息:

    public function renderOutput($response = array(), $status=NULL, $message=NULL) {}
    
  2. 在此时,我们将定义几个响应头,这将允许网络客户端与我们的 API 通信,并获取现代网络浏览器中设置的相同源策略设置,以保护用户。没有这些跨源资源共享头(简称 CORS),网络客户端将无法与我们的 API 通信。这还将允许网络浏览器发送我们稍后定义的自定义身份验证头:

    header("Access-Control-Allow-Origin: *");
    header("Access-Control-Allow-Headers: x-auth-token, x-auth-email");
    header('Access-Control-Allow-Methods: PUT, PATCH, DELETE, POST, GET, OPTIONS');
    
  3. 我们将定义我们的基本数据响应:

    $data = array(
        'status' => $status != NULL ? $status : $this->status,
        'message' => $message != NULL ? $message : ($this->message == NULL ? 'Your request was successfully fulfilled' : $this->message),
        'response' => $response
    );
    
  4. 然后,我们将确定我们想要从名为 formatGET 参数返回数据的数据格式,并适当地渲染数据:

    $format = Yii::app()->request->getParam('format', 'json');
    if ($format == 'xml')
    {
        header ("Content-Type:text/xml");
        echo $this->renderXML($data);
    }
    else
        echo $this->renderJSON($data);
    Yii::app()->end();
    
  5. 要渲染 JSON 数据,我们只需将我们在上一步骤中构建的数据响应输出,并使用 CJSON::encode()

    private function renderJSON($data)
    {
        header('Content-Type: application/json');
        return CJSON::encode($data);
    }
    
  6. 渲染 XML 数据稍微复杂一些,但可以使用以下递归方法轻松完成:

    private function renderXML($array, $level=1)
    {
        $xml = '';
        if ($level==1)
            $xml .= '<?xml version="1.0" encoding="ISO-8859-1"?>'."\n<data>\n";
    
        foreach ($array as $key=>$value)
        {
            $key = strtolower($key);
            if (is_array($value))
            {
                $multi_tags = false;
                foreach($value as $key2=>$value2)
                {
                    if (is_array($value2))
                    {
                        $xml .= str_repeat("\t",$level)."<$key>\n";
                        $xml .= $this->renderXML($value2, $level+1);
                        $xml .= str_repeat("\t",$level)."</$key>\n";
                        $multi_tags = true;
                    }
                    else
                    {
                        if (trim($value2)!='')
                        {
                            if (htmlspecialchars($value2)!=$value2)
                                $xml .= str_repeat("\t",$level)."<$key><![CDATA[$value2]]>"."</$key>\n";
                            else
                                $xml .= str_repeat("\t",$level)."<$key>$value2</$key>\n";
                        }
    
                        $multi_tags = true;
                    }
                }
    
                if (!$multi_tags and count($value)>0)
                {
                    $xml .= str_repeat("\t",$level)."<$key>\n";
                    $xml .= $this->renderXML($value, $level+1);
                    $xml .= str_repeat("\t",$level)."</$key>\n";
                }
    
            }
            else
            {
                if (trim($value)!='')
                {
                    if (htmlspecialchars($value)!=$value)
                        $xml .= str_repeat("\t",$level)."<$key>"."<![CDATA[$value]]></$key>\n";
                    else
                        $xml .= str_repeat("\t",$level)."<$key>$value</$key>\n";
                }
            }
        }
    
        if ($level==1)
            $xml .= "</data>\n";
    
        return $xml;
    }
    

    注意

    对我们从 CController 扩展的方法有疑问?请务必查看该类的指南,链接为 www.yiiframework.com/doc/api/1.1/CController

以 RESTful 方式调用操作

在 RESTful API 中,单个端点可能对不同类型的 HTTP 请求有不同的响应。例如,/api/user/index 端点可能会在带有 ID 参数的 GET 请求下返回用户列表或特定用户。然而,如果调用 POST 请求,则可能创建或修改新用户。如果对该端点调用带有 ID 的 DELETE 请求,则将从系统中删除用户。

为了在 Yii 中模拟这种行为,我们需要重载 ApiControllercreateAction() 方法,以便调用正确的操作。在我们的控制器中,这将允许我们通过请求类型来分离功能。内部,我们的 API 将以 action<Name><Method> 的格式调用操作,默认的 GET 操作将击中原始操作方法(例如,actionIndex()actionIndexPost()actionIndexDelete())。此方法还将调用我们之前定义的 ApiInlineAction 类,而不是 CInlineAction

public function createAction($actionID)
{
    if($actionID==='')
        $actionID=$this->defaultAction;

    if (Yii::app()->request->getRequestType() != 'GET' && $actionID != 'error')
        $actionID .= Yii::app()->request->getRequestType();

    if(method_exists($this,'action'.$actionID) && strcasecmp($actionID,'s')) // we have actions method
        return new ApiInlineAction($this,$actionID);
    else
    {
        $action=$this->createActionFromMap($this->actions(),$actionID,$actionID);
        if($action!==null && !method_exists($action,'run'))
            throw new CException(Yii::t('yii', 'Action class {class} must implement the "run" method.', array('{class}'=>get_class($action))));
        return $action;
    }
}

用户认证

由于 RESTful API 不会在 API 和客户端之间传递 cookie 信息,因此我们需要对我们的控制器进行一些修改,以便将认证用户与非认证用户分开。为此,我们将重载 CAccessControlFilter,使其针对我们在控制器中填充的用户信息操作:

  1. 我们将首先在我们的ApiController中添加一些更多的公共属性。xauth属性将存储用于认证的X-Auth-TokenX-Auth-Email头信息,而user属性将存储认证用户的原始用户模型。我们将传递这些信息到子控制器进行认证,以及到我们重载的CAccessControlFilter类:

    public $xauthtoken = null;
    public $xauthemail = null;
    public $user = null;
    
  2. 接下来,我们将加载我们的accessControl过滤器。我们还将定义另一个名为CHttpCacheFilter的过滤器,它将通知客户端不要缓存 API 返回的响应:

    public function filters()
    {
        return array(
            array(
                'CHttpCacheFilter',
                'cacheControl'=>'public, no-store, no-cache, must-revalidate',
            ),
            'accessControl'
        );
    }
    
  3. 然后,我们将定义我们的基本accessRules(),它将拒绝访问除错误操作之外的所有方法:

    public function accessRules()
    {   
        return array(
            array('allow',
                'actions' => array('error')
            ),
            array('deny')
        );
    }
    
  4. 接下来,我们将在控制器中处理认证,然后再将其传递给CInlineActionFilter。我们将首先在我们的ApiController中重载filterAccessControl()方法:

    public function filterAccessControl($filterChain) {}
    
  5. 我们将检索X-Auth-TokenX-Auth-Email头信息:

    $this->xauthtoken = isset($_SERVER['HTTP_X_AUTH_TOKEN']) ? $_SERVER['HTTP_X_AUTH_TOKEN'] : NULL;
    $this->xauthemail =isset($_SERVER['HTTP_X_AUTH_EMAIL']) ? $_SERVER['HTTP_X_AUTH_EMAIL'] : NULL;
    
  6. 接下来,我们将对这些信息与我们的数据库进行验证。为此,我们将使用X-Auth-Email地址在我们的数据库中查找一个用户;如果找到,我们将检查稍后在user_metadata表中生成的 API 令牌。如果找到 API 令牌,我们将使用原始用户模型填充$this->user

    if ($this->xauthemail != NULL)
    {
        // If a user exists with that email address
        $user = User::model()->findByAttributes(array('email' => $this->xauthemail));
        if ($user != NULL)
        {
            $q = new CDbCriteria();
            $q->addCondition('t.key LIKE :key');
            $q->addCondition('value = :value');
            $q->addCondition('user_id = :user_id');
            $q->params = array(
                ':user_id' => $user->id,
                ':value' => $this->xauthtoken,
                ':key' => 'api_key'
            );
    
            $meta = UserMetadata::model()->find($q);
    
            // And they have an active XAuthToken, set $this->user = the User object
            if ($meta != NULL)
                $this->user = $user;
        }
    }
    
  7. 最后,我们将调用我们的自定义CAccessControlFilter类,将用户传递给它,设置规则,并调用过滤器:

    $filter=new ApiAccessControlFilter;
    $filter->user = $this->user;
    $filter->setRules($this->accessRules());
    $filter->filter($filterChain);
    

重载 CAccessControlFilter

我们需要在protected/modules/api/components/中创建一个新的类ApiAccessControl过滤器,这样我们就可以在我们的控制器中继续使用accessRules数组。这个类将操作我们从控制器传递给它的user对象,并使我们的accessRules数组与新的用户对象一起工作:

  1. 在创建ApiAccessControlFilter.php文件后,定义如下:

    <?php class ApiAccessControlFilter extends CAccessControlFilter {}
    
  2. 然后,我们需要添加user属性以存储从我们的控制器传递的用户,并重新定义父类操作的private $_rules属性:

    public $user;
    private $_rules;
    
  3. 由于父类中的$_rules数组是私有的,我们还需要重新定义规则数组的获取器和设置器,以及使用私有属性的preFilter()方法。我们首先从preFilter()方法开始:

    protected function preFilter($filterChain)
    {
        $app=Yii::app();
        $request=$app->getRequest();
        $user=$this->user;
        $verb=$request->getRequestType();
        $ip=$request->getUserHostAddress();
    
        foreach($this->getRules() as $rule)
        {
            if(($allow=$rule->isUserAllowed($user,$filterChain->controller,$filterChain->action,$ip,$verb))>0) // allowed
                break;
            elseif($allow<0) // denied
            {
                if(isset($rule->deniedCallback))
                    call_user_func($rule->deniedCallback, $rule);
                else
                    $this->accessDenied($user,$this->resolveErrorMessage($rule));
                return false;
            }
        }
    
        return true;
    }
    
  4. 然后,我们将为我们的rules数组创建获取器和设置器:

    public function getRules()
    {
        return $this->_rules;
    }
    
    public function setRules($rules)
    {
        foreach($rules as $rule)
        {
            if(is_array($rule) && isset($rule[0]))
            {
                $r=new ApiAccessRule;
                $r->allow=$rule[0]==='allow';
                foreach(array_slice($rule,1) as $name=>$value)
                {
                    if($name==='expression' || $name==='roles' || $name==='message' || $name==='deniedCallback')
                        $r->$name=$value;
                    else
                        $r->$name=array_map('strtolower',$value);
                }
                $this->_rules[]=$r;
            }
        }
    }
    
  5. 在这个时候,我们还想重新定义当用户没有访问特定操作时的accessDenied行为。在这里,我们将简单地调用ApiControllerrenderOutput()方法:

    protected function accessDenied($user,$message=NULL)
    {
        http_response_code(403);
        Yii::app()->controller->renderOutput(array(), 403, $message);
    }
    
  6. 为了遵循 Yii 的相同约定,我们还将添加第二个类,ApiAccessRule,它扩展了同一文件中的CAccessRule。这只是简单的修改,确保我们的信息被加载,而不是传递给CAccessRule的信息:

    class ApiAccessRule extends CAccessRule
    {
        public function isUserAllowed($user,$controller,$action,$ip,$verb)
        {
            if($this->isActionMatched($action)
                && $this->isIpMatched($ip)
                && $this->isVerbMatched($verb)
                && $this->isControllerMatched($controller)
                && $this->isExpressionMatched($user))
                return $this->allow ? 1 : -1;
            else
                return 0;
        }
    }
    

    注意

    想了解更多关于CAccessControlFilter的信息?请查看类文档www.yiiframework.com/doc/api/1.1/CAccessControlFilter

处理传入的数据

由于我们的 RESTful API 将返回 JSON,因此它也接受 JSON 是合适的。为了方便,我们将配置我们的 API 接受来自数据的application/x-www-form-urlencoded(从表单发送的数据),这样我们的 Web 客户端可以直接 POST 到我们的 API,而无需进行数据转换。

为了让我们的 API 接受这些数据,我们将重载beforeAction()方法,以便在提供的情况下获取原始 JSON 主体,并将其填充到我们的$_POST数据中,如果它是一个有效的 JSON 请求。如果发送了无效的 JSON,我们将返回 HTTP 400 错误,表示请求存在问题。错误将触发我们的actionError()方法,并冒泡到我们的runAction()方法,最终显示错误:

public function beforeAction($action)
{
    // If content was sent as application/x-www-form-urlencoded, use it. Otherwise, assume raw JSON was sent and convert it into
    // the $_POST variable for ease of use
    if (Yii::app()->request->rawBody != "" && empty($_POST))
    {
        // IF the rawBody is malformed, throw an HTTP 500 error. Use json_encode so that we can get json_last_error
        $_POST = json_decode(Yii::app()->request->rawBody);
        if (json_last_error() != JSON_ERROR_NONE)
        {
            header('HTTP/1.1 400 Bad Request');
            $this->status = 400;
            $this->message = 'Request payload not properly formed JSON.';
            return null;
        }

        $_POST = CJSON::decode(Yii::app()->request->rawBody);
    }

    return parent::beforeAction($action);
}

处理错误

在继续创建控制器之前,我们需要确保我们的父类可以处理发送给它的任何错误。我们将要处理的错误有两种类型——第一种是 Yii 内部遇到的错误或通过我们调用的异常,第二种是我们想要展示给用户但不想通过异常发送的错误。

异常处理

为了处理我们抛出或 Yii 内部抛出的异常,我们将定义基actionError()方法如下。这里的数据集将简单地填充我们之前重载的runAction()方法,并确保以正确的格式显示适当的错误:

public function actionError()
{
    if($error=Yii::app()->errorHandler->error)
    {
        $this->status = $error['code'];
        $this->message = $error['message'];
    }
}

自定义错误处理

在我们的控制器中,将会有我们想要返回错误给用户而不触发异常的情况。一个很好的例子是模型验证错误。我们想要通知用户出了问题,但希望优雅地返回错误而不使我们的应用程序停滞。为此,我们将创建一个returnError()方法,从我们的控制器中调用,并将数据填充回我们之前定义的runAction()方法:

public function returnError($status, $message = NULL, $response)
{
    header('HTTP/1.1 '. $status);
    $this->status = $status;

    if ($message === NULL)
        $this->message = 'Failed to set model attributes.';
    else
        $this->message = $message;

    return $response;
}

测试是否一切正常

在我们开始创建其他控制器和操作之前,让我们创建一个非常简单的控制器,以验证我们的 API 是否按预期工作。为此,让我们在protected/modules/api/controllers中创建一个名为DefaultController的类,并按照以下设置进行:

class DefaultController extends ApiController
{
    public function accessRules()
    {
        return array(
            array('allow',
                'actions' => array('index', 'error')
            ),
            array('deny')
        );
    }

    public function actionIndex()
    {
        return "test";
    }
}

如果您的 API 设置正确,您应该能够打开浏览器到http://chapter8.example.com/api并看到以下内容显示:

{
    "status":200,
    "message":"Your request was successfully fulfilled",
    "response":"test"
}

如您所见,我们从操作返回的任何数据现在都在我们的 JSON 对象的响应属性中。此外,如果我们想渲染 XML 而不是 JSON,我们可以在http://chapter8.example.com/api?format=xml URL 中添加format=xml GET参数,如下所示:

<data>
    <status>200</status>
    <message>Your request was successfully fulfilled</message>
    <response>test</response>
</data>

注意

大多数负载均衡器和健康检查服务都会验证端点返回200状态。因此,如果你打算在你的 API 中添加健康检查,建议你只需从默认方法返回 true。

用户认证

现在我们 API 已经可用,让我们添加用户认证 API 的能力。为此,我们将创建一个端点,该端点接受以下 JSON 请求体:

{
   "email": "user@example.com",
   "password": "<example_password>"
}

使用这些信息,API 将通过我们在前面的章节中工作的LoginForm进行认证。如果用户有效,我们将生成一个新的 API 令牌,该令牌将被存储在user_metadata表中。此令牌将返回给发起请求的客户端,并将用于所有未来的请求:

  1. 要开始,请在protected/modules/api/controllers/目录下创建一个新的控制器,命名为UserController.php,其定义如下:

    <?php class UserController extends ApiController {}
    
  2. 接下来,我们需要定义一组默认的访问规则,以便我们的认证方法可以在不进行认证的情况下使用:

    public function accessRules()
    {
        return array(
           array('allow',
              'actions' => array('tokenPost'),
           ),
            array('deny')
        );
    }
    
  3. 由于这是一个POST端点,我们将按照以下方式定义我们的新方法:

    public function actionTokenPost() {}
    
  4. 然后,我们将实例化一个新的LoginForm实例,并从 JSON 体中检索我们的电子邮件地址和密码。记住,在我们的ApiController类中,我们直接将原始 JSON 体转换成了我们的$_POST参数,以便更容易处理:

    $model = new LoginForm;
    $model->username = Yii::app()->request->getParam('email', NULL);
    $model->password = Yii::app()->request->getParam('password', NULL);
    
  5. 在获取这些信息后,我们将尝试登录:

    if ($model->login()) {}
    
  6. 如果成功,我们将加载用户信息:

    $user = User::model()->findByAttributes(array('email' => $model->username));
    
  7. 尝试更新现有的 API 令牌,或者生成一个新的:

    $token = UserMetadata::model()->findByAttributes(array(
        'user_id' => $user->id,
        'key' => 'api_key'
    ));
    
    if ($token == NULL)
        $token = new UserMetadata;
    
    $token->attributes = array(
       'user_id' => $user->id,
       'key' => 'api_key',
       'value' => $user->generateActivationKey() // Reuse this method for cryptlib
    );
    
  8. 如果我们能够将令牌保存到数据库中,我们将返回它:

    if ($token->save())
        return $token->value;
    
  9. 在我们的if ($model->login())条件之外,我们将简单地返回一个错误给用户,表明出了些问题。由于这是一个认证方法,我们不希望泄露太多信息,以防止人们尝试暴力破解我们的 API 端点:

    return $this->returnError(401, $model->getErrors(), null);
    

测试认证

在继续之前,让我们确保我们的认证端点是正常工作的。为此,我们将使用一个名为RestConsole的 Google Chrome 扩展程序进行测试,该扩展程序可以从 Chrome 应用商店下载,网址为chrome.google.com/webstore/detail/rest-console/cokgbflfommojglbmbpenpphppikmonn?hl=en。如果你还没有安装 Google Chrome,你可以从www.google.com/intl/en-US/chrome/browser/下载。安装后,导航到 RestConsole 下载页面并安装插件。安装完成后,你可以在 Chrome 应用商店点击启动应用按钮来加载 RestConsole。加载完成后,你将看到几个不同的部分:

注意

像 RestConsole 这样的工具将允许我们从漂亮的 GUI 界面快速测试我们的 API 端点。如果您愿意,您可以直接从命令行使用 cURL 实用程序测试端点,该实用程序可通过大多数包管理器获得。

  1. 目标部分,填写如下截图所示的内容。请确保根据您的本地环境进行调整。本部分的关键细节是请求 URI字段。测试身份验证

  2. 然后,滚动到正文部分,并按照以下方式填写该部分:测试身份验证

    注意

    这一部分的关键部分是请求有效载荷部分。这是您将添加要发送到服务器的原始 JSON 正文的区域。在这个例子中,我们使用的是我们在第七章中建立的凭据,为 CMS 创建管理模块

    {
        "email": "user1@example.com",
        "password": "test"
    }
    

    如果您在此之后更改了这些凭据,请确保在您的 JSON 正文中更改它们。

  3. 最后,点击页面底部的提交按钮。这将向服务器发送请求。如果成功,您将收到一个包含响应正文中 API 令牌的 HTTP 200 状态码响应:测试身份验证

    {
        "status": 200,
        "message": "Your request was successfully fulfilled",
        "response": "aRwfTYyKlMm2SDaK"
    }
    

    注意

    您的响应正文将略有不同,因为 API 令牌在每个身份验证请求中都是随机生成的。

发送经过身份验证的请求

现在我们能够对我们的 API 进行身份验证,让我们确保我们可以发送经过身份验证的请求。为此,我们将创建一个 API 端点,以便注销我们的用户。这将接受用户的凭据,然后从数据库中删除 API 令牌,以防止未来的使用:

  1. 创建此端点包括两个部分。首先,我们需要向我们的accessRules数组添加一个项目,允许经过身份验证的用户向令牌端点发送DELETE请求。我们将通过向我们的accessRules数组添加以下内容来实现这一点:

    array('allow',
       'actions' => array('tokenDelete'),
       'expression' => '$user!=NULL'
    )
    
  2. 然后,我们将为我们的令牌端点添加删除方法,该方法将通过 HTTP DELETE方法提供:

    public function actionTokenDelete()
    {
       $model = UserMetadata::model()->findByAttributes(array('user_id' => $this->user->id, 'value' => $this->xauthtoken));
    
       if ($model === NULL)
          throw new CHttpException(500, 'An unexpected error occured while deleting the token. Please re-generate a new token for subsequent requests.');
       return $model->delete();
    }
    

现在我们已经设置了端点,返回 RestConsole,删除请求正文,并在请求正文下面的自定义头部部分添加以下自定义头部,如下截图所示:

X-Auth-Email: user1@example.com
X-Auth-Token: aRwfTYyKlMm2SDaK

发送经过身份验证的请求

然后,点击页面底部的删除按钮以发送DELETE请求。您应该会收到以下响应:

{
    "status": 200,
    "message": "Your request was successfully fulfilled",
    "response": true
}

我们现在已经成功测试了用户身份验证并添加了从我们的 API 中注销的能力。请注意,如果您再次尝试提交一个DELETE请求,我们的acccessRules数组将启动并阻止该请求,从而返回以下响应:

{
    "status": 403,
    "message": "You are not authorized to perform this action.",
    "response": []
}

实现 CRUD 操作

现在我们可以认证并使用我们的 API,我们可以以 RESTful 方式实现四个基本的 CRUD 操作。RESTful 操作归结为三种主要的 HTTP 请求类型——GETPOSTDELETE。我们将为我们的用户实现每一种:

我们需要实现的第一种方法是我们的loadModel()方法。此方法将在我们的用户模型中加载,并在出现错误时抛出适当的错误:

private function loadModel($id=NULL)
{
    if ($id == NULL)
        throw new CHttpException(400, 'Missing ID');

    $model = User::model()->findByPk($id);

    if ($model == NULL)
        throw new CHttpException(400, 'User not found');

    return $model;
}

删除用户

我们将要实现的第一种方法是我们的DELETE方法。记住,对于每个方法,我们将针对单个端点/api/user/index使用不同的 HTTP 请求类型:

  1. 我们需要做的第一个更改是accessRules。我们希望只有管理员才有权删除用户。我们将通过设置一个表达式来检查用户是否是管理员来实现这一点:

    array('allow',
        'actions' => array('indexDelete'),
        'expression' => '$user!=NULL&&$user->role->id==2'
    )
    
  2. 然后,我们将实现删除操作。我们想要确保用户不能删除自己:

    public function actionIndexDelete($id=NULL)
    {
        if ($id == $this->user->id)
             return $this->returnError(401, 'You cannot delete yourself', null);
    
         return $this->loadModel($id)->delete();
    }
    

/api/user/index/id/<user_id>发送DELETE请求现在将删除具有给定 ID 的用户。

检索用户

我们将要实现的第二种方法是GET方法,该方法将根据是否提供了 ID 来检索单个用户,或者如果用户是管理员,则检索多个用户。在任何情况下,我们都需要确保用户已经认证:

  1. 第一个更改,再次,将是我们的accessRules数组。我们将检查用户是否是管理员,或者给定的 ID 是否属于当前认证的用户:

    array('allow',
        'actions' => array('index'),
        'expression' => '$user!=NULL&&($user->role->id==2||Yii::app()->request->getParam("id")==$user->id)'
    )
    
  2. 然后,我们在控制器中设置一个GET方法。记住,我们在ApiController类中设置了createAction()方法,这样GET请求就不需要在方法末尾使用 HTTP 动词:

    public function actionIndex($id=NULL) {}
    
  3. 然后,如果提供了 ID,我们将简单地加载请求的用户。如果用户不是管理员并且他们请求了另一个用户,我们将抛出一个异常;否则,我们将返回适当的数据:

    array('allow',
        'actions' => array('index', 'indexPost'),
        'expression' => '$user!=NULL&&($user->role->id==2||Yii::app()->request->getParam("id")==$user->id)'
    if ($id !== NULL)
    {
        if ($this->user->role->id != 2 && $this->user->id != $id)
           throw new CHttpException(403, 'You do not have access to this resource');
    
        return $this->loadModel($id)->getApiAttributes(array('password'), array('role', 'metadata'));
    }
    

    如果您还记得,我们为了添加getApiAttributes()方法而更改了我们的CMSActiveRecord模型。现在调用此方法允许我们排除我们不希望在请求中发送的某些元素,例如用户密码。这也允许我们返回有关用户的元数据,例如角色和与用户关联的任何元数据。

  4. 继续进行,如果没有指定 ID,我们将确保用户是管理员:

    if ($this->user->role->id != 2)
        throw new CHttpException(403, 'You do not have access to this resource');
    
  5. 如果是这样,我们将加载我们模型的搜索实例。这扩展了我们的端点以允许动态搜索:

    $model = new User('search');
    $model->unsetAttributes();  // clear any default values
    if(isset($_GET['User']))
        $model->attributes = $_GET['User'];
    
  6. 为了允许分页,我们将从$model->search()方法实例化一个CActiveDataProvider的副本,并将页面变量设置为GET参数页面。这将允许我们通过分页浏览我们的用户,而不是在单个请求中一次性显示所有用户:

    $dataProvider = $model->search();
    $dataProvider->pagination = array(
        'pageVar' => 'page'
    );
    
  7. 为了处理分页,我们将继续显示结果,直到没有找到结果。当没有找到结果时,我们将抛出一个 HTTP 404 错误。这将允许客户端进行无限滚动,并让我们的客户知道何时停止请求数据:

    if ($dataProvider->totalItemCount == 0 || ($dataProvider->totalItemCount / ($dataProvider->itemCount * Yii::app()->request->getParam('page', 1))) < 1)
        throw new CHttpException(404, 'No results found');
    
  8. 然后,我们将使用getData()方法遍历我们的dataProvider,生成当前页面上所有用户对象的数组:

    $response = array();
    
    foreach ($dataProvider->getData() as $user)
        $response[] = $user->getAPIAttributes(array('password'), array('role', 'metadata'));
    
  9. 最后,我们将返回整个响应:

    return $response;
    

现在,向 API 端点发送几个请求来测试一切。你应该能够以管理员身份登录并查看所有用户或任何用户。你也应该能够以普通用户身份登录,只检索有关自己的信息。

创建和更新用户

我们需要实现的最后一个端点是POST方法,它将作为创建和更新现有用户的端点:

  1. 我们将首先更新上一节中定义的accessRules数组,以包括indexPost

    array('allow',
        'actions' => array('index', 'indexPost'),
        'expression' => '$user!=NULL&&($user->role->id==2||Yii::app()->request->getParam("id")==$user->id)'
    )
    
  2. 然后,我们将创建一个POST端点,它将分支为两个独立的方法——一个用于创建用户,另一个用于修改用户:

    public function actionIndexPost($id=NULL)
    {
        if ($id == NULL)
            return $this->createUser();
        else
            return $this->updateUser($id);
    }
    
  3. 由于创建用户的所有信息都将来自正常的POST响应,因此我们创建新用户所需做的只是验证他们是否是管理员,实例化一个新的用户模型,验证它,并保存它。如果由于任何原因遇到错误(例如无效的属性),我们只需在 JSON 响应中返回$model->getErrors()中的错误:

    private function createUser()
    {
        if ($this->user->role->id != 2)
            throw new CHttpException(403, 'You do not have access to this resource');
    
        $model = new User;
        $model->attributes = $_POST;
    
        if ($model->save())
            return User::model()->findByPk($model->id)->getApiAttributes(array('password'), array('role', 'metadata'));
        else
            return $this->returnError(400, $model->getErrors(), null);
    }
    
  4. 事实上,更新用户就像加载现有的用户模型并执行与创建新用户相同的事情一样简单。在这个端点中,唯一的区别是我们需要确保用户是管理员,或者他们正在尝试修改自己的数据:

    private function updateUser($id=NULL)
    {
        if ($this->user->role->id != 2 && $this->user->id != $id)
            throw new CHttpException(403, 'You do not have permission to modify this user');
    
        $model = $this->loadModel($id);
    
        $model->attributes = $_POST;
    
        if ($model->save())
             return User::model()->findByPk($model->id)->getApiAttributes(array('password'), array('role', 'metadata'));
        else
            return $this->returnError(400, $model->getErrors(), null);
    }
    

到目前为止,请确保你可以作为管理员创建新用户,以及现有用户可以修改自己的数据。

实现主应用程序中的其他控制器操作

到目前为止,我们已经为我们的用户数据模型创建了基本的 CRUD 接口。虽然这处理了很多管理任务,但还有一些其他方法我们可以从应用程序的前端移动到我们的 API。这些方法包括注册、账户验证和密码重置请求等操作。将这些方法从我们的前端移动到 API 中,可以立即使此功能对任何 API 消费者可用,这使得我们的 API 对 Web 和本地客户端都更有价值。

例如,我们可以通过简单地用表示注册成功的布尔值或由模型生成的错误列表替换渲染操作,轻松地将我们的注册操作从前端适配到 API。因为所有的验证规则和验证检查都是在模型中执行的,所以适配操作相对简单,如下所示:

public function actionRegisterPost()
{
    $form = new RegistrationForm;
    $form->attributes = $_POST;

    if ($form->save())
        return true;
    else
        return $this->returnError(400, $form->getErrors(), null);
}

现在尝试实现前端控制器中的其他操作,例如actionVerifyPostactionActivateactionForgotPostactionResetPasswordPost

实现分类和内容 API 控制器

我们的 CMS 不仅包括与用户相关的操作——我们还需要管理内容和分类。再次强调,将此功能从我们的仪表板控制器移动到我们的 API 相当简单。我们只是移除了与视图相关的功能,并返回布尔值或由模型生成的错误。在我们的GET方法中,我们只是添加了一些使用已提供的CActiveDataProvider分页功能进行分页,并返回相关结果。这两个控制器将几乎与我们的UserController相同,因为它们以相同的方式工作,只是数据模型不同。请尝试自己完成这些控制器。

注意

请记住,完整的应用程序已包含在项目资源中。如果您遇到困难,请查看资源文件夹。

记录我们的 API

虽然我们的 API 使用起来很有趣,并且易于集成,但如果可用的端点、细节和示例没有清晰地记录,那么对于想要使用我们的 API 的开发者来说,这毫无意义。在将您的 API 与全世界分享之前,请确保记录下客户端可以访问的端点。同样,详细记录用户需要执行的操作以验证 API 也是一个好主意。通常,这是通过提供详细的示例请求和详细的示例响应来完成的。

摘要

正如本书中所示,Yii 框架是一个极其强大、灵活且易于使用的 PHP 框架。在本章中,我们彻底改变了 Yii 框架通过 JSON 请求处理用户认证的方式,并使其能够返回 JSON 和 XML 文档类型,以便 Web 和原生应用程序都可以消费 API。在本章中,我们还介绍了我们需要对之前设计为直接渲染给客户端的功能进行哪些更改,以便将其迁移到我们的 API 以作为 JSON 或 XML 渲染。最后,我们调整了我们的 API,使其能够对同一端点的不同类型的 HTTP 请求做出响应,从而使我们能够创建一个文档齐全的 RESTful JSON API。

感谢您阅读这本书。在这本书中,我们展示了无数个例子,说明了 Yii 框架是多么强大和灵活。从与第三方 API 的交互到执行数据库无关的迁移,再到开发功能齐全的应用程序,包括 API,Yii 框架使我们能够快速工作、开发和调整我们的代码,以便及时满足我们的目标和最终目标。我希望您觉得这本书中的信息是有益的、有用的,并且有趣的。我也希望您已经学会了如何使用 Yii 框架来做不仅仅是创建简单的 Web 应用程序。

在 Yii 框架的关于页面,Yii 被描述为 "Yes It Is" 的缩写,它回答了关于 Yii 的一些最基本的问题。Yii 快速吗?Yii 安全吗?Yii 专业吗?Yii 适合您的下一个项目吗?我希望这本书已经向您表明,这些问题的答案是一个简单的“是的,它是”。

posted @ 2025-09-09 11:30  绝不原创的飞龙  阅读(5)  评论(0)    收藏  举报