PHP-YII-Web-应用开发(全)

PHP YII Web 应用开发(全)

原文:zh.annas-archive.org/md5/6008a5c78f9d1deb914065f1c36d5b5a

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

本书是使用 Yii Web 应用程序开发框架开发真实应用程序的逐步教程。本书试图模拟一个被要求构建在线应用程序的软件开发团队的环境,涵盖软件开发生命周期的每个方面,从构思到生产部署,构建项目任务管理应用程序。

在对 Yii 框架进行简要的一般介绍,并通过标志性的“Hello World”示例之后,剩下的章节以与真实项目中软件开发迭代相同的方式进行了分解。我们首先创建一个具有有效,经过测试的与数据库连接的工作应用程序。

然后我们开始定义我们的主要数据库实体和领域对象模型,并熟悉 Yii 的对象关系映射ORM)层Active Record。我们学习如何依靠 Yii 的代码生成工具自动构建针对我们新创建的模型的创建/读取/更新/删除(CRUD)功能。我们还关注 Yii 的表单验证和提交模型的工作原理。到第五章 管理问题 结束时,您将拥有一个可以管理项目和项目中的问题(任务)的工作应用程序。

然后我们转向用户管理的话题。我们了解 Yii 内置的身份验证模型,以帮助应用程序的登录和注销功能。我们深入研究授权模型,首先利用 Yii 的简单访问控制模型,然后实施 Yii 提供的更复杂的基于角色的访问控制RBAC)框架。

到第七章 用户访问控制 结束时,任务管理应用程序的所有基础都已就位。接下来的几章将开始专注于额外的用户功能,用户体验和设计。我们添加用户评论功能,介绍了一种可重用的内容小部件架构方法。我们添加了一个 RSS 网络订阅,并演示了在 Yii 应用程序中集成其他第三方工具和框架有多么容易。我们利用 Yii 的主题结构来帮助简化和设计应用程序,然后介绍 Yii 的国际化(I18N)功能,以便应用程序可以在不进行工程更改的情况下适应各种语言和地区。

在最后一章中,我们将把重点转向准备应用程序进行生产部署。我们介绍了优化性能和提高安全性的方法,以准备应用程序投入生产环境。

本书涵盖内容

第一章,遇见 Yii,为您提供了 Yii 的简要历史,介绍了模型视图控制器MVC)应用程序架构,并向您介绍了典型的请求生命周期,从最终用户通过应用程序,最终作为响应返回给最终用户。

第二章,入门,致力于下载和安装框架,创建一个新的 Yii 应用程序外壳,并介绍 Gii,Yii 强大灵活的代码生成工具。

第三章,TrackStar 应用程序,介绍了 TrackStar 应用程序。这是一个在线项目管理和问题跟踪应用程序,您将在接下来的章节中构建。在这里,您将学习如何将 Yii 应用程序连接到底层数据库。您还将学习如何从命令行运行交互式 shell。本章的最后部分侧重于在 Yii 应用程序中提供单元测试和功能测试的概述,并提供了在 Yii 中编写单元测试的具体示例。

第四章,“项目 CRUD”,帮助您开始与数据库交互,开始向基于数据库的 Yii 应用程序 TrackStar 添加功能。您将学习如何使用 Yii 迁移进行数据库变更管理,我们使用 Gii 工具创建模型类,并使用模型类构建创建、读取、更新和删除(CRUD)功能。本章还向读者介绍了配置和执行表单字段验证。

第五章,“管理问题”,解释了如何向 TrackStar 应用程序添加其他相关的数据库表,并介绍了 Yii 中的关联 Active Record。本章还涵盖了使用控制器过滤器来利用应用程序生命周期,以提供前操作和后操作处理。我们介绍了官方 Yii 扩展库 Zii,并使用 Zii 小部件来增强 TrackStar 应用程序。

第六章,“用户管理和身份验证”,解释了如何在 Yii 中对用户进行身份验证。在为 TrackStar 应用程序添加管理用户功能的同时,读者学习如何利用 Yii 中的“行为”来实现对 Yii 组件之间共享通用代码和功能的极其灵活的方法。本章还详细介绍了 Yii 的身份验证模型。

第七章,“用户访问控制”,专门介绍了 Yii 的授权模型。首先,我们介绍了简单的访问控制功能,它允许您轻松地为基于多个参数的控制器操作配置访问规则。然后,我们看看在 Yii 中如何实现基于角色的访问控制(RBAC),它允许更健壮的授权模型,完全基于角色、操作和任务的分层模型进行完整的访问控制。将基于角色的访问控制实现到 TrackStar 应用程序中还向读者介绍了在 Yii 中使用控制台命令。

第八章,“添加用户评论”,帮助演示了如何实现允许用户在 TrackStar 应用程序中对项目和问题进行评论的功能;我们介绍了如何配置和使用统计查询关系,如何创建高度可重用的用户界面组件称为“小部件”,以及如何在 Yii 中定义和使用命名范围。

第九章,“添加 RSS Web Feed”,演示了在 Yii 应用程序中使用其他第三方框架和库有多么容易,并展示了如何使用 Yii 的 URL 管理功能来自定义应用程序的 URL 格式和结构。

第十章,“使其看起来不错”,帮助您更多地了解 Yii 中的视图,以及如何使用布局来管理跨应用程序页面共享的标记和内容。我们还介绍了主题化,展示了如何轻松地为 Yii 应用程序赋予全新的外观,而无需修改任何基础工程。然后,我们将介绍 Yii 中的国际化(i18n)和本地化(l10n),因为语言翻译被添加到我们的 TrackStar 应用程序中。

第十一章,“使用 Yii 模块”,解释了如何通过使用 Yii 模块向 TrackStar 网站添加管理功能。模块提供了一种非常灵活的方法来开发和管理应用程序中较大的、独立的部分。

第十二章,“生产就绪”,帮助我们准备我们的 TrackStar 应用程序投入生产。您将了解 Yii 的日志框架、缓存技术和错误处理方法,以帮助使您的 Yii 应用程序达到生产就绪状态。

本书所需软件

本书需要以下软件:

  • Yii 框架版本 1.1.12

  • PHP 5.1 或更高版本(建议使用 5.3 或 5.4)

  • MySQL 5.1 或更高版本

  • 能够运行 PHP 5.1 的 Web 服务器;本书中提供的示例是在 Apache HTTP 服务器上构建和测试的,在这些服务器上 Yii 已经在 Windows 和 Linux 环境中进行了彻底测试

  • Zend 框架版本 1.1.12 或更高版本(仅需要第九章中的内容,添加 RSS Web Feed,以及此库的下载和配置,这在本章中有介绍)

这本书适合谁

如果您是具有面向对象编程知识的 PHP 程序员,并且希望快速开发现代、复杂的 Web 应用程序,那么这本书适合您。阅读本书不需要对 Yii 有任何先前的了解。

约定

在本书中,您会发现一些文本样式,用于区分不同类型的信息。以下是一些这些样式的示例,以及它们的含义解释。

文本中的代码词如下所示:“我们可以通过使用include指令来包含其他上下文。”

代码块设置如下:

'components'=>array( 
'db'=>array(
    'connectionString' => 'mysql:host=localhost;dbname=trackstar',
    'emulatePrepare' => true,
    'username' => '[YOUR-USERNAME]',
    'password' => '[YOUR-PASSWORD]',
    'charset' => 'utf8',
  ),
),

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

'components'=>array( 
'db'=>array(
    **'connectionString' => 'mysql:host=localhost;dbname=trackstar',
    'emulatePrepare' => true,**
    'username' => '[YOUR-USERNAME]',
    'password' => '[YOUR-PASSWORD]',
    'charset' => 'utf8',
  ),
),

任何命令行输入或输出都以以下方式编写:

**$ yiic migrate create <name>**
**%cd /WebRoot/trackstar/protected/tests**

新术语重要单词以粗体显示。例如,屏幕上看到的单词,如菜单或对话框中的单词,会以这样的方式出现在文本中:“点击下一步按钮会将您移动到下一个屏幕”。

注意

警告或重要说明会以这样的方式出现在框中。

提示

提示和技巧会以这样的方式出现。

第一章:见识 Yii

网络开发框架通过立即提供核心基础和所需的基础设施,帮助您快速将白板上的想法转化为功能齐全的生产就绪代码,从而帮助您快速启动应用程序。有了今天网络应用程序所需的所有常见功能,并且有可用的框架选项来满足这些期望,几乎没有理由从头开始编写下一个网络应用程序。现代、灵活、可扩展的框架几乎和编程语言本身一样是今天网络开发人员的必备工具。当两者特别互补时,结果是一个非常强大的工具包——比如 Java 和 Spring、Ruby 和 Rails、C#和.NET 以及 PHP 和 Yii。

Yii 是创始人薛强的心血结晶,他于 2008 年 1 月 1 日开始开发这个开源框架。薛强在开始这个项目之前,曾经多年开发和维护 PRADO 框架。从 PRADO 项目中积累的多年经验和用户反馈,巩固了对一个更易于扩展、更高效的基于 PHP5 的框架的需求,以满足应用开发人员日益增长的需求。Yii 的初始 alpha 版本于 2008 年 10 月正式发布,与其他基于 PHP 的框架相比,其极其出色的性能指标立即引起了极大的关注。2008 年 12 月 3 日,Yii 1.0 正式发布,截至 2012 年 10 月 1 日,最新的生产就绪版本为 1.1.12。它拥有一个不断壮大的开发团队,并且每天都在 PHP 开发人员中不断增加其知名度。

Yii这个名字是Yes, it is的缩写,发音为Yee或(ji:)。Yii 是一个高性能、基于组件的、用 PHP5 编写的 Web 应用程序框架。这个名字也代表了最常用来描述它的形容词,比如易用、高效和可扩展。让我们快速看一下 Yii 的每个特点。逐个进行。

易用

要运行基于 Yii 1.x 的 Web 应用程序,您只需要核心框架文件和支持 PHP 5.1.0 或更高版本的 Web 服务器。要使用 Yii 进行开发,您只需要了解 PHP 和面向对象编程。您不需要学习任何新的配置或模板语言。构建 Yii 应用程序主要涉及编写和维护自己的自定义 PHP 类,其中一些将扩展自核心 Yii 框架组件类。

Yii 吸收了许多其他知名 Web 编程框架和应用程序的优秀理念和工作。因此,如果您从其他网络开发框架转向 Yii,您很可能会发现它很熟悉并且易于操作。

Yii 还秉承着“约定优于配置”的理念,这有助于其易用性。这意味着 Yii 对于几乎所有用于配置应用程序的方面都有合理的默认值。遵循规定的约定,您可以编写更少的代码,并花费更少的时间开发应用程序。但是,Yii 并不强迫您。它允许您自定义所有默认值,并且很容易覆盖所有这些约定。我们将在本章和整本书中介绍一些这些默认值和约定。

高效

Yii 是一个高性能的基于组件的框架,可用于开发任何规模的 Web 应用程序。它鼓励在 Web 编程中最大程度地重用代码,并且可以显著加速开发过程。正如之前提到的,如果您遵循 Yii 的内置约定,您可以几乎不需要手动配置就能让应用程序立即运行起来。

Yii 还设计为帮助您进行DRY开发。DRY代表不要重复自己,这是敏捷应用程序开发的一个关键概念。所有 Yii 应用程序都是使用模型-视图-控制器MVC)架构构建的。Yii 通过提供一个地方来保存您的 MVC 代码的每一部分来强制执行这种开发模式。这最小化了重复,并有助于促进代码重用和易于维护。您需要编写的代码越少,将应用程序推向市场所需的时间就越少。应用程序越容易维护,它在市场上的时间就越长。

当然,这个框架不仅使用高效,而且速度非常快,性能优化。Yii 从一开始就考虑了性能优化,并且结果是 PHP 框架中最高效的之一。因此,Yii 增加到其上的应用程序的任何额外开销都是极其微不足道的。

可扩展

Yii 经过精心设计,几乎可以扩展和定制其代码的每一部分,以满足任何项目需求。事实上,很难不利用 Yii 的可扩展性,因为开发 Yii 应用程序的主要活动之一就是扩展核心框架类。如果您想将扩展的代码转化为其他开发人员有用的工具,Yii 提供了易于遵循的步骤和指南,帮助您创建这样的第三方扩展。这使您能够为 Yii 不断增长的功能列表做出贡献,并积极参与扩展 Yii 本身。

值得注意的是,这种易用性、卓越性能和深度的可扩展性并不是以牺牲其功能为代价的。Yii 充满了功能,帮助您满足当今 Web 应用程序所面临的高要求。支持 AJAX 的小部件,RESTful 和 SOAP Web 服务集成,MVC 架构的强制执行,DAO 和关系 ActiveRecord 数据库层,复杂的缓存,分层基于角色的访问控制,主题,国际化(I18N)和本地化(L10N)只是 Yii 冰山一角。从 1.1 版本开始,核心框架现在打包了一个官方扩展库,称为Zii。这些扩展由核心框架团队成员开发和维护,并继续扩展 Yii 的核心功能集。并且随着一个庞大的用户社区,他们也通过编写 Yii 扩展来贡献,Yii 应用程序的整体功能集每天都在增长。在 Yii 框架网站上可以找到可用的用户贡献的扩展列表,网址为www.yiiframework.com/extensions。还有一个非官方的扩展库,其中包含了一些很棒的扩展,网址为yiiext.github.com/,这真正展示了社区的力量和这个框架的可扩展性。

MVC 架构

正如前面提到的,Yii 是一个 MVC 框架,并为每个模型、视图和控制器代码的每个部分提供了明确的目录结构。在我们开始构建第一个 Yii 应用程序之前,我们需要定义一些关键术语,并了解 Yii 如何实现和强制执行这种 MVC 架构。

模型

通常在 MVC 架构中,模型负责维护状态,并应该封装适用于定义此状态的数据的业务规则。在 Yii 中,模型是框架类CModel或其子类的任何实例。模型类通常由可以具有单独标签(用于显示目的的用户友好内容)的数据属性组成,并且可以根据模型中定义的一组规则进行验证。构成模型类属性的数据可以来自数据库表的一行,也可以来自用户输入表单中的字段。

Yii 实现了两种模型,即表单模型(CFormModel类)和活动记录(CActiveRecord类)。它们都是从同一个基类CModel继承而来。CFormModel类表示收集 HTML 表单输入的数据模型。它封装了所有表单字段验证的逻辑,以及可能需要应用于表单字段数据的任何其他业务逻辑。然后它可以将这些数据存储在内存中,或者借助活动记录模型将数据存储在数据库中。

活动记录(AR)是一种设计模式,用于以面向对象的方式抽象数据库访问。Yii 中的每个 AR 对象都是CActiveRecord或其子类的实例,它包装数据库表或视图中的单行数据,封装了所有与数据库访问相关的逻辑和细节,并包含了大部分需要应用于该数据的业务逻辑。表行中每个列的数据字段值都表示为活动记录对象的属性。稍后将更详细地介绍活动记录。

视图

通常视图负责呈现用户界面,通常基于模型中的数据。Yii 中的视图是一个包含用户界面相关元素的 PHP 脚本,通常使用 HTML 构建,但也可以包含 PHP 语句。通常,视图中的任何 PHP 语句都非常简单,是条件或循环语句,或者引用其他 Yii UI 相关元素,如 HTML 助手类方法或预构建小部件。更复杂的逻辑应该与视图分离,并适当放置在模型中(如果直接处理数据)或控制器中(用于更一般的业务逻辑)。

控制器

控制器是我们路由请求的主要指挥官,负责接收用户输入,与模型交互,并指示视图更新和适当显示。Yii 中的控制器是CController或其子类的实例。当控制器运行时,它执行请求的操作,然后与必要的模型交互,并呈现适当的视图。一个操作,简单来说,就是一个以action开头的控制器类方法。

将这些连接在一起:Yii 请求路由

在 MVC 实现中,Web 请求通常具有以下生命周期:

  • 浏览器向托管 MVC 应用程序的服务器发送请求

  • 调用控制器操作来处理请求

  • 控制器与模型交互

  • 控制器调用视图

  • 视图呈现数据(通常为 HTML)并将其返回给浏览器显示

Yii 的 MVC 实现也不例外。在 Yii 应用程序中,来自浏览器的传入请求首先由路由器接收。路由器分析请求以决定应将其发送到应用程序的何处进行进一步处理。在大多数情况下,路由器会识别控制器类中的特定操作方法,将请求传递给该方法。这个操作方法将查看传入的请求数据,可能与模型交互,并执行其他所需的业务逻辑。最终,这个操作方法将准备好响应数据并发送给视图。视图将格式化这些数据以符合所需的布局和设计,并返回给浏览器显示。

博客发布示例

为了更好地理解所有这些,让我们看一个虚构的例子。假设我们使用 Yii 构建了一个新的博客网站http://yourblog.com。这个网站与大多数典型的博客网站类似。主页显示最近发布的博客文章列表。每篇博客文章的名称都是超链接,可以将用户带到显示完整文章的页面。以下图表说明了 Yii 如何处理从点击这些假想博客文章链接发送的传入请求:

博客发布示例

该图表跟踪了用户点击链接时发出的请求:http://yourblog.com/post/show/id/99

首先,请求被发送到路由器。路由器解析请求,决定将其发送到何处。URL 的结构对路由器将做出的决定至关重要。默认情况下,Yii 识别以下格式的 URL:

http://hostname/index.php?r=ControllerID/ActionID

r查询字符串变量指的是 Yii 路由器分析的路由。它将解析此路由以确定适当的控制器和操作方法,以进一步处理请求。现在您可能立即注意到我们上面的示例 URL 不遵循此默认格式。这只是一个非常简单的配置应用程序以识别以下格式的 URL:

http://hostname/ControllerID/ActionID

我们将继续使用这种简化的格式来进行示例。URL 中的ControllerID名称指的是控制器的名称。默认情况下,这是控制器类名称的第一部分,直到单词Controller。例如,如果您的控制器类名称是TestControllerControllerID名称将是testActionID类似地指的是由控制器定义的操作的名称。如果操作是在控制器内定义的简单方法,那么它将是方法名称中跟在单词action后面的任何内容。例如,如果您的操作方法名为actionCreate()ActionID名称就是create

注意

如果省略了ActionID,控制器将采取默认操作,按照约定是控制器中称为actionIndex()的方法。如果还省略了ControllerID,应用程序将使用默认控制器。Yii 默认控制器称为SiteController

回到示例,路由器将分析 URLhttp://yourblog.com/post/show/id/99,并将 URL 路径的第一部分post作为ControllerID,将第二部分show作为ActionID。这将转换为将请求路由到PostController类中的actionShow()方法。URL 的最后部分,id/99部分,是一个名称/值查询字符串参数,在处理过程中将可用于该方法。在这个示例中,数字99代表所选博客文章的唯一内部 ID。

在我们虚构的博客应用程序中,actionShow()方法处理特定博客文章条目的请求。它使用查询字符串变量id来确定请求的特定文章。它要求模型检索有关博客文章条目编号 99 的信息。模型 AR 类与数据库交互以检索所请求的数据。在从模型检索数据后,我们的控制器通过使其可用于视图来进一步准备数据以供显示。视图然后负责处理数据布局,并向浏览器提供响应以供用户显示。

这种 MVC 架构允许我们将数据呈现与数据操作、验证和其他应用程序业务逻辑分开。这使得开发人员非常容易改变应用程序的各个方面而不影响 UI,UI 设计人员也可以自由地进行更改而不影响模型或业务逻辑。这种分离还使得非常容易提供同一模型代码的多个呈现方式。例如,您可以使用驱动http://yourblog.com的 HTML 布局的相同模型代码来驱动 RIA 呈现、移动应用程序、Web 服务或命令行界面。最终,遵循这些约定并分离功能将导致一个更容易扩展和维护的应用程序。

Yii 做了很多工作来帮助您执行这种分离,不仅仅提供一些命名约定和代码放置建议。它有助于处理所有需要将所有部分粘合在一起的低级"胶水"代码。这使您能够在不必自己编写所有细节的情况下获得严格的 MVC 设计应用程序的好处。让我们来看看其中一些低级细节。

对象关系映射和 Active Record

在很大程度上,我们构建的 Web 应用程序将其数据存储在关系数据库中。我们在上一个示例中使用的博客帖子应用程序将博客帖子内容存储在数据库表中。然而,Web 应用程序需要将持久数据库存储中的数据映射到定义域对象的内存类属性中。对象关系映射ORM)库提供了将数据库表映射到域对象类的功能。

处理 ORM 的大部分代码都是关于描述数据库中的字段如何对应到我们内存对象的属性,并且编写起来是乏味和重复的。幸运的是,Yii 通过提供 Active Record(AR)模式的 ORM 层来拯救我们,使我们免受这种重复和乏味。

Active Record

如前所述,AR 是一种用于以面向对象的方式抽象数据库访问的设计模式。它将表映射到类,行映射到对象,列映射到类属性。换句话说,每个 Active Record 类的实例代表数据库表中的一行。然而,AR 类不仅仅是一组属性,这些属性映射到数据库表中的列。它还包含应用于该数据的必要业务逻辑。最终结果是一个定义了如何写入和从数据库中读取的类。

通过依赖约定并坚持合理的默认设置,Yii 对 AR 的实现将节省开发人员大量时间,否则可能会花在配置上,或者编写创建、读取、更新和删除数据所需的乏味重复的 SQL 语句上。它还允许开发人员以面向对象的方式访问存储在数据库中的数据。为了说明这一点,让我们再次以我们虚构的博客示例为例。以下是一些使用 AR 操作特定博客帖子的示例代码,其内部 ID(也用作表的主键)为99。它首先通过使用主键检索帖子。然后更改标题并更新数据库以保存更改:

$post=Post::model()->findByPk(99);
$post->title='Some new title';
$post->save();

Active Record 完全解除了我们编写任何 SQL 代码或以其他方式处理底层数据库的乏味。

实际上,Yii 中的 Active Record 甚至做得更多。它与 Yii 框架的许多其他方面无缝集成。有许多"活动"HTML 助手输入表单字段直接与它们各自的 AR 类属性相关联。通过这种方式,AR 直接提取输入表单字段的值到模型中。它还支持复杂的自动数据验证,如果验证失败,Yii 视图类可以轻松地将验证错误显示给最终用户。我们将在本书中多次重新访问 AR 并提供具体示例。

视图和控制器

视图和控制器非常密切相关。控制器使数据可供视图显示,视图生成页面触发事件,将数据发送到控制器。

在 Yii 中,视图文件属于呈现它的控制器类。通过这种方式,我们可以在视图脚本中简单地引用$this来访问控制器实例。这种实现方式使视图和控制器非常密切。

当涉及到 Yii 控制器时,故事远不止调用模型和渲染视图那么简单。控制器可以管理服务,以提供对请求的复杂预处理和后处理,实现基本的访问控制规则以限制对某些操作的访问,管理应用程序范围的布局和嵌套布局文件的渲染,管理数据的分页,以及许多其他幕后服务。再次感谢 Yii,让我们不必为这些混乱的细节而费心。

Yii 有很多内容。探索它所有美丽的最佳方式就是开始使用它。现在我们已经掌握了一些非常基本的想法和术语,我们有很好的条件来做到这一点。

总结

在本章中,我们在很高的层次上介绍了 Yii PHP Web 应用程序框架。我们还涵盖了 Yii 所采用的许多软件设计概念。如果你对这次初步讨论的抽象性有些困惑,不要担心。一旦我们深入具体的例子,一切都会变得清晰起来。但总结一下,我们具体涵盖了:

  • 应用程序开发框架的重要性和实用性

  • Yii 是什么,以及使 Yii 变得非常强大和有用的特点。

  • MVC 应用程序架构以及在 Yii 中实现此架构

  • 典型的 Yii Web 请求生命周期和 URL 结构

  • Yii 中的对象关系映射和 Active Record

在下一章中,我们将通过简单的 Yii 安装过程,并开始构建一个工作应用程序,以更好地阐述所有这些想法。

第二章:入门

通过简单地使用 Yii,我们很快就能发现 Yii 的真正乐趣和好处。在本章中,我们将看到在一个示例 Yii 应用程序中,前一章介绍的概念是如何体现的。遵循 Yii 的约定优于配置的理念,我们将按照标准约定开始编写一个 Yii 中的“Hello, World!”程序。

在本章中,我们将涵盖:

  • Yii 框架安装

  • 创建一个新的应用程序

  • 创建控制器和视图

  • 向视图文件添加动态内容

  • Yii 请求路由和链接页面

我们的第一步是安装框架。现在让我们来做吧。

安装 Yii

在安装 Yii 之前,您必须将应用程序开发环境配置为支持 PHP 5.1.0 或更高版本的 Web 服务器。Yii 已经在 Windows 和 Linux 操作系统上的 Apache HTTP 服务器上进行了彻底测试。它也可以在支持 PHP 5 的其他 Web 服务器和平台上运行。我们假设读者以前已经参与过 PHP 开发,并且可以访问或者知道如何设置这样的环境。我们将把 Web 服务器和 PHP 本身的安装留给读者自己去练习。

注意

一些流行的安装包包括

基本的 Yii 安装几乎是微不足道的。实际上只有两个必要的步骤:

  1. www.yiiframework.com/download/下载 Yii 框架。

  2. 将下载的文件解压到可通过 Web 访问的目录。在下载框架时,可以选择几个版本的 Yii。在本书的目的中,我们将使用 1.1.12 版本,这是写作时的最新稳定版本。虽然大多数示例代码应该适用于任何 1.1.x 版本的 Yii,但如果您使用不同版本可能会有一些细微差异。如果您正在跟随示例,请尝试使用 1.1.12 版本。

在下载了框架文件并将其解压到可通过 Web 访问的目录后,列出其内容。您应该看到以下高级目录和文件:

  • CHANGELOG

  • LICENSE

  • README

  • UPGRADE

  • demos/

  • framework/

  • requirements/

现在我们已经在可通过 Web 访问的目录中解压了我们的框架,建议您验证服务器是否满足使用 Yii 的所有要求,以确保安装成功。幸运的是,这样做非常容易。Yii 带有一个简单的要求检查工具。要使用该工具并让其验证您的安装要求,只需将浏览器指向所下载文件中的requirements/目录下的index.php入口脚本。例如,假设包含所有框架文件的目录的名称只是叫做yii,那么访问要求检查器的 URL 可能如下所示:

http://localhost/yii/requirements/index.php

以下屏幕截图显示了我们配置的结果:

安装 Yii

使用要求检查器本身并不是安装的要求。但建议使用它来确保正确安装。正如您所看到的,我们在详细部分的结果并非全部都是通过状态,有些显示警告结果。当然,您的配置很可能与我们的略有不同,因此您的结果也可能略有不同。这没关系。并不是所有详细部分的检查都必须通过,但是必须在结论部分收到以下消息:您的服务器配置满足 Yii 的最低要求

提示

Yii 框架文件不需要被放置在公开访问的 web 目录中,建议不要这样做。我们在这里这样做只是为了快速利用浏览器中的要求检查器。Yii 应用程序有一个入口脚本,通常是唯一需要放置在 web 根目录中的文件(web 根目录指的是包含index.php入口脚本的目录)。其他 PHP 脚本,包括所有的 Yii 框架文件,应该受到保护,以避免安全问题。只需在入口脚本中引用包含 Yii 框架文件的目录,并将这些文件放在 web 根目录之外。

安装数据库

在本书中,我们将使用数据库来支持许多示例和我们将要编写的应用程序。为了正确地跟随本书,建议你安装一个数据库服务器。虽然你可以使用 Yii 支持的任何数据库,如果你想使用 Yii 内置的数据库抽象层和工具,就像我们将要使用的那样,你需要使用框架支持的数据库。截至 1.1 版本,支持的数据库有:

  • MySQL 4.1 或更高版本

  • PostgresSQL 7.3 或更高版本

  • SQLite 2 和 3

  • Microsoft SQL Server 2000 或更高版本

  • Oracle

提示

虽然你可以使用任何受支持的数据库服务器来跟随本书中的所有示例,但我们将在所有示例中使用 MySQL(具体来说是 5.1)作为我们的数据库服务器。建议你也使用 MySQL,版本为 5 或更高,以确保所提供的示例可以正常工作而无需进行调整。在本章中,我们的简单的“Hello, World!”应用程序不需要数据库。

现在我们已经安装了框架并验证了我们已满足最低要求,让我们继续创建一个全新的 Yii web 应用程序。

创建一个新应用程序

为了创建一个新的应用程序,我们将使用一个随框架捆绑的强大工具,称为yiic。这是一个命令行工具,你可以用它快速引导一个全新的 Yii 应用程序。使用这个工具并不是强制的,但它可以节省时间,并保证应用程序有一个正确的目录和文件结构。

要使用这个工具,打开你的命令行,并导航到你的文件系统中你想要创建应用程序目录结构的地方。为了这个演示应用程序的目的,我们假设以下情况:

  • YiiRoot是你安装 Yii 框架文件的目录的名称

  • WebRoot被配置为你的 web 服务器的文档根目录

从命令行中,切换到你的WebRoot目录并执行yiic命令:

% cd WebRoot
% YiiRoot/framework/yiic webapp helloworld
   Create a Web application under '/Webroot/helloworld'? [Yes|No] 
   Yes 
      mkdir /WebRoot/helloworld
      mkdir /WebRoot/helloworld/assets
      mkdir /WebRoot/helloworld/css
   generate css/bg.gif
   generate css/form.css
   generate css/main.css

Your application has been created successfully under /Webroot/helloworld.

注意

yiic命令可能不会按预期工作,特别是如果你尝试在 Windows 环境中使用它。yiic文件是一个可执行文件,使用你的命令行版本的 PHP 来运行。它调用yiic.php脚本。你可能需要在前面使用php来完全限定,如$ php yiic$ php yiic.php。你可能还需要指定要使用的 PHP 可执行文件,比如C:\PHP5\php.exe yiic.php。还有yiic.bat文件,它执行yiic.php文件,可能更适合 Windows 用户。你可能需要确保你的 PHP 可执行文件位置在你的%PATH%变量中是可访问的。请尝试这些变化,找到适合你计算机配置的解决方案。我将继续简单地称这个命令为yiic

yiic webapp命令用于创建一个全新的 Yii web 应用程序。它只需要一个参数来指定应用程序应该被创建的目录的绝对或相对路径。结果是生成所有必要的目录和文件,用于提供默认 Yii web 应用程序的框架。

让我们列出我们的新应用程序的内容,看看为我们创建了什么:

assets/    images/    index.php  themes/
css/    index-test.php    protected/

以下是这些高级项目的描述,这些项目是自动创建的:

  • index.php: Web 应用程序入口脚本文件

  • index-test.php: 用于加载测试配置的入口脚本文件

  • assets/: 包含发布的资源文件

  • css/: 包含 CSS 文件

  • images/: 包含图像文件

  • themes/: 包含应用程序主题

  • protected/: 包含受保护的(非公开的)应用程序文件

通过一条简单的命令行命令的执行,我们已经创建了所有所需的目录结构和文件,以立即利用 Yii 的合理默认配置。这些目录和文件,以及它们包含的子目录和文件,乍一看可能有点令人生畏。然而,我们在开始时可以忽略大部分内容。重要的是要注意,所有这些目录和文件实际上都是一个工作的 Web 应用程序。yiic命令已经填充了应用程序足够的代码,以建立一个简单的首页,一个典型的联系我们页面,以提供一个 Web 表单的示例,以及一个登录页面,以演示 Yii 中的基本授权和认证。如果您的 Web 服务器支持 GD2 图形库扩展,您还将在联系我们表单上看到一个 CAPTCHA 小部件,并且应用程序将对该表单字段进行相应的验证。

只要您的 Web 服务器正在运行,您就应该能够打开浏览器并导航到http://localhost/helloworld/index.php。在这里,您将看到一个我的 Web 应用程序首页,以及友好的问候语欢迎来到我的 Web 应用程序,接着是一些有用的下一步信息。以下截图显示了这个示例首页:

创建一个新应用程序

注意

您需要确保assets/protected/runtime/目录对您的 Web 服务器进程是可写的,否则您可能会看到一个错误而不是工作应用程序。

您会注意到页面顶部有一个可用的应用程序导航栏。从左到右依次是主页关于联系登录。点击并探索。点击关于链接提供了一个静态页面的简单示例。联系链接将带您到之前提到的联系我们表单,以及表单中的 CAPTCHA 输入字段。(再次强调,只有在您的 PHP 配置中有gd图形扩展时,您才会看到 CAPTCHA 字段。)

登录链接将带您到显示登录表单的页面。这是一个带有表单验证的工作代码,以及用户名和密码的验证和认证。使用demo/demoadmin/admin作为用户名/密码组合将使您登录到网站。试试看!您可以尝试一个将失败的登录(除了 demo/demo 或 admin/admin 之外的任何组合),并查看错误验证消息的显示。成功登录后,页眉中的登录链接将更改为注销链接(用户名),其中用户名是 demo 或 admin,具体取决于您用于登录的用户名。令人惊讶的是,所有这些都可以在不编写任何代码的情况下完成。

"你好,世界!"

一旦我们通过一个简单的示例走过,所有这些生成的代码将开始变得更加清晰。为了尝试这个新系统,让我们构建在本章开头承诺的“你好,世界!”程序。在 Yii 中,“你好,世界!”程序将是一个向我们的浏览器发送这条非常重要消息的简单 Web 页面应用程序。

如在第一章中讨论的那样,遇见 Yii,Yii 是一个模型-视图-控制器框架。一个典型的 Yii web 应用程序接收用户的传入请求,处理该请求中的信息以创建一个控制器,然后调用该控制器中的一个动作。控制器可以调用特定的视图来渲染并返回响应给用户。如果涉及数据,控制器还可以与模型交互,处理数据的所有CRUD创建,读取,更新,删除)操作。在我们简单的“你好,世界!”应用程序中,我们只需要控制器和视图的代码。我们不涉及任何数据,因此不需要模型。让我们通过创建我们的控制器来开始我们的示例。

创建控制器

以前,我们使用yiic webapp命令来帮助我们生成一个新的 Yii web 应用程序。为了为我们的“你好,世界!”应用程序创建一个新的控制器,我们将使用 Yii 提供的另一个实用工具。这个工具叫做 Gii。Gii是一个高度可定制和可扩展的基于 Web 的代码生成平台。

配置 Gii

在使用 Gii 之前,我们必须在应用程序中对其进行配置。我们在位于protected/config/main.php的主应用程序配置文件中进行配置。要配置 Gii,打开此文件并取消注释gii模块。我们的自动生成的代码已经添加了gii配置,但它被注释掉了。因此,我们只需要取消注释,然后还要添加我们自己的密码,如下面的代码片段所示:

return array(
  'basePath'=>dirname(__FILE__).DIRECTORY_SEPARATOR.'..',
  'name'=>'My Web Application',

  // preloading 'log' component
  'preload'=>array('log'),

  // autoloading model and component classes
  'import'=>array(
    'application.models.*',
    'application.components.*',
  ),

 **'modules'=>array(**
 **// uncomment the following to enable the Gii tool**
 **/***
 **'gii'=>array(**
 **'class'=>'system.gii.GiiModule',**
 **'password'=>'Enter Your Password Here',**
 **// If removed, Gii defaults to localhost only. Edit carefully to taste.**
 **'ipFilters'=>array('127.0.0.1','::1'),**
 **),**
 ***/**
 **),**

取消注释后,Gii 被配置为一个应用程序模块。我们将在本书后面详细介绍 Yii 模块。此时的重要事情是确保将其添加到配置文件中,并提供您的密码。有了这个配置,通过http://localhost/helloworld/index.php?r=gii导航到工具。

注意

实际上,您可以将密码值指定为false,然后模块将不需要密码。由于 ipFilters 属性被指定为仅允许访问本地主机,因此在本地开发环境中将密码设置为false是安全的。

好的,在成功输入密码后(除非您指定不使用密码),您将看到列出 Gii 主要功能的菜单页面:

配置 Gii

Gii 在左侧菜单中列出了几个代码生成选项。我们想要创建一个新的控制器,所以点击控制器生成器菜单项。

这样做将带我们到一个表单,允许我们填写相关细节以创建一个新的 Yii 控制器类。在下面的屏幕截图中,我们已经填写了控制器 ID值为message,并且我们添加了一个我们称之为helloAction ID值。下面的屏幕截图还反映了我们已经点击了预览按钮。这显示了将与我们的控制器类一起生成的所有文件:

配置 Gii

我们可以看到,除了我们的MessageController类之外,Gii 还将为我们指定的每个 Action ID 创建一个视图文件。您可能还记得第一章中提到的,如果message控制器 ID,我们对应的类文件名为MessageController。同样,如果我们提供了helloAction ID值,我们期望在控制器中有一个名为actionHello的方法。

您还可以单击预览选项中提供的链接,以查看将为每个文件生成的代码。继续并查看它们。一旦您对即将生成的内容感到满意,请点击生成按钮。您应该收到一条消息,告诉您控制器已成功创建,并附有立即尝试的链接。如果您收到错误消息,请确保controllers/views/目录对您的 Web 服务器进程是可写的。

单击立即尝试链接实际上会将我们带到一个404 页面未找到错误页面。原因是我们在创建新控制器时没有指定默认的 actionID index。我们决定将我们的称为hello。为了使请求路由到我们的actionHello()方法,我们只需要将 actionID 添加到 URL 中。如下截图所示:

配置 Gii

现在它显示了调用MessageController::actionHello()方法的结果。

这很棒。在 Gii 的帮助下,我们生成了一个名为MessageController.php的新控制器 PHP 文件,并将其正确放置在默认控制器目录protected/controllers/下。生成的MessageController类扩展了一个名为Controller的应用基类,位于protected/components/Controller.php中,而这个类又扩展了基础框架类CController。由于我们指定了 actionID hello,因此在MessageController中还创建了一个名为actionHello()的简单操作。Gii 还假定,像大多数由控制器定义的操作一样,此操作将需要呈现一个视图。因此,它添加了呈现同名视图文件hello.php的代码到此方法中,并将其放置在默认目录protected/views/message/中,用于与此控制器相关的视图文件。以下是为MessageController类生成的未注释部分代码:

<?php
class MessageController extends Controller
{
        public function actionHello()
        {
                $this->render('hello');
        }

正如我们所看到的,由于我们在使用 Gii 创建此控制器时没有指定'index'作为 actionID 之一,因此没有actionIndex()方法。正如在第一章中讨论的那样,按照约定,指定控制器 ID 为消息,但未指定操作的请求将被路由到actionIndex()方法进行进一步处理。这就是为什么我们最初看到 404 错误的原因,因为请求没有指定 actionID。

让我们花点时间来修复这个问题。正如我们所提到的,Yii 更青睐于约定而不是配置,并且几乎所有内容都有合理的默认值。同时,几乎所有内容也是可配置的,默认控制器操作也不例外。通过在我们的MessageController顶部添加一行简单的代码,我们可以将actionHello()方法定义为默认操作。在MessageController类的顶部添加以下行:

<?php

class MessageController extends Controller
{
 **public $defaultAction = 'hello';**

提示

下载示例代码

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

尝试通过导航到http://localhost/helloworld/index.php?r=message来测试。您应该仍然看到显示hello action页面,不再看到错误页面。

最后一步

要将其转换为“Hello, World!”应用程序,我们只需要自定义我们的hello.php视图以显示“Hello, World!”。这样做很简单。编辑文件protected/views/message/hello.php,使其只包含以下代码:

<?php
<h1>Hello World!</h1> 

保存它,并在浏览器中再次查看:http://localhost/helloworld/index.php?r=message

现在它显示了我们的介绍性问候,如下截图所示:

最后一步

我们的简单应用程序只需极少的代码就可以运行。我们只需在hello.php视图文件中添加了一行 HTML。

注意

您可能想知道所有其他 HTML 是如何/在哪里生成的。我们基本的hello.php视图文件只包含一个带有<h1>标签的单行。当我们在控制器中调用render()时,也会应用布局视图文件。现在不需要太担心这一点,因为我们将在以后更详细地介绍布局。但是如果您感兴趣,您可以查看protected/views/layouts/目录,看看已经定义的布局文件,并帮助您了解其他 HTML 的定义位置。

审查我们的请求路由

让我们回顾一下 Yii 如何在这个示例应用程序的上下文中分析我们的请求:

  1. 通过将浏览器指向 URL http://localhost/helloworld/index.php?r=message(或者您可以使用等效的 URL http://localhost/helloworld/index.php?r=message/hello)来导航到“Hello, World!”页面。

  2. Yii 分析 URL。r(路由)查询字符串变量表示 controllerID 是message。这告诉 Yii 将请求路由到 message 控制器类,它在protected/controllers/MessageController.php中找到。

  3. Yii 还发现指定的 actionID 是hello。(或者如果没有指定 actionID,它会路由到控制器的默认动作。)因此,在MessageController中调用actionHello()方法。

  4. actionHello()方法呈现位于protected/views/message/hello.phphello.php视图文件。我们修改了这个视图文件,只是简单地显示我们的问候语,然后返回给浏览器。

这一切都是非常轻松地组合在一起的。通过遵循 Yii 的默认约定,整个应用程序请求路由已经无缝地为我们拼接在一起。当然,Yii 给了我们每一个机会来覆盖这个默认的工作流程,但是你越是遵循约定,你就会花费越少的时间在调整配置代码上。

添加动态内容

向我们的视图模板添加动态内容的最简单方法是将 PHP 代码嵌入到模板本身中。视图文件由我们的简单应用程序呈现为 HTML,这些文件中的任何基本文本都会被传递而不会被更改。但是,任何在<?php?>之间的内容都会被解释和执行为 PHP 代码。这是 PHP 代码嵌入 HTML 文件的典型方式,可能对您来说很熟悉。

添加日期和时间

为了给我们的页面增添动态内容,让我们显示日期和时间:

  1. 再次打开 hello 视图,并在问候文本下面添加以下行:
<h3><?php echo date("D M j G:i:s T Y"); ?></h3>
  1. 保存和查看:http://localhost/helloworld/index.php?r=message/hello

哎呀!我们已经在我们的应用程序中添加了动态内容。每次刷新页面,我们都可以看到显示的内容在变化。

诚然,这并不是非常令人兴奋,但它确实向您展示了如何将简单的 PHP 代码嵌入到我们的视图模板中。

添加日期和时间的另一种方法

尽管这种直接将 PHP 代码嵌入视图文件的方法允许任意数量或复杂度的 PHP 代码,但强烈建议这些语句不要改变数据模型,并保持简单、面向显示的语句。这将有助于将我们的业务逻辑与我们的呈现代码分开,这是使用 MVC 架构的好处之一。

将数据创建移到控制器

让我们将创建时间的逻辑移回到控制器,并且让视图什么都不做,只是显示时间。我们将时间的确定放到控制器中的actionHello()方法中,并在一个名为$time的实例变量中设置值。

首先让我们修改控制器动作。目前我们在MessageController中的动作actionHello(),只是通过执行以下代码来调用渲染我们的 hello 视图:

$this->render('hello'); 

在我们渲染视图之前,让我们添加调用来确定时间,然后将其存储在一个名为$theTime的局部变量中。然后我们通过添加第二个参数来修改我们对render()的调用,其中包括这个变量:

$theTime = date("D M j G:i:s T Y");
$this->render('hello',array('time'=>$theTime)); 

当调用render()并带有包含数组数据的第二个参数时,它将把数组的值提取到 PHP 变量中,并使这些变量可用于视图脚本。数组中的键将是可用于我们视图文件的变量的名称。因此,在这个例子中,我们的数组键'time',其值是$theTime,将被提取到一个名为$time的变量中,并在视图中可用。这是一种从控制器传递数据到视图的方法。

注意

这假设您正在使用 Yii 的默认视图渲染器。正如之前多次提到的,Yii 允许您自定义几乎所有内容,如果您愿意,您可以指定不同的视图渲染实现。其他视图渲染可能不会以完全相同的方式行事。

现在让我们修改视图,使用这个$time变量而不是直接调用日期函数本身:

  1. 再次打开 HelloWorld 视图文件,并用以下内容替换我们之前添加的用于输出时间的行:
<h3><?php echo $time; ?></h3>
  1. 再次保存并查看结果:http://localhost/helloworld/index.php?r=message/hello

我们再次看到时间显示与之前完全相同,因此两种方法的最终结果没有任何不同。

我们已经演示了向视图模板文件添加 PHP 生成内容的两种方法。第一种方法将数据创建逻辑直接放入视图文件本身。第二种方法将这个逻辑放在控制器类中,并通过使用变量将信息传递给视图文件。最终结果是相同的;时间显示在我们渲染的 HTML 中,但第二种方法在保持数据获取和处理(即业务逻辑)与我们的呈现代码分离方面迈出了一小步。这种分离正是模型-视图-控制器架构努力提供的,Yii 的显式目录结构和合理的默认值使其易于实现。

你有在关注吗?

在第一章中提到过,视图和控制器确实是非常相似的。在视图文件中,$this指的是渲染视图的Controller类。

在前面的例子中,我们通过在 render 方法中使用第二个参数,明确地从控制器向视图文件提供了时间。这第二个参数明确地设置了立即可用于视图文件的变量。但是还有另一种方法可以尝试一下。

通过在MessageController上定义一个公共类属性,而不是一个局部作用域的变量,其值是当前日期时间,来修改前面的例子。然后通过$this访问这个类属性,在视图文件中显示时间。

注意

可下载的代码库中包含了这个“自己动手”的练习的解决方案。

链接页面

典型的 Web 应用程序中有多个页面供用户体验,我们简单的应用程序也不例外。让我们添加另一个页面,显示来自世界的响应,“再见,Yii 开发者!”并从我们的“Hello, World!”页面链接到这个页面,反之亦然。

通常,在 Yii web 应用程序中,每个渲染的 HTML 页面都对应一个单独的视图(尽管这并不总是必须的)。因此,我们将创建一个新视图,并使用一个单独的操作方法来渲染这个视图。在添加新页面时,我们还需要考虑是否使用单独的控制器。由于我们的 Hello 和 Goodbye 页面是相关的并且非常相似,目前没有必要将应用程序逻辑委托给单独的控制器类。

链接到新页面

让我们的新页面的 URL 形式为http://localhost/helloworld/index.php?r=message/goodbye

遵循 Yii 的约定,这个决定定义了我们的操作方法的名称,我们需要在控制器中使用,以及我们的视图的名称。因此,打开MessageController并在我们的actionHello()操作的下面添加一个actionGoodbye()方法:

class MessageController extends Controller
{
  ...

  public function actionGoodbye()
  {
    $this->render('goodbye');
  }

    ...
}

接下来,我们需要在/protected/views/message/目录中创建我们的视图文件。这应该被称为goodbye.php,因为它应该与我们选择的 actionID 相同。

注意

请记住,这只是一个推荐的约定。视图不一定必须与操作具有相同的名称。视图文件名只需与render()的第一个参数匹配即可。

在该目录中创建一个空文件,并添加一行:

<h1>Goodbye, Yii developer!</h1>      

再次保存和查看http://localhost/helloworld/index.php?r=message/goodbye将显示再见消息。

现在我们需要添加链接来连接这两个页面。要在 Hello 页面上添加到 Goodbye 页面的链接,我们可以直接在hello.php视图文件中添加<a>标签,并硬编码 URL 结构如下:

<a href="/helloworld/index.php?r=message/goodbye">Goodbye!</a>

这样做可以,但它将视图代码实现紧密耦合到特定的 URL 结构,这可能在某个时候发生变化。如果 URL 结构发生变化,这些链接将变得无效。

注意

还记得在第一章 遇见 Yii中,我们通过博客发布应用程序示例吗?我们使用的 URL 格式与 Yii 默认格式不同,更符合 SEO,即:

http://yourhostname/controllerID/actionID

将 Yii Web 应用程序配置为使用这种“路径”格式而不是我们在此示例中使用的查询字符串格式是一件简单的事情。能够轻松更改 URL 格式对 Web 应用程序非常重要。只要我们避免在整个应用程序中硬编码它们,更改它们将保持简单,只需更改应用程序配置文件即可。

从 Yii CHtml 获得一点帮助

幸运的是,Yii 在这里提供了帮助。Yii 带有许多可以在视图模板中使用的辅助方法。这些方法存在于静态 HTML 辅助框架类CHtml中。在这种情况下,我们想要使用的是“link”辅助方法,它接受一个controllerID/actionID对,并根据应用程序配置的 URL 结构为您创建适当的超链接。由于所有这些辅助方法都是静态的,我们可以直接调用它们,而无需创建CHtml类的显式实例。使用这个链接助手,我们可以在我们的hello.php视图中在我们输出时间的下面添加一个链接,如下所示:

<p><?php echo CHtml::link('Goodbye'array('message/goodbye')); ?></p>  

保存并查看“Hello, World!”页面:http://localhost/helloworld/index.php?r=message/hello

您应该看到超链接,并单击它应该将您带到再见页面。调用link方法的第一个参数是将显示在超链接中的文本。第二个参数是一个包含我们的controllerID/actionID对值的数组。

我们可以采用相同的方法在我们的 Goodbye 视图中放置一个相互链接:

<h1>Goodbye, Yii developer!</h1>      
<p><?php echo CHtml::link('Hello',array('message/hello')); ?></p>  

保存并查看再见页面:

http://localhost/helloworld/index.php?r=message/goodbye

现在,您应该看到从再见页面返回到“Hello, World!”页面的活动链接。

所以我们现在知道了在我们的简单应用程序中链接网页的几种方法。一种方法是直接在视图文件中添加 HTML <a>标签,并硬编码 URL 结构。另一种更常用的方法是利用 Yii 的CHtml辅助类来帮助构建基于controllerID /actionID对的 URL,以便结果格式始终符合应用程序配置。通过这种方式,我们可以轻松地在整个应用程序中更改 URL 格式,而无需返回更改每个视图文件,这些文件恰好具有内部链接。

我们简单的“Hello, World!”应用程序真正受益于 Yii 的约定优于配置的理念。通过应用某些默认行为并遵循推荐的约定,这个简单应用程序的构建和整个请求路由过程都以非常简单和方便的方式完成了。

总结

在本章中,我们构建了一个极其简单的应用程序,以涵盖许多主题。首先我们安装了框架。然后我们使用yiic控制台命令来引导创建一个新的 Yii 应用程序。然后我们介绍了一个非常强大的代码生成工具叫做 Gii。我们使用它在我们的简单应用程序中创建了一个新的控制器。

一旦我们的应用程序就位,我们就可以亲自看到 Yii 如何处理请求和路由到控制器和动作。然后,我们继续创建和显示非常简单的动态内容。最后,我们看了一下如何在 Yii 应用程序中链接页面。

虽然这个非常简单的应用程序为我们提供了具体的例子,帮助我们更好地理解 Yii 框架的使用,但它过于简单,无法展示 Yii 在简化实际应用程序构建方面的能力。为了证明这一点,我们需要构建一个真实的 Web 应用程序。我们将会这样做。在下一章中,我们将向您介绍项目任务和问题跟踪应用程序,我们将在本书的其余部分中构建该应用程序。

第三章:TrackStar 应用程序

我们可以继续不断向我们简单的“Hello, World!”应用程序添加 Yii 的功能示例,但这并不会真正帮助理解框架在真实应用程序的上下文中。为了做到这一点,我们需要朝着更接近 Web 开发人员实际需要构建的应用程序类型的方向发展。这正是我们将在本书的其余部分中要做的事情。

在本章中,我们将介绍名为 TrackStar 的项目任务跟踪应用程序。世界上有许多其他项目管理和问题跟踪应用程序,我们的基本功能与许多这些应用程序并无不同。那么,为什么要构建它呢?事实证明,这种基于用户的应用程序具有许多对许多 Web 应用程序都是常见的功能。这将使我们能够实现两个主要目标:

  • 展示 Yii 作为我们构建有用功能和征服真实世界 Web 应用程序挑战的不可思议的实用性和功能集

  • 提供现实世界的示例和方法,这些方法将立即适用于您的下一个 Web 应用程序项目

介绍 TrackStar

TrackStar 是一个软件开发生命周期(SDLC)问题管理应用程序。它的主要目标是帮助跟踪在构建软件应用程序过程中出现的许多问题。它是一个基于用户的应用程序,允许创建用户帐户并在用户经过身份验证和授权后访问应用程序功能。它允许用户添加和管理项目。

项目可以与其关联的用户(通常是项目上工作的团队成员)以及问题相关联。项目问题将是开发任务和应用程序错误等事物。问题可以分配给项目的成员,并且将具有尚未开始已开始已完成等状态。通过这种方式,跟踪工具可以准确描述项目的情况,包括已完成的工作,当前正在进行的工作以及尚未开始的工作。

创建用户故事

简单的用户故事是识别应用程序必要功能功能的好方法。用户故事以最简单的形式陈述用户可以使用软件做什么。它们应该从简单开始,并随着您深入了解每个功能周围的细节而变得更加复杂。我们的目标是从足够的复杂性开始,以便我们可以开始。如果有必要,我们将稍后添加更多细节。

我们简要介绍了在这个应用程序中扮演重要角色的三个主要实体,即用户项目问题。这些是我们的主要领域对象,在这个应用程序中非常重要。所以让我们从它们开始。

用户

TrackStar 是一个基于用户的 Web 应用程序。在高层次上,用户可以处于两种用户状态中的一种。

  • 匿名

  • 经过身份验证

匿名用户是应用程序的任何未经过登录过程认证的用户。匿名用户只能访问注册新帐户或登录。所有其他功能将受限于经过身份验证的用户。

经过身份验证的用户是通过登录过程提供有效身份验证凭据的用户。换句话说,经过身份验证的用户是已登录的用户。经过身份验证的用户将可以访问应用程序的主要功能功能,如创建和管理项目以及项目问题。

项目

管理项目是 TrackStar 应用程序的主要目的。项目代表一个由应用程序的一个或多个用户实现的一般高层目标。项目通常被分解为更细粒度的任务或问题,这些任务或问题代表需要采取的更小步骤以实现整体目标。

举个例子,让我们以本书中将要做的事情为例,即构建一个项目和问题跟踪管理应用程序。不幸的是,我们无法使用尚未创建的应用程序来帮助我们跟踪其自身的开发,但如果可以的话,我们可能会创建一个名为“构建 TrackStar 项目/问题管理工具”的项目。该项目将被细分为更详细的项目问题,例如“创建登录界面”,“为问题设计数据库模式”等等。

经过身份验证的用户可以创建新项目。账户内项目的创建者将在该项目中拥有称为项目所有者的特殊角色。项目所有者有权编辑和删除这些项目,以及向项目添加新成员。除项目所有者之外与项目相关的其他用户简称为项目成员。项目成员将有添加新问题以及编辑现有问题的权限。

问题

项目问题将被分类为三个类别之一:

  • 特性:代表要添加到应用程序中的实际功能的项目。例如,登录功能的实施。

  • 任务:代表需要完成的工作,但不是软件的实际功能。例如,设置构建和集成服务器。

  • 错误:代表应用程序行为不如预期工作的项目。例如,账户注册表格未验证输入电子邮件地址的格式。

问题可以处于以下三种状态之一:

  • 尚未开始

  • 已开始

  • 已完成

项目成员可以向项目添加新问题,以及编辑和删除它们。他们可以将问题分配给自己或其他项目成员。

目前,这些三个主要实体的信息足够让我们继续前进。我们可以详细了解“账户注册具体包括什么?”或者“如何向项目添加新任务?”但我们已经概述了足够的规格以开始这些基本功能。随着实施的进行,我们将确定更详细的细节。

但在我们开始之前,我们应该记下一些基本的导航和应用程序工作流程。这将帮助每个人更好地理解我们正在构建的应用程序的一般布局和流程。

导航和页面流程

总是很好地概述应用程序中的主要页面,并查看它们如何配合。这将帮助我们快速确定一些需要的 Yii 控制器、操作和视图,以及帮助设定每个人对我们在开发初期将要构建的期望。

以下图表显示了基本的应用程序流程,从登录到项目详情列表:

导航和页面流程

当用户首次进入应用程序时,他们必须先登录并进行身份验证,然后才能继续使用任何功能。成功登录后,他们将看到他们当前项目的列表,以及创建新项目的功能。选择特定项目将带他们进入项目详情页面。项目详情页面将展示按类型列出的问题列表。还可以添加新问题,以及编辑列出的任何问题。

这都是非常基本的功能,但这个图表为我们提供了关于应用程序如何组合在一起的更多信息,并且让我们更好地开始确定我们需要的模型、视图和控制器。它还允许与他人分享一些可视化的东西,以便每个参与者对我们正在努力实现的目标有相同的理解。根据我的经验,几乎每个人在首次思考新应用程序时都更喜欢图片而不是书面规格。

数据关系

我们在开始朝着这些规格构建之前,仍然需要更多地考虑我们将要处理的数据。如果我们从系统中挑选出所有的主要名词,我们可能会得到一个相当不错的领域对象列表,通过使用活动记录,我们想要建模的数据也会得到延伸。我们之前概述的用户故事确定了以下内容:

  • 一个用户

  • 一个项目

  • 一个问题

基于这一点以及用户故事和应用程序工作流程图中提供的其他细节,我们在以下图表中展示了对必要数据模型的第一次尝试:

数据关系

这是一个非常基本的对象模型,概述了我们的主要数据实体、它们各自的属性以及它们之间的一些关系。在项目和用户对象之间的线的两侧的 1..和 0..表示它们之间存在多对多的关系。一个用户可以与零个或多个项目相关联,一个项目可以有一个或多个用户。同样地,我们表示了一个项目可以有零个或多个与之相关的问题,而一个问题只属于一个特定的项目。此外,一个用户可以是许多问题的所有者(或请求者),但一个问题只有一个所有者(也只有一个请求者)。

在这个阶段,我们尽可能地保持属性的简单。用户需要用户名和密码才能通过登录界面。项目只有一个名称属性。

根据我们目前所知的信息,问题具有最多的相关信息。正如在之前定义的用户故事中简要讨论的,问题将具有一个类型属性,用于区分一般类别(错误、功能或任务)。它们还将具有一个状态属性,用于指示正在处理的问题的进展。将有一个已登录的用户最初创建问题;这是请求者。一旦系统中的用户被分配来处理问题,他们将成为问题的所有者。我们还定义了描述属性,以允许输入问题的一些描述性文本。

请注意,我们还没有明确讨论模式或数据库。事实上,直到我们仔细考虑从数据角度真正需要什么,我们才会知道用来存储这些数据的正确工具。文件系统上的平面文件是否和关系数据库一样有效?我们是否需要持久化数据?

在这个早期规划阶段,这些问题的答案并不总是必要的。更好的是,更专注于我们想要的功能以及支持这些功能所需的数据类型。在与其他项目利益相关者讨论这些想法之后,我们可以转向明确的技术实施细节,以确保我们走在正确的道路上。其他项目利益相关者包括所有参与这个开发项目的人。这可能包括客户,如果你为别人构建应用程序,以及其他开发团队成员、产品/项目经理等等。从“团队”中获得一些反馈来帮助验证方法和所做的任何假设总是一个好主意。

在我们的情况下,确实没有其他人参与这个开发工作。因此,我们可以快速得出一些结论来回答我们与数据相关的问题,并继续我们的应用程序开发。

由于这是一个基于 Web 的应用程序,并且考虑到我们需要存储、检索和操作的信息的性质,我们可以得出结论,最好将数据持久化在这个应用程序中。此外,基于我们想要捕获和管理的数据类型之间存在的关系,存储这些数据的一个良好方法是使用关系数据库。基于其易用性、优秀的价格点、在 PHP 应用程序开发人员中的普遍受欢迎程度以及与 Yii 框架的兼容性,我们将使用 MySQL 作为特定的数据库服务器。

现在我们已经了解了我们将要开始构建的内容以及我们将如何开始构建它的足够信息,让我们开始吧。

创建新应用程序

首先,让我们先创建初始的 Yii Web 应用程序。我们已经在第二章中看到了这是多么容易实现,入门。就像我们在那里所做的那样,我们将假设以下内容:

  • YiiRoot是您安装 Yii 的目录

  • WebRoot被配置为您的 Web 服务器的文档根目录(即http://localhost/解析到的位置)

因此,从命令行,切换到您的WebRoot目录并执行以下操作:

**% YiiRoot/framework/yiic webapp trackstar**
**Create a Web application under '/Webroot/trackstar'? [Yes|No] Yes**

这为我们提供了我们的骨架目录结构和开箱即用的工作应用程序。您应该能够通过导航到http://localhost/trackstar/index.php?r=site/index来查看这个新应用程序的主页。

注意

因为我们的默认控制器是 SiteController,该控制器中的默认操作是actionIndex(),所以我们也可以在不指定路由的情况下导航到相同的页面。

连接到数据库

现在我们的骨架应用程序已经运行起来了,让我们开始着手正确地连接到数据库。事实上,骨架应用程序已自动配置为使用数据库。使用yiic工具的副产品是,我们的新应用程序配置为使用 SQLite 数据库。如果您在protected/config/main.php中的主应用程序配置文件中查看,您将在文件的中间位置看到以下声明:

'db'=>array('connectionString' => 'sqlite:'.dirname(__FILE__).'/../data/testdrive.db',
    ),

您还可以验证protected/data/testdrive.db的存在,这是配置要使用的 SQLite 数据库。

由于我们已经决定使用 MySQL,我们需要进行一些配置更改。但是,在我们改变配置以使用 MySQL 数据库服务器之前,让我们简要讨论一下 Yii 和数据库更一般的情况。

Yii 和数据库

Yii 为数据库编程提供了很好的支持。 Yii 的数据访问对象DAO)是建立在PHP 数据对象PDO)扩展(php.net/pdo)之上的。这是一个数据库抽象层,使应用程序能够通过一个与数据库无关的接口与数据库交互。所有支持的数据库管理系统DBMS)都封装在一个统一的接口后面。这样,代码可以保持与数据库无关,使用 Yii DAO 开发的应用程序可以轻松地切换到使用不同的 DBMS,而无需进行修改。

要与支持的 DBMS 建立连接,您可以简单地创建一个新的CDbConnection实例:

$connection=new CDbConnection($dsn,$username,$password);

这里$dsn变量的格式取决于所使用的特定 PDO 数据库驱动程序。一些常见的格式包括:

  • SQLite:sqlite:/path/to/dbfile

  • MySQL:mysql:host=localhost;dbname=testdb

  • PostgreSQL:pgsql:host=localhost;port=5432;dbname=testdb

  • SQL Server:mssql:host=localhost;dbname=testdb

  • Oracle:oci:dbname=//localhost:1521/testdb

CDbConnection还继承自CApplicationComponent,这使它可以被配置为应用程序组件。这意味着我们可以将其添加到应用程序的 components 属性中,并在主配置文件中自定义类和属性值。这是我们首选的方法,接下来我们将详细介绍。

将 db 连接添加为应用程序组件

让我们快速回顾一下。当我们创建初始应用程序时,我们指定了应用程序类型为 Web 应用程序。记住我们在命令行上指定了webapp。这样做指定了每个请求创建的应用程序单例类的类型为CWebApplication。这个 Yii 应用程序单例是所有请求处理运行的执行上下文。它的主要任务是解析用户请求并将其路由到适当的控制器进行进一步处理。这在第一章中使用的图表中表示为 Yii 应用程序路由器,Meet Yii,当我们介绍请求路由时。它还作为保存应用程序级配置值的中心位置。

要自定义我们的应用程序配置,通常我们会提供一个配置文件来初始化应用程序实例创建时的属性值。主应用程序配置文件位于/protected/config/main.php。这是一个包含键值对数组的 PHP 文件。每个键代表应用程序实例的属性名称,每个值是相应属性的初始值。如果您打开这个文件,您会看到已经为我们配置了几个设置。

向配置中添加应用程序组件很容易。打开文件(/protected/config/main.php)并找到组件属性。

我们可以看到已经有条目指定了loguser应用程序组件。这些将在后续章节中介绍。我们还可以看到(正如我们之前注意到的),还有一个db组件,配置为使用 SQLite 连接到位于protected/data/testdrive.db的 SQLite 数据库。还有一个被注释掉的部分,定义了这个db组件使用 MySQL 数据库。我们所需要做的就是删除 SQLite db组件定义,取消注释定义 MySQL 组件的部分,然后进行相应的更改以匹配您的数据库名称、用户名和密码,以便进行连接。以下代码显示了这个更改:

// application components
  'components'=>array(
    …
    //comment out or remove the reference to the sqlite db
/*
'db'=>array(
      'connectionString' => 'sqlite:'.dirname(__FILE__).'/../data/testdrive.db',
    ),
*/
    // uncomment the following to use a MySQL database
        'db'=>array(
      'connectionString' => 'mysql:host=localhost;dbname=trackstar',
      'emulatePrepare' => true,
      'username' => '[your-db-username]',
      'password' => '[your-db-password]',
      'charset' => 'utf8',
    ),

这假设已经创建了一个名为trackstar的 MySQL 数据库,并且可以使用 localhost 连接。根据您的环境,您可能需要指定127.0.0.1而不是localhost作为 localhost 的 IP。将其作为应用程序组件的一个巨大好处是,现在在我们的应用程序的任何地方,我们可以简单地将数据库连接引用为主 Yii 应用程序Yii::app()->db的属性。同样,我们可以将其用作config文件中定义的任何其他组件的引用。

注意

charset属性设置为'utf8'时,它设置了数据库连接使用的字符集。这个属性只用于 MySQL 和 PostgreSQL 数据库。它将默认为 null,这意味着它将使用默认字符集。我们在这里设置它是为了确保我们的 PHP 应用程序正确支持utf8 unicode 字符。

emulatePrepare => true配置将 PDO 属性(PDO::ATTR_EMULATE_PREPARES)设置为true,如果您使用的是 PHP 5.1.3 或更高版本,则建议这样做。这是在 PHP 5.1.3 中添加的,当使用时,会导致使用 PDO 本机查询解析器而不是 MySQL 客户端中的本机准备语句 API。MySQL 客户端中的本机准备语句无法利用查询缓存,因此已知会导致性能不佳。PDO 本机查询解析器可以使用查询缓存,因此建议在可用时使用此选项(PHP 5.1.3 或更高版本)。

因此,我们已指定了一个名为trackstar的 MySQL 数据库,以及连接到该数据库所需的用户名和密码。我们没有向您展示如何在 MySQL 中创建这样的数据库。我们假设您了解如何设置 MySQL 数据库以及如何使用它。如果您不确定如何创建名为trackstar的新数据库,并为连接配置用户名和密码,请参考您特定的数据库文档。

测试数据库连接

在继续之前,我们应该确保我们的数据库连接实际上是有效的。我们可以通过几种方式来做到这一点。我们将看两种方法。在第一种方法中,我们将使用yiic命令行工具启动应用程序的交互式 shell,并确保在尝试引用应用程序db组件时没有错误。然后我们将提供第二种方法,介绍 Yii 中使用 PHPUnit 进行单元测试。

使用交互式 shell

我们将从使用 Yii 交互式 shell 开始进行简单测试。您可能还记得,我们使用webapp命令以及yiic命令行实用程序来创建我们的新应用程序。与此实用程序一起使用的另一个命令是shell。这允许您直接从命令行在 Yii 应用程序的上下文中运行 PHP 命令。

要启动 shell,请导航到应用程序的根目录,即包含index.php入口脚本Webroot/trackstar/的目录。然后运行yiic实用程序,将shell作为命令传递(参考以下截图)。

使用交互式 shell

这将启动 shell,并允许您在>>提示之后直接输入命令。

我们要做的是测试我们的连接,确保我们的数据库连接应用程序组件是可访问的。我们可以简单地echo出连接字符串,并验证它是否返回我们在配置中设置的内容。因此,从 shell 提示符中输入以下内容:

**>> echo Yii::app()->db->connectionString;**

它应该回显类似于以下内容:

mysql:host=localhost;dbname=trackstar

这表明db应用程序组件已正确配置并可供我们的应用程序使用。

自动化测试-单元和功能测试

收集反馈对应用程序开发至关重要;来自应用程序用户和其他项目利益相关者的反馈,来自开发团队成员的反馈,以及来自软件本身的直接反馈。以一种允许软件在出现故障时告诉您的方式开发软件,可以将与集成和部署应用程序相关的恐惧转化为无聊。您可以赋予软件这种反馈机制的方法是编写自动化单元和功能测试,然后重复并经常执行它们。

单元和功能测试

单元测试是为了向开发人员提供代码是否正确执行的验证。功能测试是为了向开发人员以及其他项目利益相关者提供应用程序是否以正确方式执行的验证。

单元测试

单元测试是专注于软件应用程序中最小单元的测试。在面向对象的应用程序中,比如 Yii web 应用程序,最小的单元是构成类接口的公共方法。单元测试应该专注于一个单一的类,不需要其他类或对象来运行。它们的目的是验证单元代码是否按预期工作。

功能测试

功能测试专注于测试应用程序的端到端功能功能。这些测试存在于比单元测试更高的级别,并且通常需要多个类或对象来运行。它们的目的是验证应用程序的特定功能是否按预期工作。

测试的好处

编写单元测试和功能测试有许多好处。首先,它们是提供文档的好方法。单元测试可以快速告诉代码块存在的确切原因。同样,功能测试记录了应用程序中实现的功能。如果您坚持编写这些测试,那么随着应用程序的发展,文档将自然而然地不断发展。

它们还是一种宝贵的反馈机制,不断向开发人员和其他项目利益相关者保证代码和应用程序按预期工作。每次对代码进行更改时都运行测试,并立即获得反馈,告诉您是否无意中更改了系统的预期行为。然后您可以立即解决这些问题。这确实增加了开发人员对应用程序的信心,并转化为更少的错误和更成功的项目。

这种即时反馈也有助于促进变革和改进代码的设计。如果一套测试能够立即提供反馈,告诉开发人员所做的更改是否改变了应用程序的行为,开发人员更有可能对现有代码进行改进。单元测试和功能测试套件提供的信心使开发人员能够编写更好的软件,发布更稳定的应用程序,并交付高质量的产品。

Yii 中的测试

从 1.1 版本开始,Yii 与 PHPUnit (www.phpunit.de/)和 Selenium Remote Control (seleniumhq.org/projects/remote-control/)测试框架紧密集成。您可以使用任何可用的测试框架测试 Yii PHP 代码。但是,Yii 与前述两个框架的紧密集成使事情变得更加容易。使事情变得容易是我们的主要目标之一。

当我们使用yiic webapp控制台命令创建新的 Web 应用程序时,我们注意到许多文件和目录会自动为我们创建。其中与编写和执行自动化测试相关的是以下内容:

文件/目录 包含/存储
trackstar/ 包含文件/目录列出的所有文件
protected/ 受保护的应用程序文件
tests/ 应用程序的测试
fixtures/ 数据库固定装置
functional/ 功能测试
unit/ 单元测试
report/ 覆盖率报告
bootstrap.php 在测试开始时执行的脚本
phpunit.xml PHPUnit 配置文件
WebTestCase.php 用于基于 Web 的功能测试的基类

您可以将测试文件放入三个主要目录,即fixturesfunctionalunitreport目录用于存储生成的代码覆盖率报告。

注意

必须安装 PHP 扩展 XDebug 才能生成报告。有关此安装的详细信息,请参阅xdebug.org/docs/install。此示例不需要此扩展。

单元测试

在 Yii 中,单元测试是以扩展自框架类CTestCase的 PHP 类编写的。约定规定它的名称应为AbcTest,其中Abc被要测试的类的名称替换。例如,如果我们要测试第二章中的“Hello, World!”应用程序中的MessageController类,我们将命名测试类为MessageControllerTest。这个类保存在protected/tests/unit/目录下的名为MessageControllerTest.php的文件中。

测试类主要有一组名为testXyz的测试方法,其中Xyz通常与您编写测试的方法名称相同。

继续使用MessageController示例,如果我们正在测试actionHelloworld()方法,我们将在MessageControllerTest类中命名相应的测试方法为testActionHelloworld()

安装 PHPUnit

从 1.1 版本开始,Yii 与 PHPUnit(www.phpunit.de/)测试框架紧密集成。

为了跟随这个示例,您需要安装 PHPUnit。这应该使用 Pear Installer 完成。(有关 Pear 的更多信息,请参阅pear.php.net/。)请访问以下网址,了解如何根据您的环境配置安装 PHPUnit 的更多信息:

github.com/sebastianbergmann/phpunit/

注意

本书的范围当然不包括具体介绍 PHPUnit 的测试功能。建议您花些时间阅读文档,了解术语和编写基本单元测试的感觉:github.com/sebastianbergmann/phpunit/

测试连接

假设您已成功安装了 PHPUnit,我们可以在protected/tests/unit/下为我们的数据库连接添加一个测试。让我们在这个目录下创建一个名为DbTest.php的简单数据库连接性测试文件。添加以下内容的新文件:

<?php
class DbTest extends CTestCase
{  
     public function testConnection()
     {
        $this->assertTrue(true);
     }
}

在这里,我们添加了一个相当琐碎的测试。assertTrue()方法是 PHPUnit 的一部分,它是一个断言,如果传递给它的参数为true,则会通过,如果为false,则会失败。在这种情况下,它正在测试true是否为true。因此,这个测试肯定会通过。我们这样做是为了确保我们的新应用程序按预期工作,用于 PHPUnit 测试。转到 tests 文件夹并执行这个新测试:

**%cd /WebRoot/trackstar/protected/tests**
**%phpunit unit/DbTest.php**

 …
 Time: 0 seconds, Memory: 10.00Mb

 OK (1 test, 1 assertion)

注意

如果由于某种原因此测试在您的系统上失败,您可能需要更改protected/tests/bootstrap.php,以便变量$yiit正确指向您的/YiiRoot/yiit.php文件。

确信我们的测试框架在新创建的 TrackStar 应用程序中按预期工作,我们可以使用它来为db连接编写测试。

testConnection()测试方法中的assertEquals(true)语句更改为:

$this->assertNotNull(Yii::app()->db->connectionString); 

然后重新运行测试:

**%phpunit unit/DbTest.php**

	 …
	 Time: 0 seconds, Memory: 10.00Mb

	 OK (1 test, 1 assertion)

如您所记得的,由于我们将数据库连接配置为名为db的应用程序组件,Yii::app()->db应返回CDbConnection类的实例。如果应用程序未能建立数据库连接,此测试将返回错误。由于测试仍然通过,我们可以放心地继续,确保数据库连接已正确设置。

总结

本章介绍了任务跟踪应用程序 TrackStar,我们将在本书的其余部分中开发。我们讨论了应用程序是什么以及它的功能,并以非正式用户故事的形式提供了一些高级需求。然后,我们确定了一些需要创建的主要领域对象,以及解决一些需要存储和管理的数据。

然后,我们迈出了构建 TrackStar 应用程序的第一步。我们创建了一个新的应用程序,其中包含从自动生成的代码中“免费”获得的所有工作功能。我们还配置了我们的应用程序连接到 MySQL 数据库,并演示了测试该连接的两种方法。一种方法演示了 Yii 与 PHPUnit 的集成以及如何为 Yii 应用程序编写自动化测试。

在下一章中,我们将最终开始深入研究更复杂的功能。我们将开始进行一些实际的编码,以实现在应用程序中管理项目实体所需的功能。

第四章:项目 CRUD

现在我们已经有了一个基本的应用程序,并配置好与我们的数据库通信,我们可以开始着手一些我们应用程序的真正功能。我们知道"项目"是我们应用程序中最基本的组件之一。用户在 TrackStar 应用程序中不能做任何有用的事情,而不是首先创建或选择一个现有的项目,然后在其中添加任务和其他问题。因此,我们首先要把注意力转向将一些项目功能加入应用程序。

功能规划

在本章的努力结束时,我们的应用程序应该允许用户创建新项目,从现有项目列表中选择,更新/编辑现有项目,并删除现有项目。

为了实现这个目标,我们应该确定更加细粒度的任务来关注。下面的列表确定了我们在本章内的任务清单:

  • 设计数据库架构以支持项目

  • 构建架构中标识的必要表和所有其他数据库对象

  • 创建 Yii AR 模型类,以便应用程序可以轻松地与创建的数据库表进行交互

  • 创建 Yii 控制器类,用于包含以下功能:

  • 创建新项目

  • 检索现有项目列表以显示

  • 更新与现有项目相关的数据

  • 删除现有项目

  • 创建 Yii 视图文件和表示层逻辑,将:

  • 展示表单以允许创建新项目

  • 显示所有现有项目的列表

  • 显示表单以允许用户编辑现有项目

  • 在项目列表中添加删除按钮,以允许删除项目

这绝对足够让我们开始了。

创建项目表

回到第三章 TrackStar 应用程序,我们谈到了代表项目的基本数据,并且我们决定我们将使用 MySQL 关系数据库来构建这个应用程序的持久层。现在我们需要设计和构建将持久化我们项目数据的表。

我们知道项目需要有名称和描述。我们还将在每个表上保留一些基本的表审计信息,通过跟踪记录创建和更新的时间,以及谁创建和更新记录。

基于这些属性,项目表将如下所示:

CREATE TABLE tbl_`project` (
`id` INTEGER NOT NULL auto_increment,
`name` varchar(255) NOT NULL,
`description` text NOT NULL,
`create_time` DATETIME default NULL,
`create_user_id` INTEGER default NULL,
`update_time` DATETIME default NULL,
`update_user_id` INTEGER default NULL,
PRIMARY KEY  (`id`)
) ENGINE = InnoDB
;

现在,在我们直接使用我们喜爱的 MySQL 数据库编辑器来创建这个表之前,我们需要讨论一下我们如何使用 Yii 来管理我们的数据库架构的变化,因为我们构建我们的 TrackStar 应用程序时会发生变化。

Yii 数据库迁移

我们知道跟踪应用程序源代码的版本更改是一个好习惯。当您在构建我们的 TrackStar 应用程序时,使用版本控制软件如 SVN 或 GIT 来帮助管理我们在代码库中所做的所有更改是明智的。如果我们的代码库更改与数据库更改不同步,很可能我们整个应用程序都会崩溃。因此,管理我们将在数据库中进行的结构更改也是非常重要的。

Yii 在这方面帮助了我们。Yii 提供了一个数据库迁移工具,用于跟踪数据库迁移历史,并允许我们应用新的迁移,以及回滚现有的迁移,以便我们将数据库结构恢复到先前的状态。

Yii 迁移实用程序是一个控制台命令,我们使用yiic命令行工具。作为控制台命令,它使用一个特定于控制台命令的配置文件,默认情况下是protected/config/console.php。我们需要在这个文件中正确配置我们的数据库组件。就像我们在main.php配置文件中所做的那样,我们需要定义我们的db组件来使用我们的 MySQL 数据库。如果你打开protected/config/console.php配置文件,你会看到它已经定义了一个 MySQL 配置,但是被注释掉了。让我们删除 SQLite 配置并取消注释 MySQL 配置,根据你的数据库设置更改用户名和密码:

'components'=>array(	
'db'=>array(
    'connectionString' => 'mysql:host=localhost;dbname=trackstar',
    'emulatePrepare' => true,
    'username' => '[YOUR-USERNAME]',
    'password' => '[YOUR-PASSWORD]',
    'charset' => 'utf8',
  ),
),

现在我们已经完成了配置更改,可以继续创建迁移。为此,我们使用yiic命令行实用工具和migrate命令。创建迁移的一般形式如下:

**$ yiic migrate create <name>**

在这里,必需的name参数允许我们指定我们正在进行的数据库更改的简要描述。name参数用作迁移文件名和 PHP 类名的一部分。因此,它应该只包含字母、数字或下划线字符。Yii 接受输入的名称参数,并附加一个 UTC 时间戳(格式为yymmdd_hhmmss),并在其后加上字母m以用作文件名和 PHP 类名。让我们继续为我们的项目表创建一个新的迁移,这个命名约定将更加清晰。从命令行中,导航到应用程序的protected/目录,然后发出使用名称create_project_table创建新迁移的命令:

Yii 数据库迁移

这将创建文件/Webroot/trackstar/protected/migrations/m121108_195611_create_project_table.php,内容如下:

class m121108_195611_create_project_table extends CDbMigration
{
  public function up()
  {
  }

  public function down()
  {
    echo "m121108_195611_create_project_table does not support migration down.\n";
    return false;
  }

  /*
  // Use safeUp/safeDown to do migration with transaction
  public function safeUp()
  {
  }

  public function safeDown()
  {
  }
  */
}

当然,我们将不得不对这个文件进行一些更改,以便它创建我们的新表。我们实现up()方法来应用我们所需的数据库更改,并实现down()方法来撤消这些更改,这将允许我们恢复到数据库结构的先前版本。safeUp()safeDown()方法类似,但它们将在数据库事务中执行更改,以便将整个迁移作为原子单元以一种全有或全无的方式执行。在这种情况下,我们要应用的更改是创建一个新表,我们可以通过删除表来撤消这些更改。这些更改如下:

public function up()
{
  $this->createTable('tbl_project', array(
    'id' => 'pk',
     'name' => 'string NOT NULL',
    'description' => 'text NOT NULL',
    'create_time' => 'datetime DEFAULT NULL',
    'create_user_id' => 'int(11) DEFAULT NULL',
    'update_time' => 'datetime DEFAULT NULL',
    'update_user_id' => 'int(11) DEFAULT NULL',
  ), 'ENGINE=InnoDB');
}

public function down()
{
  $this->dropTable('tbl_project');
}

保存更改后,我们可以执行迁移。在protected/目录中,执行以下迁移:

Yii 数据库迁移

使用不带参数的迁移命令将导致对尚未应用的每个迁移执行迁移(即执行up()方法)。而且,由于这是我们第一次运行迁移,Yii 将自动为我们创建一个新的迁移历史表tbl_migration。Yii 使用此表来跟踪已经应用的迁移。如果我们在迁移命令的命令行参数中指定down,则将通过运行该迁移的down()方法来撤消最后应用的迁移。

现在我们已经应用了迁移,我们的新的tbl_project表已经被创建并准备好供我们使用。

注意

在开发我们的 TrackStar 应用程序时,我们将在整本书中使用 Yii 迁移,因此在使用它们时我们将继续学习更多关于它们的知识。有关 Yii 迁移的更详细信息,请参见:

www.yiiframework.com/doc/guide/1.1/en/database.migration

命名约定

您可能已经注意到我们将数据库表以及所有列名都定义为小写。在整个开发过程中,我们将使用小写来表示所有表名和列名。这主要是因为不同的 DBMS 以不同的方式处理大小写敏感性。例如,PostgreSQL默认情况下将列名视为不区分大小写,如果列包含混合大小写字母,则必须在查询条件中引用列。使用小写将有助于消除这个问题。

您可能还注意到我们在命名项目表时使用了tbl_前缀。从 1.1.0 版本开始,Yii 提供了对表前缀的集成支持。表前缀是一个字符串,它被添加到表的名称之前。它经常用于共享托管环境,其中多个应用程序共享一个单一的数据库,并使用不同的表前缀来区分彼此;一种数据库对象的名称空间。例如,一个应用程序可以使用tbl_作为前缀,而另一个应用程序可以使用yii_。此外,一些数据库管理员使用这个作为一个命名约定,以前缀数据库对象的标识符,以确定它们是什么类型的实体或者将要使用。他们使用前缀来帮助将对象组织成相似的组。使用表前缀是一种偏好,当然不是必需的。

为了充分利用 Yii 中集成的表前缀支持,必须适当地设置CDbConnection::tablePrefix属性为所需的表前缀。然后,在整个应用程序中使用的 SQL 语句中,可以使用{{TableName}}来引用表名,其中TableName是表的名称,但不包括前缀。例如,如果我们要进行这个配置更改,我们可以使用以下代码来查询所有项目:

$sql='SELECT * FROM {{project}}';
$projects=Yii::app()->db->createCommand($sql)->queryAll();

但这有点超前。让我们暂时保持配置不变,等到我们稍后在应用程序开发中进行数据库查询时再回顾这个话题。

创建 AR 模型类

现在我们已经创建了tbl_project表,我们需要创建 Yii 模型类,以便我们可以轻松地管理该表中的数据。我们在第一章 遇见 Yii中介绍了 Yii 的 ORM 层,Active Record(AR)。现在我们将在这个应用程序的上下文中看到一个具体的例子。

配置 Gii

回到第二章 入门,当我们构建我们简单的“Hello, World!” Yii 应用程序时,我们介绍了代码生成工具Gii。如果您还记得,在我们开始使用 Gii 之前,我们必须为其配置我们的应用程序。我们需要在我们的新 TrackStar 应用程序中再次这样做。作为提醒,要配置 Gii 的使用,打开protected/config/main.php,并定义 Gii 模块如下:

return array(
  …
  …
  'modules'=>array(
'gii'=>array(
        'class'=>'system.gii.GiiModule',
        'password'=>false,
        // If removed, Gii defaults to localhost only. Edit carefully to taste.
        'ipFilters'=>array('127.0.0.1','::1'),
    ),
    …
  ),

这将 Gii 配置为一个应用程序模块。我们将在本书的后面详细介绍 Yii 模块。此时重要的是确保将其添加到配置文件中,并提供您的密码(或者在开发环境中将密码设置为false,以避免被提示登录屏幕)。现在,通过转到http://localhost/trackstar/index.php?r=gii来导航到该工具。

使用 Gii 创建我们的 Project AR 类

Gii 的主菜单页面如下所示:

使用 Gii 创建我们的 Project AR 类

由于我们想要为我们的tbl_project表创建一个新的模型类,模型生成器选项似乎是正确的选择。点击该链接会带我们到以下页面:

使用 Gii 创建我们的 Project AR 类

表前缀字段主要用于帮助 Gii 确定我们正在生成的 AR 类的命名方式。如果您使用前缀,可以在此处添加。这样,它在命名新类时就不会使用该前缀。在我们的情况下,我们使用tbl_前缀,所以我们应该在这里指定。指定此值将意味着我们新生成的 AR 类将被命名为Project,而不是Tbl_project

接下来的两个字段要求我们的表名和我们想要生成的类文件的名称。在表名字段中输入我们的表名tbl_project,并观察模型类名称自动填充。模型类名称的约定是表的名称,减去前缀,并以大写字母开头。因此,它将假定我们的模型类名称为 Project,但您当然可以自定义。

接下来的几个字段允许进一步定制。基类字段用于指定我们的模型类将继承的类。这将需要是CActiveRecord或其子类。模型路径字段让我们指定在应用程序目录结构中输出新文件的位置。默认值是protected/models/(别名application.models)。构建关系复选框允许您决定是否让 Gii 通过使用在 MySQL 数据库表之间定义的关系来自动定义 AR 对象之间的关系。它默认为选中状态。最后一个字段允许我们指定基于哪个模板进行代码生成。我们可以自定义默认模板以满足可能适用于所有这类类文件的任何特定需求。目前,这些字段的默认值完全满足我们的需求。

点击预览按钮继续。这将导致以下表格显示在页面底部:

使用 Gii 创建我们的项目 AR 类

此链接允许您预览将要生成的代码。在点击生成之前,点击models/Project.php链接。以下截图显示了这个预览的样子:

使用 Gii 创建我们的项目 AR 类

它提供了一个可滚动的弹出窗口,以便我们可以预览将要生成的文件。

好的,关闭这个弹出窗口,然后点击生成按钮。假设一切顺利,您应该看到页面底部显示类似以下截图的内容:

使用 Gii 创建我们的项目 AR 类

提示

在尝试生成新模型类之前,请确保 Gii 尝试创建新文件的路径protected/models/(或者如果您更改了位置,则是模型路径表单字段中指定的任何目录路径)可被您的 Web 服务器进程写入,否则您将收到写入权限错误。

Gii 已为我们创建了一个新的 Yii 活动记录模型类,并按照我们的指示命名为Project.php。它还将其放在了默认的 Yii 模型类位置protected/models/中,这是我们指示的位置。这个类是我们的tbl_project数据库表的包装类。tbl_project表中的所有列都可以作为Project AR 类的属性访问。

为项目启用 CRUD 操作

现在我们有了一个新的 AR 模型类,但接下来呢?在 MVC 架构中,通常我们需要一个控制器和一个视图来配合我们的模型,以完成整个架构。在我们的情况下,我们需要能够在应用程序中管理我们的项目。我们需要能够创建新项目,检索现有项目的信息,更新现有项目的信息,并删除现有项目。我们需要添加一个控制器类,该类将处理我们的模型类上的 CRUD(创建、读取、更新、删除)操作,以及一个视图文件,以提供 GUI,允许用户在浏览器中执行这些操作。我们可以采取的一种方法是打开我们喜欢的代码编辑器,并创建一个新的控制器和视图类。但是,幸运的是,我们不必这样做。

为项目创建 CRUD 脚手架

再次,Gii 工具将帮助我们摆脱编写常见、繁琐且耗时的代码。CRUD 操作在为应用程序创建的数据库表上是如此常见,Yii 的开发人员决定为我们提供这个功能。如果您来自其他框架,您可能会知道这个术语脚手架。让我们看看如何在 Yii 中利用这一点。

返回到位于http://localhost/trackstar/index.php?r=gii的主 Gii 菜单,并选择Crud Generator链接。您将看到以下屏幕:

为项目创建 CRUD 脚手架

在这里,我们看到两个输入表单字段。第一个要求我们指定针对哪个模型类生成所有 CRUD 操作。在我们的情况下,这是我们之前创建的Project AR 类。因此,我们将在此字段中输入Project。在这样做时,我们注意到控制器 ID字段自动填充了名称project。这是 Yii 的命名约定。当然,您可以更改为其他名称,但我们暂时将坚持使用默认值。我们还将使用默认的基础控制器类Controller,这是在我们最初创建应用程序时为我们创建的,以及默认的代码模板文件来生成类文件。

填写了所有这些字段后,点击预览按钮会在页面底部显示以下表格:

为项目创建 CRUD 脚手架

我们可以看到将生成相当多的文件。列表顶部是一个新的ProjectController控制器类,将包含所有 CRUD 操作方法。列表的其余部分代表还将创建的许多单独的视图文件。每个操作都有一个单独的视图文件,还有一个将提供搜索项目记录功能的视图文件。当然,您可以通过更改表中相应生成列中的复选框来选择不生成其中的一些文件。但是,对于我们的目的,我们希望 Gii 为我们创建所有这些文件。

请点击生成按钮。您应该在页面底部看到以下成功消息:

为项目创建 CRUD 脚手架

注意

您可能需要确保根应用程序目录下的/protected/controllers/protected/views都可以被 Web 服务器进程写入。否则,您将收到权限错误,而不是这个成功的结果。

现在,我们可以点击立即尝试链接,测试我们的新功能。

这样做会带您到一个项目列表页面。这是显示系统中当前所有项目的页面。在我们的情况下,我们还没有创建任何项目,所以页面会显示未找到结果的消息。让我们通过创建一个新项目来改变这种情况。

创建新项目

在项目列表页面(http://localhost/trackstar/index.php?r=project)的右侧有一个小的导航区域。单击创建项目链接。您会发现这实际上将我们带到登录页面,而不是一个创建新项目的表单。原因是 Gii 生成的代码应用了一个规则,规定只有经过适当身份验证的用户(即已登录的用户)才能创建新项目。任何尝试访问创建新项目功能的匿名用户都将被重定向到登录页面。我们稍后会详细介绍身份验证和授权。现在,继续使用用户名demo和密码demo登录。

成功登录后,应将您重定向到以下 URL:

http://localhost/trackstar/index.php?r=project/create

此页面显示了一个用于添加新项目的输入表单,如下面的屏幕截图所示:

创建新项目

让我们快速填写这个表单来创建一个新项目。表单指示有两个必填字段,名称描述。Gii 代码生成器足够聪明,知道我们在数据库表中定义了tbl_project.nametbl_project.description列为NOT NULL,这应该在创建新项目时转换为必填表单字段。很酷,对吧?

因此,我们至少需要填写这两个字段。给它起名字,测试项目,并将描述设置为测试项目描述。单击创建按钮将把表单数据发送回服务器,并尝试添加一个新的项目记录。如果有任何验证错误,将显示一个简单的错误消息,突出显示每个错误的字段。成功保存将重定向到新创建项目的特定列表。我们的成功了,我们被重定向到页面http://localhost/trackstar/index.php?r=project/view&id=1,如下面的屏幕截图所示:

创建新项目

正如我们之前简要提到的,我们注意到我们的新项目创建表单中,名称和描述字段都被标记为必填项。这是因为我们在数据库表中定义了名称和描述列不允许为空值。让我们看看 Yii 中这些必填字段是如何工作的。

表单字段验证

在 Yii 中的表单中使用 AR 模型类时,围绕表单字段设置验证规则非常简单。这是通过在 AR 模型类中的rules()方法中定义的数组中指定值来完成的。

如果您查看Project模型类中的代码(/protected/models/Project.php),您会发现rules()公共函数已经为我们定义好了,并且其中已经有一些规则了:

/**
   * @return array validation rules for model attributes.
   */
  public function rules()
  {
    // NOTE: you should only define rules for those attributes that
    // will receive user inputs.
    return array(
      array('name, description', 'required'),
      array('create_user_id, update_user_id', 'numerical', 'integerOnly'=>true),
      array('name', 'length', 'max'=>255),
      array('create_time, update_time', 'safe'),
      // The following rule is used by search().
      // Please remove those attributes that should not be searched.
      array('id, name, description, create_time, create_user_id, update_time, update_user_id', 'safe', 'on'=>'search'),
      );
  }	

rules()方法返回一个规则数组。每个规则的一般格式如下:

Array('Attribute List', 'Validator', 'on'=>'Scenario List', …additional options);

Attribute List是一个逗号分隔的类属性名称字符串,根据Validator进行验证。Validator指定应强制执行什么样的规则。on参数指定应用规则的情景列表。例如,如果我们指定验证应在insert情景上下文中应用,这将表示规则应仅在插入新记录时应用。

如果没有定义特定的情景,验证规则将在模型数据进行验证时的所有情景中应用。

注意

从 Yii 的 1.1.11 版本开始,您还可以指定一个except参数,它允许您排除某些情景的验证。语法与on参数相同。

最后,您还可以指定额外的选项作为name=>value对,用于初始化验证器的属性。这些额外的选项将根据指定的验证器的属性而有所不同。

验证器可以是模型类中的一个方法,也可以是一个单独的验证器类。如果定义为模型类方法,它必须具有以下签名:

/** 
* @param string the name of the attribute to be validated 
* @param array options specified in the validation rule 
*/ 
public function validatorName($attribute,$params) 
{
...
}

如果使用一个单独的类来定义验证器,那个类必须继承自CValidator

实际上有三种指定验证器的方法:

  • 在模型类本身中指定一个方法名

  • 指定一个验证器类型的单独类(即一个继承自CValidator的类)

  • 在 Yii 框架中指定现有验证器类的预定义别名

Yii 为您提供了许多预定义的验证器类,并提供了别名来定义规则时引用这些类。截至 Yii 版本 1.1.12,预定义的验证器类别名的完整列表如下:

  • booleanCBooleanValidator的别名,验证包含truefalse的属性

  • captchaCCaptchaValidator的别名,验证属性值是否与 CAPTCHA 中显示的验证码相同

  • compareCCompareValidator的别名,比较两个属性并验证它们是否相等

  • emailCEmailValidator的别名,验证属性值是否为有效的电子邮件地址

  • dateCDateValidator的别名,验证属性值是否为有效的日期、时间或日期时间值

  • defaultCDefaultValueValidator的别名,为指定的属性分配默认值

  • existCExistValidator的别名,验证属性值是否与数据库中指定表列中的值相匹配

  • fileCFileValidator的别名,验证包含已上传文件名称的属性值

  • filterCFilterValidator的别名,使用指定的过滤器转换属性值

  • inCRangeValidator的别名,验证数据是否在预定范围内的值,或者存在于指定的值列表中

  • lengthCStringValidator的别名,验证属性值的长度是否在指定范围内

  • matchCRegularExpressionValidator的别名,使用正则表达式验证属性值

  • numericalCNumberValidator的别名,验证属性值是否为有效数字

  • requiredCRequiredValidator的别名,验证属性值是否为空

  • typeCTypeValidator的别名,验证属性值是否为特定数据类型

  • uniqueCUniqueValidator的别名,验证属性值是否唯一,并与数据库表列进行比较

  • urlCUrlValidator的别名,验证属性值是否为有效的 URL

我们看到在我们的rules()函数中,有一个规则定义了名称和描述属性,并使用了 Yii 别名required来指定验证器:

array('name, description', 'required'),

这个验证规则的声明负责在新项目表单的名称描述字段旁边显示小红色星号。这表示这个字段现在是必填的。如果我们回到新项目创建表单(http://localhost/trackstar/index.php?r=project/create)并尝试提交表单而没有指定名称描述,我们将得到一个精美格式的错误消息,告诉我们不能提交带有这些字段空值的表单,如下面的截图所示:

表单字段验证

注意

正如我们之前提到的,Gii 代码生成工具将根据底层表中列的定义自动向 AR 类添加验证规则。我们看到了NameDescription列定义为NOT NULL约束,并且有相关的必填验证器定义。另一个例子是,具有长度限制的列,比如我们的名字列被定义为varchar(255),将自动应用字符限制规则。通过再次查看我们在Project AR 类中的rules()方法,我们注意到 Gii 根据其列定义为我们自动创建了规则array('name', 'length', 'max'=>255)。有关验证器的更多信息,请参见www.yiiframework.com/doc/guide/1.1/en/form.model#declaring-validation-rules

阅读项目

当我们成功保存一个新项目后被带到项目详细信息页面时,我们实际上已经看到了这个过程http://localhost/trackstar/index.php?r=project/view&id=1。该页面展示了 CRUD 中的R。然而,要查看整个列表,我们可以点击右侧列中的List Project链接。这将带我们回到起点,只是现在我们在项目列表中有了我们新创建的项目。因此,我们有能力检索应用程序中所有项目的列表,以及查看每个项目的详细信息。

更新和删除项目

通过点击列表中任何项目的小项目ID链接,可以导航回项目详细信息页面。让我们为我们新创建的项目,即我们的情况下的ID: 1,做这个操作。点击此链接将带我们到该项目的项目详细信息页面。该页面在其右侧列中显示了一些操作功能,如下截图所示:

更新和删除项目

我们可以看到Update ProjectDelete Project链接,分别为我们提供了 CRUD 操作中的UD。我们将留给您来验证这些链接是否按预期工作。

注意

删除功能仅限于管理员用户;也就是说,您必须使用admin/admin的用户名/密码组合登录。因此,如果您正在验证删除功能并收到 403 错误,请确保您以管理员身份登录。这将在后面更详细地讨论,并且我们将在后面的章节中详细介绍身份验证和授权。

在管理模式下管理项目

我们在上一个截图中未涵盖的最后一个链接是Manage Project链接。请点击此链接。这很可能会导致授权错误,如下截图所示:

在管理模式下管理项目

出现此错误的原因是该功能调用了 Yii 中的简单访问控制功能,并且只限制了admin用户的访问。如果您回忆起,当我们登录应用程序以创建新项目时,我们使用demo/demo作为我们的用户名/密码组合。这个demo用户没有权限访问此管理员页面。由 Gii 生成的代码限制了对此功能的访问。

在这个上下文中,管理员简单地指的是使用admin/admin用户名/密码组合登录的人。请点击主导航栏中的注销(演示)来退出应用程序。然后再次登录,但这次使用管理员凭据。成功以admin身份登录后,您会注意到顶部导航栏的注销链接变成了注销(管理员)。然后返回到特定的项目列表页面,例如http://localhost/trackstar/index.php?r=project/view&id=1,再次尝试管理项目链接。您现在应该看到以下截图中显示的内容:

在管理员模式下管理项目

我们现在看到的是我们项目列表页面的高度互动版本。它显示了所有项目在一个互动数据表中。每一行都有内联链接,可以查看、更新和删除每个项目。点击任何列标题链接都会按照该列数值对项目列表进行排序。第二行的小输入框允许您通过关键词在各个列数值中搜索这个项目列表。高级搜索链接会显示一个完整的搜索表单,提供了指定多个搜索条件来提交一个搜索的能力。以下截图显示了这个高级搜索表单:

在管理员模式下管理项目

我们基本上实现了这个迭代中设定的所有功能,而且几乎没有编写任何代码。事实上,借助 Gii 的帮助,我们不仅创建了所有的 CRUD 功能,还实现了我们没有预期到达的基本项目搜索功能。虽然非常基础,但我们已经拥有了一个完全功能的应用程序,具有特定于项目任务跟踪应用程序的功能,并且几乎没有付出太多的努力。

当然,我们的 TrackStar 应用程序还有很多工作要完成。所有这些脚手架代码并不打算完全取代应用程序开发。它为我们提供了一个很好的起点和基础,可以继续构建我们的应用程序。当我们通过项目功能应该如何工作的所有细节和微妙之处时,我们可以依靠这个自动生成的代码以快速的速度推动事情向前发展。

摘要

尽管在本章中我们没有做太多编码,但我们取得了很大的成就。我们创建了一个新的数据库表,这使我们能够看到 Yii Active Record(AR)的实际运行情况。我们使用 Gii 工具首先创建了一个 AR 模型类来包装我们的tbl_project数据库表。然后我们演示了如何使用 Gii 代码生成工具在 Web 应用程序中生成实际的 CRUD 功能。这个神奇的工具快速地创建了我们需要的功能,甚至进一步提供了一个管理仪表板,让我们可以根据不同的条件搜索和排序我们的项目。我们还演示了如何实现模型数据验证以及这如何转化为 Yii 中表单字段验证。

在下一章中,我们将在已学到的基础上继续深入研究 Yii 中的 Active Record,同时在我们的数据模型中引入相关实体。

第五章:管理问题

在上一章中,我们提供了围绕项目实体的基本功能。项目是 TrackStar 应用程序的基础。然而,单独的项目并不是非常有用。项目是我们希望这个应用程序管理的问题的基本容器。由于管理项目问题是这个应用程序的主要目的,我们希望开始添加一些基本的问题管理功能。

功能规划

我们已经有了创建和列出项目的能力,但没有办法管理与项目相关的问题。在本章结束时,我们希望应用程序能够在项目问题或任务上公开所有 CRUD 操作。 (我们倾向于交替使用问题任务这两个术语,但在我们的数据模型中,任务实际上只是问题的一种类型。)我们还希望限制对问题的所有 CRUD 操作都在特定项目的上下文中进行。也就是说,问题属于项目。用户必须在能够对项目的问题执行任何 CRUD 操作之前,选择了一个现有的项目来工作。

为了实现前面提到的目标,我们需要:

  • 设计数据库模式并构建支持项目问题的对象

  • 创建 Yii 模型类,使应用程序能够轻松地与我们创建的数据库表进行交互

  • 创建控制器类,其中将包含允许我们进行以下操作的功能:

  • 创建新问题

  • 从数据库中检索项目中现有问题的列表

  • 更新/编辑现有问题

  • 删除现有问题

  • 为这些(上述)操作创建视图以渲染用户界面

这个列表足以让我们开始。让我们开始做必要的数据库更改。

设计模式

回到第三章, TrackStar 应用程序,我们提出了一些关于问题实体的初始想法。我们建议它有一个名称,一个类型,一个所有者,一个请求者,一个状态和一个描述。我们还提到当我们创建tbl_project表时,我们将向每个创建的表添加基本的审计历史信息,以跟踪更新表的日期、时间和用户。然而,类型、所有者、请求者和状态本身也是它们自己的实体。为了保持我们的模型灵活和可扩展,我们将分别对其中一些进行建模。所有者请求者都是系统的用户,因此将被放在一个名为tbl_user的单独表中。我们已经在tbl_project表中介绍了用户的概念,因为我们添加了create_user_idupdate_user_id列来跟踪最初创建项目的用户的标识符,以及负责最后更新项目详细信息的用户。尽管我们尚未正式介绍该表,但这些字段旨在成为user表的外键。tbl_issue表中的owner_idrequestor_id列也将是关联回这个tbl_user表的外键。

我们可以以相同的方式对类型和状态属性进行建模。然而,直到我们的需求要求模型中的这种额外复杂性,我们可以保持简单。tbl_issue表上的typestatus列将保持整数值,可以映射到命名类型和状态。然而,我们将这些建模为我们为问题实体创建的 AR 模型类中的基本类常量(const)值,而不是通过使用单独的表来使我们的模型复杂化。如果所有这些都有点模糊,不要担心;在接下来的章节中会更清晰。

定义一些关系

由于我们引入了tbl_user表,我们需要回去定义用户和项目之间的关系。在第三章中,TrackStar 应用程序,我们指定用户(我们称之为项目成员)将与零个或多个项目关联。我们还提到项目也可以有许多(一个或多个)用户。由于项目可以有许多用户,并且这些用户可以与许多项目关联,我们将其称为项目和用户之间的多对多关系。在关系数据库中建模多对多关系的最简单方法是使用关联表(也称为分配表)。因此,我们还需要将这个表添加到我们的模型中。

下图概述了用户、项目和问题之间的基本实体关系。项目可以有零到多个用户。用户需要与至少一个项目关联,但可以与多个项目关联。问题属于一个且仅属于一个项目,而项目可以有零到多个问题。最后,一个问题被分配给(或由)一个单一用户。

定义一些关系

构建对象及其关系

我们需要创建三个新表,即tbl_issuetbl_user和我们的关联表tbl_project_user_assignment。您可能还记得我们在第四章介绍了 Yii 数据库迁移。由于我们现在准备对数据库结构进行更改,我们将使用 Yii 迁移来更好地管理这些更改的应用。

由于我们要同时向数据库中添加这些内容,我们将在一个迁移中完成。从命令行,切换到protected/目录,并输入以下命令:

**$ ./yiic migrate create create_issue_user_and_assignment_tables**

这将导致一个新的迁移文件被添加到protected/migrations/目录中。

打开这个新创建的文件,并按照以下方式实现 safeUp()和 safeDown()方法:

  // Use safeUp/safeDown to do migration with transaction
  public function safeUp()
  {
    //create the issue table
    $this->createTable('tbl_issue', array(
      'id' => 'pk',
        'name' => 'string NOT NULL',
        'description' => 'text',
        'project_id' => 'int(11) DEFAULT NULL',
      'type_id' => 'int(11) DEFAULT NULL',
      'status_id' => 'int(11) DEFAULT NULL',
      'owner_id' => 'int(11) DEFAULT NULL',
      'requester_id' => 'int(11) DEFAULT NULL',
      'create_time' => 'datetime DEFAULT NULL',
      'create_user_id' => 'int(11) DEFAULT NULL',
      'update_time' => 'datetime DEFAULT NULL',
      'update_user_id' => 'int(11) DEFAULT NULL',
       ), 'ENGINE=InnoDB');

    //create the user table
    $this->createTable('tbl_user', array(
      'id' => 'pk',
      'username' => 'string NOT NULL',
        'email' => 'string NOT NULL',
        'password' => 'string NOT NULL',
      'last_login_time' => 'datetime DEFAULT NULL',
      'create_time' => 'datetime DEFAULT NULL',
      'create_user_id' => 'int(11) DEFAULT NULL',
      'update_time' => 'datetime DEFAULT NULL',
      'update_user_id' => 'int(11) DEFAULT NULL',
       ), 'ENGINE=InnoDB');

    //create the assignment table that allows for many-to-many 
//relationship between projects and users
    $this->createTable('tbl_project_user_assignment', array(
      'project_id' => 'int(11) NOT NULL',
      'user_id' => 'int(11) NOT NULL',
      'PRIMARY KEY (`project_id`,`user_id`)',
     ), 'ENGINE=InnoDB');

    //foreign key relationships

    //the tbl_issue.project_id is a reference to tbl_project.id 
    $this->addForeignKey("fk_issue_project", "tbl_issue", "project_id", "tbl_project", "id", "CASCADE", "RESTRICT");

    //the tbl_issue.owner_id is a reference to tbl_user.id 
    $this->addForeignKey("fk_issue_owner", "tbl_issue", "owner_id", "tbl_user", "id", "CASCADE", "RESTRICT");

    //the tbl_issue.requester_id is a reference to tbl_user.id 
    $this->addForeignKey("fk_issue_requester", "tbl_issue", "requester_id", "tbl_user", "id", "CASCADE", "RESTRICT");

    //the tbl_project_user_assignment.project_id is a reference to tbl_project.id 
    $this->addForeignKey("fk_project_user", "tbl_project_user_assignment", "project_id", "tbl_project", "id", "CASCADE", "RESTRICT");

    //the tbl_project_user_assignment.user_id is a reference to tbl_user.id 
    $this->addForeignKey("fk_user_project", "tbl_project_user_assignment", "user_id", "tbl_user", "id", "CASCADE", "RESTRICT");

  }

  public function safeDown()
  {
    $this->truncateTable('tbl_project_user_assignment');
    $this->truncateTable('tbl_issue');
    $this->truncateTable('tbl_user');
    $this->dropTable('tbl_project_user_assignment');
    $this->dropTable('tbl_issue');
    $this->dropTable('tbl_user');
  }

在这里,我们实现了safeUp()safeDown()方法,而不是标准的up()down()方法。这样做可以在数据库事务中运行这些语句,以便它们作为单个单元被提交或回滚。

注意

实际上,由于我们正在使用 MySQL,这些create tabledrop table语句不会在单个事务中运行。某些 MySQL 语句会导致隐式提交,因此在这种情况下使用safeUp()safeDown()方法并没有太多用处。我们将保留这一点,以帮助用户了解为什么 Yii 迁移提供safeUp()safeDown()方法。有关更多详细信息,请参见dev.mysql.com/doc/refman/5.5/en/implicit-commit.html

现在我们可以从命令行运行迁移:

构建对象及其关系

这个迁移已经创建了我们需要的数据库对象。现在我们可以把注意力转向创建我们的活动记录模型类。

创建活动记录模型类

现在我们已经创建了这些表,我们需要创建 Yii 模型 AR 类,以便我们可以在应用程序中轻松地与这些表交互。在上一章创建Project模型类时,我们使用了 Gii 代码生成工具。我们会在这里提醒您这些步骤,但不会给您所有的截图。请参考第四章,项目 CRUD,以获取使用 Gii 工具创建活动记录类的更详细步骤。

创建 Issue 模型类

通过http://localhost/trackstar/index.php?r=gii导航到 Gii 工具,然后选择Model Generator链接。将表前缀保留为tbl_。在Table Name字段中填写tbl_issue,这将自动填充Model Class字段为Issue。还要确保Build Relations复选框被选中。这将确保我们的关系在新的模型类中自动创建。

填写表单后,点击Preview按钮,获取一个弹出窗口的链接,显示即将生成的所有代码。然后点击Generate按钮,实际在/protected/models/目录中创建新的Issue.php模型类文件。

创建用户模型类

这在这一点上可能已经变得老生常谈了,所以我们将把User AR 类的创建留给您作为一个练习。在下一章中,当我们深入研究用户认证和授权时,这个特定的类将变得更加重要。

您可能会问,“tbl_project_user_assignment表的 AR 类呢?”。虽然可以为这个表创建一个 AR 类,但这并不是必要的。AR 模型为我们的应用程序提供了一个对象关系映射ORM)层,帮助我们更轻松地处理领域对象。然而,ProjectUserAssignment不是我们应用程序的领域对象。它只是一个在关系数据库中的构造,帮助我们建模和管理项目和用户之间的多对多关系。为处理这个表的管理而维护一个单独的 AR 类是我们可以暂时避免的额外复杂性。我们可以直接使用 Yii 的 DAO 来管理这个表的插入、更新和删除。

创建问题的 CRUD 操作

现在我们已经有了问题的 AR 类,我们可以开始构建必要的功能来管理我们的项目问题。我们将再次依靠 Gii 代码生成工具来帮助我们创建这些功能的基础。我们在上一章节详细介绍了项目的这一点。我将再次提醒您 Issues 的基本步骤:

  1. 导航到 Gii 生成器菜单http://localhost/trackstar/index.php?r=gii,然后选择Crud Generator链接。

  2. 使用Issue作为Model Class字段的值填写表单。这将自动填充Controller IDIssueBase Controller ClassCode Template字段可以保留它们预定义的默认值。

  3. 点击Preview按钮,获取 Gii 工具建议创建的所有文件列表。以下截屏显示了这些文件的列表:Creating the issue CRUD operations

  4. 您可以点击每个单独的链接预览要生成的代码。一旦满意,点击Generate按钮来创建所有这些文件。您应该收到以下成功消息:Creating the issue CRUD operations

使用问题 CRUD 操作

让我们试一试。要么点击前面截屏中显示的try it now链接,要么直接导航到http://localhost/trackstar/index.php?r=issue。您应该看到类似于以下截屏的内容:

使用问题 CRUD 操作

创建一个新问题

由于我们还没有添加任何新问题,所以没有要列出的问题。让我们改变这种情况,创建一个新问题。点击Create Issue链接。(如果这将您带到登录页面,请使用demo/demoadmin/admin登录。成功登录后,您将被正确重定向。)现在您应该看到一个类似于以下截屏的新问题输入表单:

创建一个新问题

当查看这个输入表单时,我们可以看到它在数据库表中的每一列都有一个输入字段,就像在数据库表中定义的那样。然而,正如我们从设计模式和建立表格时所知道的那样,其中一些字段不是直接的输入字段,而是代表与其他实体的关系。例如,与其在这个表单上有一个类型自由文本输入字段,我们应该使用一个下拉输入表单字段,其中填充了允许的问题类型的选择。类似的论点也适用于状态字段。所有者请求者字段也应该是下拉菜单,显示被分配到处理问题所在项目的用户的名称选择。此外,由于所有问题管理都应该在特定项目的上下文中进行,项目字段根本不应该是这个表单的一部分。最后,创建时间创建用户更新时间更新用户字段都是应该在表单提交后计算和确定的值,不应该供用户直接操作。

看起来我们已经确定了一些我们想要在这个初始输入表单上做出的更正。正如我们在上一章中提到的,Gii 工具生成的自动生成的 CRUD“脚手架”代码只是一个起点。很少有情况下它本身就足以满足应用程序的所有特定功能需求。

添加下拉字段

我们将从为问题类型添加一个下拉菜单开始。问题只有三种类型,即错误功能任务。当创建一个新问题时,我们希望看到的是一个下拉式输入类型表单字段,其中包含这三个选择。我们将通过Issue模型类本身提供其可用类型的列表来实现这一点。由于我们没有创建一个单独的数据库表来保存我们的问题类型,我们将这些直接添加为Issue活动记录模型类的类常量。

Issue模型类的顶部添加以下三个常量定义:

const TYPE_BUG=0;
const TYPE_FEATURE=1;
const TYPE_TASK=2;

现在在这个类中添加一个新的方法Issue::getTypeOptions(),它将根据这些定义的常量返回一个数组:

/**
  * Retrieves a list of issue types
  * @return array an array of available issue types.
  */
public function getTypeOptions()
{
  return array(
    self::TYPE_BUG=>'Bug',
    self::TYPE_FEATURE=>'Feature',
    self::TYPE_TASK=>'Task',
  );
}

现在我们有了一种方法来检索可用的问题类型列表,但我们仍然没有一个下拉字段在输入表单中显示这些值,我们可以从中选择。让我们现在添加它。

添加问题类型下拉

打开包含新问题创建表单的文件protected/views/issue/_form.php,找到与表单上的类型字段对应的行:

<div class="row">
  <?php echo $form->labelEx($model,'type_id'); ?>
  <?php echo $form->textField($model,'type_id'); ?>
  <?php echo $form->error($model,'type_id'); ?>
</div> 

这些行需要一点澄清。为了理解这一点,我们需要参考_form.php文件顶部的一些代码,如下所示:

<?php $form=$this->beginWidget('CActiveForm', array(
  'id'=>'issue-form',
  'enableAjaxValidation'=>false,
)); ?>

这是使用 Yii 中的CActiveForm小部件定义$form变量。小部件将在以后更详细地介绍。现在,我们可以通过更好地理解CActiveForm来理解这段代码。CActiveForm可以被认为是一个帮助类,它提供了一组方法来帮助我们创建与数据模型类相关联的表单。在这种情况下,它被用来基于我们的Issue模型类创建一个输入表单。

为了充分理解视图文件中的变量,让我们也回顾一下渲染视图文件的控制器代码。正如之前讨论过的,从控制器传递数据到视图的一种方式是通过显式声明一个数组,其中的键将是视图文件中可用变量的名称。由于这是一个新问题的创建操作,渲染表单的控制器方法是IssueController::actionCreate()。该方法如下所示:

/**
   * Creates a new model.
   * If creation is successful, the browser will be redirected to the 'view' 
 * page.
   */
  public function actionCreate()
  {
    $model=new Issue;

    // Uncomment the following line if AJAX validation is needed
    // $this->performAjaxValidation($model);

    if(isset($_POST['Issue']))
    {
      $model->attributes=$_POST['Issue'];
      if($model->save())
        $this->redirect(array('view','id'=>$model->id));
    }

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

在这里,我们看到当视图被渲染时,它会传递一个Issue模型类的实例,这个实例将在视图中作为一个名为$model的变量可用。

现在让我们回到负责在表单上渲染Type字段的代码。第一行是:

$form->labelEx($model,'type_id');

这一行使用CActiveForm::labelEx()方法为问题模型属性type_id渲染 HTML 标签。它接受模型类的实例和我们想要生成标签的相应模型属性。模型类Issue:: attributeLabels()方法将被用于确定标签。如果我们查看下面列出的方法,我们会看到属性type_id被映射为标签'Type',这正是我们在表单字段中看到的标签。

public function attributeLabels()
{
    return array(
      'id' => 'ID',
      'name' => 'Name',
      'description' => 'Description',
      'project_id' => 'Project',
 **'type_id' => 'Type',**
      'status_id' => 'Status',
      'owner_id' => 'Owner',
      'requester_id' => 'Requester',
      'create_time' => 'Create Time',
      'create_user_id' => 'Create User',
      'update_time' => 'Update Time',
      'update_user_id' => 'Update User',
    );
}

使用labelEx()方法也是我们的必填字段旁边出现小红星号的原因。当属性是必填时,labelEx()方法将添加一个额外的CSS类名(CHtml::requiredCss,默认为'required')和星号(使用CHtml::afterRequiredLabel,默认为' <span class="required">*</span>')。

接下来的一行,<?php echo $form->textField($model,'type_id'); ?>,使用CActiveForm::textField()方法为我们的Issue模型属性type_id渲染文本输入字段。在模型类Issue::rules()方法中定义的任何验证规则都将被应用为此输入表单的表单验证规则。

最后一行<?php echo $form->error($model,'type_id'); ?>使用CActiveForm::error()方法在提交时渲染与type_id属性相关的任何验证错误。

您可以尝试使用类型字段进行验证。在我们的 MySQL 模式定义中,type_id列被定义为整数类型,因此,Gii 在Issue::rules()方法中生成了一个验证规则来强制执行这一点。

  public function rules()
  {
    // NOTE: you should only define rules for those attributes that
    // will receive user inputs.
    return array(
      array('name', 'required'),
 **array('project_id, type_id, status_id, owner_id, requester_id, create_user_id, update_user_id', 'numerical', 'integerOnly'=>true),**

因此,如果我们尝试在Type表单字段中提交字符串值,我们将在字段下方立即收到内联错误,如下面的屏幕截图所示:

添加问题类型下拉菜单

现在我们更好地理解了我们所拥有的东西,我们更有能力对其进行更改。我们需要做的是将这个字段从自由格式的文本输入字段更改为下拉式输入类型。也许不足为奇的是,CActiveForm类有一个dropDownList()方法,可以为模型属性生成一个下拉列表。让我们用以下内容替换调用$form->textField的行(在文件/protected/views/issue/_form.php中):

<?php echo $form->dropDownList($model,'type_id', $model->getTypeOptions()); ?>

这仍然将早期的模型作为第一个参数,将模型属性作为第二个参数。第三个参数指定下拉选择列表。这应该是一个value=>display对的数组。我们已经在Issue.php模型类中创建了我们的getTypeOptions()方法来返回这种格式的数组,所以我们可以直接使用它。

注意

应该注意的是,Yii 框架基类使用了 PHP _get "魔术"函数。这允许我们在子类中编写诸如getTypeOptions()之类的方法,并使用->typeOptions语法将这些方法作为类属性引用。因此,当请求问题类型选项数组$model->typeOptions时,我们也可以使用等效的语法。

保存您的工作,再次查看我们的问题输入表单。您应该看到一个漂亮的问题类型选择下拉菜单,取代了自由格式文本字段,如下面的屏幕截图所示:

添加问题类型下拉菜单

添加状态下拉菜单:自己动手

我们将采用相同的方法处理问题状态。正如在第三章中提到的TrackStar 应用程序,当我们介绍应用程序时,问题可以处于以下三种状态之一:

  • 尚未开始

  • 开始

  • 完成

我们将在Issue模型类中创建三个类常量来表示状态值。然后我们将创建一个新方法,Issue::getStatusOptions(),来返回一个可用的问题状态数组。最后,我们将修改_form.php文件,以渲染状态选项的下拉菜单,而不是状态的自由格式文本输入字段。

我们将把状态下拉菜单的实现留给你。你可以按照我们为类型所采取的方法来操作。在你做出这些改变后,表单应该看起来和下面的截图类似:

添加状态下拉菜单:自己动手做

我们还应该注意,当我们把这些从自由格式文本输入字段改为下拉菜单字段时,最好也在我们的rules()方法中添加一个范围验证,以确保提交的值在下拉菜单允许的值范围内。在上一章中,我们看到了 Yii 框架提供的所有验证器列表。CRangeValidator属性,使用别名in,是定义这个验证规则的一个很好的选择。因此,我们可以定义这样一个规则:

array('type_id', 'in', 'range'=>self::getAllowedTypeRange()),

然后我们添加一个方法来返回我们允许的数值类型值的数组:

public static function getAllowedTypeRange()
{
  return array(
    self::TYPE_BUG,
    self::TYPE_FEATURE,
    self::TYPE_TASK,
  );
}

同样的方法也用于我们的status_id。我们也将把这个留给你来实现。

修复所有者和请求者字段

我们在问题创建表单中注意到的另一个问题是,所有者和请求者字段也是自由格式的文本输入字段。然而,我们知道这些在问题表中是整数值,它们保存了对tbl_user表的id列的外键标识符。因此,我们还需要为这些字段添加下拉菜单。我们不会采取和类型和状态属性相同的方法,因为问题的所有者和请求者需要从tbl_user表中获取。而且,由于系统中并非每个用户都与问题所在的项目相关联,这些问题不能用从整个tbl_user表中获取的数据填充下拉菜单。我们需要将列表限制为仅包括与该项目相关联的用户。

这也带来了另一件我们需要解决的事情。正如本章开头的功能规划部分所提到的,我们需要在特定项目的上下文中管理我们的问题。也就是说,在创建新问题之前,应该选择一个特定的项目。目前,我们的应用程序没有强制执行这个工作流程。

让我们逐一解决这些变化。首先,我们将修改应用程序,以强制在使用与该项目相关的任何功能来管理相关问题之前,必须确定一个有效的项目。一旦选择了一个项目,我们将确保我们的所有者和请求者下拉选择仅限于与该项目相关联的用户。

强制项目上下文

在允许访问管理问题之前,我们希望确保存在一个有效的项目上下文。为了做到这一点,我们将实现一个叫做过滤器的东西。在 Yii 中,过滤器是一段配置为在控制器动作执行之前或之后执行的代码。一个常见的例子是,如果我们想要确保用户在执行控制器动作方法之前已经登录,我们可以编写一个简单的访问过滤器来检查这个要求。另一个例子是,如果我们想在动作执行后执行一些额外的日志记录或其他审计逻辑,我们可以编写一个简单的审计过滤器来提供这种动作后处理。

在这种情况下,我们希望确保在创建新问题之前已经选择了一个有效的项目。因此,我们将在我们的IssueController类中添加一个项目过滤器来实现这一点。

定义过滤器

过滤器可以被定义为控制器类方法,也可以是一个单独的类。使用简单方法的方法时,方法名必须以 filter 开头,并具有特定的签名。例如,如果我们要创建一个名为 someMethodName 的过滤器方法,我们的完整过滤器方法将如下所示:

public function filterSomeMethodName($filterChain)
{
...
}

另一种方法是编写一个单独的类来执行过滤逻辑。使用单独类的方法时,该类必须扩展 CFilter,然后根据逻辑应该在操作调用之前还是之后,重写至少一个 preFilter()postFilter() 方法。

添加一个过滤器

因此,让我们向我们的 IssueController 类添加一个过滤器,以处理对有效项目的检查。我们将采用类方法的方法。

打开 protected/controllers/IssueController.php 并在类的底部添加以下方法:

public function filterProjectContext($filterChain)
{   
     $filterChain->run(); 
} 

好的,我们现在已经定义了一个过滤器。但是它还没有做太多事情。它只是执行 $filterChain->run(),这会继续过滤过程并允许被该方法过滤的操作方法的执行。这带来了另一个问题。我们如何定义哪些操作方法应该使用这个过滤器?

指定被过滤的操作

我们的控制器类的 Yii 框架基类是 CController。它有一个需要被重写以指定需要应用过滤器的操作的 filters() 方法。实际上,这个方法已经在我们的 IssueController.php 类中被重写。当我们使用 Gii 工具自动生成这个类时,它已经为我们完成了。它已经添加了一个简单的 accessControl 过滤器,该过滤器在 CController 基类中定义,用于处理一些基本授权,以确保用户有足够的权限执行某些操作。如果您尚未登录并单击 创建问题 链接,您将被引导到登录页面进行身份验证,然后才能创建新问题。访问控制过滤器负责此操作。在下一章节中,当我们专注于用户身份验证和授权时,我们将更详细地介绍它。

目前,我们只需要将我们的新过滤器添加到这个配置数组中。要指定我们的新过滤器应该应用于创建操作,通过添加下面的代码来修改 IssueController::filters() 方法:

/**
 * @return array action filters
 */
public function filters()
{
  return array(
    'accessControl', // perform access control for CRUD operations  
 **'projectContext + create', //check to ensure valid project context**
  );
}

filters() 方法应该返回一个过滤器配置的数组。之前的代码返回了一个配置,指定了应该将定义为类内方法的 projectContext 过滤器应用于 actionCreate() 方法。配置语法允许使用 "+" 和 "-" 符号来指定是否应该应用过滤器。例如,如果我们决定希望该过滤器应用于除 actionUpdate()actionView() 之外的所有操作,我们可以指定:

return array(
        'projectContext - update, view' ,
 );

您不应该同时指定加号和减号运算符。对于任何给定的过滤器配置,只需要一个。加号运算符表示“仅将过滤器应用于以下操作”。减号运算符表示“将过滤器应用于除以下操作之外的所有操作”。如果配置中既没有“+”也没有“-”,则该过滤器将应用于所有操作。

目前,我们将这个限制在只有创建操作。因此,如之前定义的 + create 配置,我们的过滤器方法将在任何用户尝试创建新问题时被调用。

添加过滤逻辑

好的,现在我们已经定义了一个过滤器,并且已经配置它在尝试的 actionCreate() 方法调用时被调用。但是,它仍然没有执行必要的逻辑。由于我们希望在尝试操作之前确保项目上下文,我们需要在调用 $filterChain->run() 之前将逻辑放在过滤器方法中。

我们将在控制器类本身中添加一个项目属性。然后,我们将在我们的 URL 中使用一个查询字符串参数来指示项目标识符。我们的预操作过滤器将检查现有的项目属性是否为空;如果是,它将使用查询字符串参数来尝试根据主键标识符选择项目。如果成功,操作将执行;如果失败,将抛出异常。以下是执行所有这些操作所需的相关代码:

class IssueController extends CController
{
     ....
     /**
   * @var private property containing the associated Project model instance.
   */
     private $_project = null; 

     /**
   * Protected method to load the associated Project model class
       * @param integer projectId the primary identifier of the associated Project
 * @return object the Project data model based on the primary key 
   */
     protected function loadProject($projectId)    {
     //if the project property is null, create it based on input id
     if($this->_project===null)
     {
      $this->_project=Project::model()->findByPk($projectId);
      if($this->_project===null)
                  {
          throw new CHttpException(404,'The requested project does not exist.'); 
         }
     }

     return $this->_project; 
  } 

  /**
   * In-class defined filter method, configured for use in the above filters() 
 * method. It is called before the actionCreate() action method is run in 
 * order to ensure a proper project context
   */
  public function filterProjectContext($filterChain)
  {   
//set the project identifier based on GET input request variables       if(isset($_GET['pid']))
      $this->loadProject($_GET['pid']);   
    else
      throw new CHttpException(403,'Must specify a project before performing this action.');

    //complete the running of other filters and execute the requested action
    $filterChain->run();

  }
  ...
}

有了这个设置,如果您现在尝试通过在问题列表页面上的创建问题链接上点击来创建一个新问题,您应该会看到一个“错误 403”错误消息,同时显示我们之前指定的错误文本。

这很好。它表明我们已经正确实现了防止在没有识别到项目时创建新问题的代码。要快速解决这个错误,只需简单地在用于创建新问题的 URL 中添加一个pid查询字符串参数。让我们这样做,这样我们就可以为过滤器提供一个有效的项目标识符,并继续到创建新问题的表单。

添加项目 ID

回到第四章项目 CRUD,在测试和实施项目的 CRUD 操作时,我们向应用程序添加了几个新项目。因此,您可能仍然在开发数据库中拥有一个有效的项目。如果没有,只需使用应用程序再次创建一个新项目。完成后,请注意所创建的项目 ID,因为我们需要将此 ID 添加到新问题的 URL 中。

我们需要修改的链接位于问题列表页面的视图文件/protected/views/issue/index.php中。在该文件的顶部,您会看到我们的菜单项中定义了创建新问题的链接的位置。这在以下突出显示的代码中指定:

$this->menu=array(
 **array('label'=>'Create Issue', 'url'=>array('create')),**
  array('label'=>'Manage Issue', 'url'=>array('admin')),
);

要向这个链接添加一个查询字符串参数,我们只需在url参数的定义数组中追加一个name=>value对。我们为过滤器添加的代码期望查询字符串参数是pid(项目 ID)。另外,由于我们在这个例子中使用的是第一个(项目 ID = 1)项目,我们将修改创建问题链接如下:

array('label'=>'Create Issue', 'url'=>array('create', 'pid'=>1)),

现在当您查看问题列表页面时,您会看到创建问题超链接打开了一个在末尾附加了查询字符串参数的 URL:

http://localhost/trackstar/index.php?r=issue/create&pid=1

这个查询字符串参数允许过滤器正确设置项目上下文。所以这一次当您点击链接时,不会再出现 403 错误页面,而是会显示创建新问题的表单。

注意

有关在 Yii 中使用过滤器的更多详细信息,请参阅www.yiiframework.com/doc/guide/1.1/en/basics.controller#filter

修改项目详细信息页面

将项目 ID 添加到“创建新问题”链接的 URL 是确保我们的过滤器按预期工作的一个很好的第一步。然而,我们现在已经将链接硬编码为始终将新问题与项目 ID = 1 关联起来。这当然不是我们想要的。我们想要做的是让创建新问题的菜单选项成为项目详细信息页面的一部分。这样,一旦您从项目列表页面选择了一个项目,特定的项目上下文将被知晓,我们可以动态地将项目 ID 附加到创建新问题的链接上。让我们做出这个改变。

打开项目详细信息视图文件/protected/views/project/view.php。在这个文件的顶部,您会注意到包含在$this->menu数组中的菜单项。我们需要在已定义的菜单链接列表的末尾添加另一个链接以创建新问题:

$this->menu=array(
  array('label'=>'List Project', 'url'=>array('index')),
  array('label'=>'Create Project', 'url'=>array('create')),
  array('label'=>'Update Project', 'url'=>array('update', 'id'=>$model->id)),
  array('label'=>'Delete Project', 'url'=>'#', 'linkOptions'=>array('submit'=>array('delete','id'=>$model->id),'confirm'=>'Are you sure you want to delete this item?')),
  array('label'=>'Manage Project', 'url'=>array('admin')),
 **array('label'=>'Create Issue', 'url'=>array('issue/create', 'pid'=>$model->id)),**
);

我们所做的是将菜单选项移动到列出特定项目详细信息的页面上创建新问题。我们使用了类似于之前的链接,但这次我们必须指定完整的controllerID/actionID对(issue/create)。而不是将项目 ID 硬编码为 1,我们在视图文件中使用了$model变量,这是特定项目的 AR 类。通过这种方式,无论我们选择哪个项目,这个变量都将始终反映该项目的正确项目id属性。

有了这个设置,我们还可以删除另一个链接,我们在protected/views/issue/index.php视图文件中将项目 ID 硬编码为1

现在我们在创建新问题时已经正确设置了项目上下文,我们可以将项目字段作为用户输入表单字段删除。打开新问题表单的视图文件/protected/views/issue/_form.php。删除与项目输入字段相关的以下行:

<div class="row">
    <?php echo $form->labelEx($model,'project_id'); ?>
    <?php echo $form->textField($model,'project_id'); ?>
    <?php echo $form->error($model,'project_id'); ?>
</div>

然而,由于project_id属性不会随表单一起提交,我们需要根据我们刚刚实现的过滤器设置的值来设置project_id参数。由于我们已经知道关联的项目 ID,让我们明确地将Issue::project_id设置为我们先前实现的过滤器创建的项目实例的id属性的值。因此,根据以下突出显示的代码修改IssueController::actionCreate()方法:

public function actionCreate()
{
  $model=new Issue;
 **$model->project_id = $this->_project->id;**

现在当我们提交表单时,问题活动记录实例的project_id属性将被正确设置。即使我们还没有设置我们的所有者和请求者下拉框,我们也可以提交表单,新问题将被创建并正确设置项目 ID。

返回到所有者和请求者下拉框

最后,我们可以回到我们原来要做的事情,即将所有者和请求者字段更改为该项目的有效成员的下拉选择。为了正确地做到这一点,我们需要将一些用户与项目关联起来。由于用户管理是即将到来的章节的重点,我们将通过直接使用直接 SQL 将这些关联手动添加到数据库中来完成这一点。让我们使用以下 SQL 添加两个测试用户:

INSERT INTO tbl_user (email, username, password) VALUES ('test1@notanaddress.com','User One', MD5('test1')), ('test2@notanaddress.com','User Two', MD5('test2'));

注意

我们在这里使用单向MD5哈希算法,因为它易于使用,并且在 MySQL 和 PHP 的 5.x 版本中广泛可用。然而,现在已经知道MD5作为单向哈希算法在安全方面是“破碎的”,不建议在生产环境中使用这个哈希算法。请考虑在您的真实生产应用程序中使用Bcrypt。以下是一些提供有关Bcrypt更多信息的网址:

en.wikipedia.org/wiki/Bcrypt

php.net/manual/en/function.crypt.php

www.openwall.com/phpass/

当您在trackstar数据库上运行时,它将在我们的系统中创建两个具有 ID 1 和 2 的新用户。让我们也手动将这两个用户分配给项目#1,使用以下 SQL:

INSERT INTO tbl_project_user_assignment (project_id, user_id) 
VALUES (1,1), (1,2);   

在运行前面的 SQL 语句之后,我们已经将两个有效成员分配给项目#1。

Yii 中关系型 Active Record 的一个很棒的特性是能够直接从问题$model实例中访问问题所属项目的有效成员。当我们使用 Gii 工具最初创建我们的问题模型类时,我们确保选中了构建关系复选框。这指示 Gii 查看底层数据库并定义相关的关系。这可以在/protected/models/Issue.php中的relations()方法中看到。由于我们在添加适当的关系到数据库后创建了这个类,该方法应该看起来像以下内容:

      /**
   * @return array relational rules.
   */
  public function relations()
  {
    //NOTE: you may need to adjust the relation name and the related
    // class name for the relations automatically generated below.
    return array(
      'requester' => array(self::BELONGS_TO, 'User', 'requester_id'),
      'owner' => array(self::BELONGS_TO, 'User', 'owner_id'),
      'project' => array(self::BELONGS_TO, 'Project', 'project_id'),
    );
  }

前面代码片段中的//NOTE注释表明您可能具有略有不同的类属性名称,或者希望略有不同,并鼓励您根据需要进行调整。这个数组配置定义了模型实例上的属性,这些属性本身是其他 AR 实例。有了这些关系,我们可以非常容易地访问相关的 AR 实例。例如,假设我们想要访问与问题相关联的项目。我们可以使用以下语法来实现:

//create the model instance by primary key:
$issue = Issue::model()->findByPk(1);
//access the associated Project AR instance
$project = $issue->project;

由于我们在数据库中定义其他表和关系之前创建了我们的Project模型类,因此尚未定义关系。但是现在我们已经定义了一些关系,我们需要将这些添加到Project::relations()方法中。打开项目 AR 类/protected/models/Project.php,并用以下内容替换整个relations()方法:

   /**
     * @return array relational rules.
     */
    public function relations()
    {
return array(
            'issues' => array(self::HAS_MANY, 'Issue', 'project_id'),
            'users' => array(self::MANY_MANY, 'User', 'tbl_project_user_assignment(project_id, user_id)'),
        );
    }

有了这些,我们可以很容易地使用非常简单的语法访问与项目相关的所有问题和/或用户。例如:

//instantiate the Project model instance by primary key:  
$project = Project::model()->findByPk(1);
//get an array of all associated Issue AR instances
$allProjectIssues = $project->issues;
//get an array of all associated User AR instance
$allUsers = $project->users;
//get the User AR instance representing the owner of 
//the first issue associated with this project
$ownerOfFirstIssue = $project->issues[0]->owner;

通常情况下,我们需要编写复杂的 SQL 连接语句来访问这样的相关数据。在 Yii 中使用关系 AR 可以避免这种复杂性和单调性。我们现在可以以非常优雅和简洁的面向对象方式访问这些关系,这样非常容易阅读和理解。

生成用于填充下拉框的数据

我们将采用与状态和类型下拉数据相似的方法来实现有效的用户下拉。我们将在我们的Project模型类中添加一个getUserOptions()方法。

打开文件/protected/models/Project.php,并在类的底部添加以下方法:

/**
 * @return array of valid users for this project, indexed by user IDs
 */ 
public function getUserOptions()
{
  $usersArray = CHtml::listData($this->users, 'id', 'username');
      return $usersArray;
} 

在这里,我们使用 Yii 的CHtml辅助类来帮助我们从与项目相关的每个用户创建一个id=>username对的数组。记住,在项目类中relations()方法中定义的users属性映射到用户 AR 实例的数组。CHtml::listData()方法可以接受这个列表,并产生一个适合CActiveForm::dropDownList()的有效数组格式。

现在我们的getUserOptions()方法返回我们需要的数据,我们应该实现下拉框以显示返回的数据。我们已经使用过滤器从$_GET请求中设置了关联的项目 ID,并且我们在IssueController::actionCreate()方法的开头使用了这个值来设置新问题实例的project_id属性。所以现在,通过 Yii 关系 AR 功能的美妙力量,我们可以轻松地使用关联的Project模型来填充我们的用户下拉框。以下是我们在问题表单中需要做的更改:

打开包含输入表单元素的视图文件/protected/views/issue/_form.php,找到owner_idrequester_id的两个文本输入字段表单元素定义,并用以下代码替换它:

<?php echo $form->textField($model,'owner_id'); ?>
with this:
<?php echo $form->dropDownList($model,'owner_id', $model->project->getUserOptions()); ?>
and also replace this line:
<?php echo $form->textField($model,'requester_id'); ?>
with this:
<?php echo $form->dropDownList($model,'requester_id', $model->project->getUserOptions()); ?>

现在,如果我们再次查看我们的问题创建表单,我们会看到所有者请求者两个下拉框字段已经很好地填充了。

生成用于填充下拉框的数据

进行最后一次更改

由于我们已经打开了创建问题表单视图文件,让我们快速进行最后一次更改。我们在每个表上都有用于基本历史和审计目的的创建时间和用户以及最后更新时间和用户字段,不应该暴露给用户。稍后我们将更改应用程序逻辑,以在插入和更新时自动填充这些字段。现在,让我们只是将它们从表单中移除。

/protected/views/issue/_form.php中完全删除以下行:

<div class="row">
    <?php echo $form->labelEx($model,'create_time'); ?>
    <?php echo $form->textField($model,'create_time'); ?>
    <?php echo $form->error($model,'create_time'); ?>
  </div>

  <div class="row">
    <?php echo $form->labelEx($model,'create_user_id'); ?>
    <?php echo $form->textField($model,'create_user_id'); ?>
    <?php echo $form->error($model,'create_user_id'); ?>
  </div>

   <div class="row">
      <?php echo $form->labelEx($model,'update_time'); ?>
      <?php echo $form->textField($model,'update_time'); ?>
      <?php echo $form->error($model,'update_time'); ?>
   </div>

  <div class="row">
    <?php echo $form->labelEx($model,'update_user_id'); ?>
    <?php echo $form->textField($model,'update_user_id'); ?>
    <?php echo $form->error($model,'update_user_id'); ?>
  </div>

以下截图显示了我们的新问题创建表单经过所有这些更改后的样子:

进行最后一次更改

CRUD 的其余部分

本章的目标是实现问题的所有 CRUD 操作。我们已经完成了创建功能,但我们仍需要完成问题的读取、更新和删除。幸运的是,通过使用 Gii CRUD 生成功能,大部分基础已经搭好了。但是,由于我们希望在项目的上下文中管理所有问题,我们需要对访问这些功能的方式进行一些调整。

列出问题

尽管IssueController类中有actionIndex()方法,用于显示数据库中所有问题的列表,但我们目前编写的功能并不需要这个功能。我们不想要一个单独的独立页面列出数据库中的所有问题,而是只想列出与特定项目相关联的问题。因此,我们将修改应用程序,以在项目详细信息页面上显示问题列表。由于我们正在利用 Yii 中的关联 AR 模型,所以对于这个改变来说将会非常容易。

修改项目控制器

首先让我们修改ProjectController类中的actionView()方法。因为我们想在同一个页面上显示与特定项目相关联的问题列表,我们可以在项目详细信息页面上做到这一点。actionView()方法是显示项目详细信息的方法。

将该方法修改为:

    /**
         * Displays a particular model.
         * @param integer $id the ID of the model to be displayed
         */
        public function actionView($id)
        {
                $issueDataProvider=new CActiveDataProvider('Issue', array(
                        'criteria'=>array(
                                'condition'=>'project_id=:projectId',
                                'params'=>array(':projectId'=>$this->loadModel($id)->id),
                        ),
                        'pagination'=>array(
                                'pageSize'=>1,
                        ),
                 ));

                $this->render('view',array(
                        'model'=>$this->loadModel($id),
                        'issueDataProvider'=>$issueDataProvider,
                ));

        }

在这里,我们使用CActiveDataProvider框架类来使用CActiveRecord对象提供数据。它将使用关联的 AR 模型类以一种非常容易与内置的框架列表组件CListView一起使用的方式从数据库中检索数据。我们将使用这个组件在视图文件中显示我们的问题列表。我们使用了 criteria 属性来指定条件,它应该只检索与正在显示的项目相关联的问题。我们还使用了 pagination 属性来限制问题列表每页只显示一个问题。我们将这个值设置得很低,这样我们只需添加另一个问题就可以快速演示分页功能。我们很快会演示这一点。

我们做的最后一件事是将这个数据提供程序添加到render()调用中定义的数组中,以便在视图文件中以$issueDataProvider变量的形式提供给它。

修改项目视图文件

正如我们刚才提到的,我们将使用一个名为CListView的框架组件在项目详细信息页面上显示我们的问题列表。打开/protected/views/project/view.php并将以下内容添加到文件的底部:

<br />
<h1>Project Issues</h1>

<?php $this->widget('zii.widgets.CListView', array(
  'dataProvider'=>$issueDataProvider,
  'itemView'=>'/issue/_view',
)); ?>

在这里,我们将CListViewdataProvider属性设置为我们上面创建的问题数据提供程序。然后我们配置它使用protected/views/issue/_view.php文件作为渲染数据提供程序中每个项目的模板。当我们为问题生成 CRUD 时,Gii 工具已经为我们创建了这个文件。我们在这里使用它来显示项目详细信息页面上的问题。

注意

您可能还记得在第一章认识 Yii中,Zii是 Yii 框架附带的官方扩展库。这些扩展是由核心 Yii 框架团队开发和维护的。您可以在这里阅读更多关于 Zii 的信息:www.yiiframework.com/doc/guide/1.1/en/extension.use#zii-extensions

我们还需要对我们指定为每个问题的布局模板的/protected/views/issue/_view.php文件进行一些更改。将该文件的整个内容修改为以下内容:

<div class="view">

  <b><?php echo CHtml::encode($data->getAttributeLabel('name')); ?>:</b>
  <?php echo CHtml::link(CHtml::encode($data->name), array('issue/view', 'id'=>$data->id)); ?>
  <br />

  <b><?php echo CHtml::encode($data->getAttributeLabel('description')); ?>:</b>
  <?php echo CHtml::encode($data->description); ?>
  <br />

  <b><?php echo CHtml::encode($data->getAttributeLabel('type_id')); ?>:</b>
  <?php echo CHtml::encode($data->type_id); ?>
<br />

  <b><?php echo CHtml::encode($data->getAttributeLabel('status_id')); ?>:</b>
  <?php echo CHtml::encode($data->status_id); ?>

</div>

现在,如果我们保存并查看我们的结果,查看项目编号 1 的项目详细信息页面(http://localhost/trackstar/index.php?r=project/view&id=1),并假设您已经在该项目下创建了至少一个示例问题(如果没有,请在此页面使用创建问题链接创建一个),我们应该看到以下截图中显示的内容:

修改项目视图文件

由于我们将数据提供程序的分页属性设置得非常低(记住我们只设置为 1),我们可以添加一个问题来演示内置的分页功能。添加一个问题会改变问题的显示,使我们能够在项目问题列表中从一页到另一页,如下截图所示:

修改项目视图文件

最后的微调

现在我们有了与项目相关联的问题列表,并在项目详细信息页面上显示它们。我们还可以查看问题的详细信息(即阅读它们),以及更新和删除问题的链接。因此,我们的基本 CRUD 操作已经就位。

然而,在完成应用程序的这一部分之前,还有一些问题需要解决。我们会注意到问题显示列表显示了类型状态所有者请求者字段的数字 ID 号。我们应该更改这样,以便显示这些字段的文本值。此外,由于问题已经属于特定项目,将项目 ID 显示为问题列表数据的一部分有点多余。因此,我们可以删除它。最后,我们需要解决一些导航链接,这些链接显示在各种其他与问题相关的表单上,以确保我们始终返回到此项目详细信息页面,作为我们所有问题管理的起始位置。

我们将逐一解决这些问题。

获取状态和类型文本以显示

以前,我们在Issue AR 类中添加了公共方法,以检索状态和类型选项,以填充问题创建表单上的下拉菜单。我们需要在这个 AR 类上添加类似的方法,以返回特定状态或类型 ID 的文本。

Issue模型类(/protected/models/Issue.php)中添加以下两个新的公共方法,以检索当前问题的状态和类型文本:

   /**
   * @return string the status text display for the current issue
   */ 
  public function getStatusText()
  {
    $statusOptions=$this->statusOptions;
    return isset($statusOptions[$this->status_id]) ? $statusOptions[$this->status_id] : "unknown status ({$this->status_id})";
  }

  /**
   * @return string the type text display for the current issue
   */ 
  public function getTypeText()
  {
    $typeOptions=$this->typeOptions;
    return isset($typeOptions[$this->type_id]) ? $typeOptions[$this->type_id] : "unknown type ({$this->type_id})";
  }

这些方法返回状态文本值("尚未开始","已开始"或"已完成")和类型文本值("错误","功能"或"任务")的Issue实例。

向表单添加文本显示

现在我们有了两个新的公共方法,它们将返回我们列表显示的有效状态和类型文本,我们需要利用它们。更改/protected/views/issue/_view.php中的以下代码行。

将这个<?php echo CHtml::encode($data->type_id); ?>更改为这个:

<?php echo CHtml::encode($data->getTypeText()); ?>

将这个<?php echo CHtml::encode($data->status_id); ?>更改为这个:

<?php echo CHtml::encode($data->getStatusText()); ?>

经过这些更改,我们项目#1的问题列表页面,http://localhost/trackstar/index.php?r=issue&pid=1,不再显示我们问题类型和状态字段的整数值。现在看起来像以下截图中显示的样子:

向表单添加文本显示

由于我们使用相同的视图文件在项目详细信息页面上显示我们的问题列表,这些更改也会反映在那里。

更改问题详细视图

我们还需要对问题的详细视图进行一些其他更改。当前,如果查看问题详细信息,它显示如下截图所示:

更改问题详细视图

这是使用我们尚未更改的视图文件。它仍然显示项目 ID,我们不需要显示,以及类型状态作为整数值,而不是它们关联的文本值。打开用于呈现此显示的视图文件/protected/views/issue/view.php,我们注意到它使用了我们以前没有见过的 Zii 扩展小部件CDetailView。这类似于用于显示列表的CListView小部件,但用于显示单个数据模型实例的详细信息,而不是用于显示许多列表视图。以下是显示此小部件使用的相关代码:

<?php $this->widget('zii.widgets.CDetailView', array(
  'data'=>$model,
  'attributes'=>array(
    'id',
    'name',
    'description',
    'project_id',
    'type_id',
    'status_id',
    'owner_id',
    'requester_id',
    'create_time',
    'create_user_id',
    'update_time',
    'update_user_id',
  ),
)); ?>

在这里,我们将CDetailView小部件的数据模型设置为Issue模型类的实例(即我们要显示详细信息的特定实例),然后设置要在渲染的详细视图中显示的模型实例的属性列表。属性可以被指定为Name:Type:Label格式的字符串,其中TypeLabel都是可选的,或者作为数组本身。在这种情况下,只指定属性的名称。

如果我们将属性指定为数组,我们可以通过声明值元素来进一步自定义显示。我们将采取这种方法,以指定模型类方法Issue::getTypeText()Issue::getStatusText()用于获取类型状态字段的文本值。

让我们将CDetailView的使用更改为以下配置:

<?php $this->widget('zii.widgets.CDetailView', array(
  'data'=>$model,
  'attributes'=>array(
    'id',
    'name',
    'description',
    array(        
      'name'=>'type_id',
        'value'=>CHtml::encode($model->getTypeText())
    ),
    array(        
      'name'=>'status_id',
        'value'=>CHtml::encode($model->getStatusText())
    ),
    'owner_id',
    'requester_id',
    ),
)); ?>

在这里,我们已经删除了一些属性的显示,即project_idcreate_timeupdate_timecreate_user_idupdate_user_id属性。我们稍后会处理一些这些属性的填充和显示,但现在我们可以将它们从详细显示中删除。

我们还改变了type_idstatus_id属性的声明,以使用数组规范,以便我们可以使用值元素。我们已经指定了相应的Issue::getTypeText()Issue::getStatusText()方法用于获取这些属性的值。有了这些改变,查看问题详细页面显示如下:

更改问题详细视图

好的,我们离我们想要的更近了,但还有一些改变我们需要做。

显示所有者和请求者的名称

事情看起来更好了,但我们仍然看到整数标识符被显示为所有者请求者,而不是实际的用户名。我们将采取类似的方法来处理类型和状态文本显示。我们将在Issue模型类上添加两个新的公共方法,以返回这两个属性的名称。

使用关联 AR

由于我们的问题和用户分别表示为单独的数据库表,并通过外键关系相关联,我们可以直接从视图文件中的$model中访问ownerrequester用户名。利用 Yii 的关联 AR 模型功能,显示相关User模型类实例的用户名属性非常简单。

正如我们所提到的,模型类Issue::relations()方法是定义关系的地方。如果我们来看一下这个方法,我们会看到以下内容:

/**
   * @return array relational rules.
   */
  public function relations()
  {
    // NOTE: you may need to adjust the relation name and 
//the related class name for the relations automatically generated 
//below.
    return array(
 **'owner' => array(self::BELONGS_TO, 'User', 'owner_id'),**
      'project' => array(self::BELONGS_TO, 'Project', 'project_id'),
 **'requester' => array(self::BELONGS_TO, 'User', 'requester_id'),**
    );
  }

突出显示的代码是我们需求最相关的。ownerrequester属性都被定义为与User模型类的关系。这些定义指定这些属性的值是User模型类的实例。owner_idrequester_id参数指定了它们各自User类实例的唯一主键。因此,我们可以像访问Issue模型类的其他属性一样访问这些属性。

为了显示所有者和请求者User类实例的用户名,我们再次将CDetailView配置更改为以下内容:

<?php $this->widget('zii.widgets.CDetailView', array(
  'data'=>$model,
  'attributes'=>array(
    'id',
    'name',
    'description',
    array(        
      'name'=>'type_id',
        'value'=>CHtml::encode($model->getTypeText())
    ),
    array(        
      'name'=>'status_id',
        'value'=>CHtml::encode($model->getStatusText())
    ),
 **array(** 
 **'name'=>'owner_id',**
 **'value'=>isset($model->owner)?CHtml::encode($model->owner->username):"unknown"**
 **),**
 **array(** 
 **'name'=>'requester_id',**
 **'value'=>isset($model->requester)?CHtml::encode($model->requester->username):"unknown"      ),**
  ),
)); ?>

做出这些改变后,我们的问题详细列表开始看起来相当不错。以下截图显示了我们迄今为止取得的进展:

使用关联 AR

做一些最终的导航调整

我们非常接近完成本章中设定要实现的功能。唯一剩下的事情就是稍微清理一下我们的导航。您可能已经注意到,仍然有一些选项可供用户在项目上下文之外导航到整个问题列表,或者创建一个新问题。对于我们的 TrackStar 应用程序,我们对问题的所有操作都应该在特定项目的上下文中进行。我们之前已经强制要求在创建新问题时使用项目上下文,这是一个很好的开始,但我们仍然需要做一些改变。

我们会注意到的一件事是,应用程序仍然允许用户导航到跨所有项目的所有问题列表。例如,在问题详情页面,如http://localhost/trackstar/index.php?r=issue/view&id=1,我们看到右侧菜单导航中有问题列表管理问题的链接,分别对应http://localhost/trackstar/index.php?r=issue/indexhttp://localhost/trackstar/index.php?r=issue/admin(请记住,要访问管理页面,您必须以admin/admin身份登录)。这些链接仍然显示所有项目的所有问题。因此,我们需要将此列表限制为特定项目。

由于这些链接源自问题详情页面,并且特定问题有关联的项目,我们可以首先修改链接以传递特定项目 ID,然后将该项目 ID 作为限制问题查询的条件,分别在IssueController::actionIndex()IssueController::actionAdmin()方法中使用。

首先让我们修改链接。打开/protected/views/issue/view.php文件,找到文件顶部的菜单项数组。将菜单配置更改为:

$this->menu=array(
 **array('label'=>'List Issues', 'url'=>array('index', 'pid'=>$model->project->id)),**
 **array('label'=>'Create Issue', 'url'=>array('create', 'pid'=>$model->project->id)),**
  array('label'=>'Update Issue', 'url'=>array('update', 'id'=>$model->id)),
  array('label'=>'Delete Issue', 'url'=>'#', 'linkOptions'=>array('submit'=>array('delete','id'=>$model->id),'confirm'=>'Are you sure you want to delete this item?')),
 **array('label'=>'Manage Issues', 'url'=>array('admin', 'pid'=>$model->project->id)),**
);

所做的更改已经突出显示。我们已经在创建问题链接以及问题列表页面和问题管理列表页面中添加了一个新的查询字符串参数。我们已经知道我们必须对创建链接进行此更改,因为我们先前实施了一个过滤器,以强制在创建新问题之前提供有效的项目上下文。相对于此链接,我们不需要进行进一步的更改。但是对于索引和管理链接,我们需要修改它们对应的操作方法以使用这个新的查询字符串变量。

由于我们已经配置了一个过滤器来使用查询字符串变量加载关联的项目,让我们利用这一点。我们将添加到过滤器配置中,以便在执行IssueController::actionIndex()IssueController::actionAdmin()方法之前调用我们的过滤器方法。将IssueController::filters()方法更改为:

public function filters()
  {
    return array(
      'accessControl', // perform access control for CRUD operations
 **'projectContext + create index admin', //perform a check to ensure valid project context** 
    );
  }

有了这个设置,关联的项目将被加载并可供使用。让我们在IssueController::actionIndex()方法中使用它。修改该方法为:

  public function actionIndex()
  {
**$dataProvider=new CActiveDataProvider('Issue', array(**
 **'criteria'=>array(**
 **'condition'=>'project_id=:projectId',**
 **'params'=>array(':projectId'=>$this->_project->id),**
 **),**
 **));**
    $this->render('index',array(
      'dataProvider'=>$dataProvider,
    ));
  }

在这里,与以前一样,我们只是在创建模型数据提供程序的条件中添加了一个条件,以仅检索与项目相关的问题。这将限制问题列表仅显示项目下的问题。

我们需要对管理列表页面进行相同的更改。但是,这个视图文件/protected/views/issue/admin.php正在使用模型类Issue::search()方法的结果来提供问题列表。因此,我们实际上需要对这个列表强制执行项目上下文进行两次更改。

首先,我们需要修改IssueController::actionAdmin()方法,以在将模型实例发送到视图时设置正确的project_id属性。以下突出显示的代码显示了这个必要的更改:

public function actionAdmin()
  {
    $model=new Issue('search');

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

 **$model->project_id = $this->_project->id;**

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

然后我们需要在Issue::search()模型类方法中添加到我们的条件。以下突出显示的代码标识了我们需要对这个方法进行的更改:

public function search()
  {
    // Warning: Please modify the following code to remove attributes that
    // should not be searched.

    $criteria=new CDbCriteria;

    $criteria->compare('id',$this->id);

    $criteria->compare('name',$this->name,true);

    $criteria->compare('description',$this->description,true);

    $criteria->compare('type_id',$this->type_id);

    $criteria->compare('status_id',$this->status_id);

    $criteria->compare('owner_id',$this->owner_id);

    $criteria->compare('requester_id',$this->requester_id);

    $criteria->compare('create_time',$this->create_time,true);

    $criteria->compare('create_user_id',$this->create_user_id);

    $criteria->compare('update_time',$this->update_time,true);

    $criteria->compare('update_user_id',$this->update_user_id);

 **$criteria->condition='project_id=:projectID';

    $criteria->params=array(':projectID'=>$this->project_id);**

    return new CActiveDataProvider(get_class($this), array(
      'criteria'=>$criteria,
    ));
  }

在这里,我们使用$criteria->condition()直接移除了$criteria->compare()调用,该调用使用project_id的值必须完全等于我们的项目上下文。有了这些变化,管理页面上列出的问题现在被限制为仅与特定项目相关联的问题。

注意

/protected/views/issues/下的视图文件中有几个地方包含需要添加pid查询字符串才能正常工作的链接。我们将其留给读者根据这些示例提供的相同方法进行适当的更改。随着我们应用程序的开发,我们将假设所有创建新问题或显示问题列表的链接都已正确格式化,以包含适当的pid查询字符串参数。

总结

在本章中,我们涵盖了许多不同的主题。根据我们应用程序中问题项目用户之间的关系,我们在本章中实现问题管理功能的复杂性明显比我们在上一章中处理的项目实体管理要复杂得多。幸运的是,Yii 能够多次帮助我们减轻编写所有需要解决这种复杂性的代码的痛苦。

我们依靠我们的好朋友 Gii 来创建 Active Record 模型,以及对问题实体进行所有基本 CRUD 操作的初始实现。我们再次使用 Yii 迁移来帮助实现我们需要的数据库架构更改,以支持我们的问题功能。我们使用了 Yii 中的关联 Active Record,并看到使用这一特性轻松检索相关的数据库信息。我们引入了控制器过滤器作为一种手段,以在控制器动作方法之前和/或之后实现业务逻辑并进入请求生命周期。我们演示了如何在 Yii 表单中使用下拉菜单。

到目前为止,我们在基本应用程序上取得了很大进展,而且在不必编写大量代码的情况下完成了这一切。Yii 框架本身已经完成了大部分繁重的工作。我们现在有一个可以管理项目并管理项目中问题的工作应用程序。这是我们的应用程序试图实现的核心。到目前为止,我们应该为取得的成就感到自豪。

然而,在这个应用程序真正准备投入生产使用之前,我们还有很长的路要走。一个主要缺失的部分是围绕用户管理的所有必需功能。在接下来的两章中,我们将深入研究用户认证和授权。我们将首先展示 Yii 用户认证的工作原理,并开始对我们的用户进行认证,以验证他们的用户名和密码是否存储在数据库中。

第六章:用户管理和身份验证

我们在很短的时间内取得了很大的进展。我们已经奠定了 TrackStar 应用程序的基本基础。现在我们可以管理项目和项目内的问题,这是该应用程序的主要目的。当然,还有很多工作要做。

回到第三章 TrackStar 应用程序,当我们介绍这个应用程序时,我们将其描述为一个基于用户的应用程序,它提供了创建用户帐户并在用户经过身份验证和授权后授予对应用程序功能的能力。为了使这个应用程序对不止一个人有用,我们需要添加在项目内管理用户的能力。这将是接下来两章的重点。

功能规划

当我们使用yiic命令行工具最初创建 TrackStar 应用程序时,我们注意到基本的登录功能已经为我们自动创建。登录页面允许两个用户名/密码凭据组合,demo/demoadmin/admin。您可能还记得我们必须登录到应用程序中,以便在前两章中对项目和问题实体执行一些 CRUD 操作。

这个基本的身份验证骨架代码提供了一个很好的开始,但我们需要做一些改变,以支持任意数量的用户。我们还需要向应用程序添加用户 CRUD 功能,以便我们可以管理这些多个用户。本章将重点介绍扩展身份验证模型以使用tbl_user数据库表,并添加所需功能以允许基本用户数据管理。

为了实现上述目标,我们需要处理以下事项:

  • 创建将包含允许我们执行以下功能的控制器类:

  • 创建新用户

  • 从数据库中检索现有用户的列表

  • 更新/编辑现有用户

  • 删除现有用户

  • 创建视图文件和表示层逻辑,将:

  • 显示表单以允许创建新用户

  • 显示所有现有用户的列表

  • 显示表单以允许编辑现有用户

  • 添加删除按钮,以便我们可以删除用户

  • 调整创建新用户表单,以便外部用户可以使用自注册流程

  • 修改身份验证过程,以使用数据库验证登录凭据。

用户 CRUD

由于我们正在构建一个基于用户的 Web 应用程序,我们必须有一种方法来添加和管理用户。我们在第五章 管理问题中向数据库添加了tbl_user表。您可能还记得我们留给读者的练习是创建相关的 AR 模型类。如果您正在跟着做,并且没有创建必要的用户模型类,现在需要这样做。

以下是使用 Gii 代码创建工具创建模型类的简要提醒:

  1. 通过http://localhost/trackstar/index.php?r=gii导航到 Gii 工具,并选择Model Generator链接。

  2. 将表前缀保留为tbl_。在Table Name字段中填写tbl_user,这将自动填充Model Class名称字段为User

  3. 填写表单后,单击Preview按钮,获取一个链接到弹出窗口,显示即将生成的所有代码。

  4. 最后,单击Generate按钮,实际创建新的User.php模型类文件在/protected/models/目录中。

有了User AR 类,创建 CRUD 脚手架就变得很简单。我们以前使用过 Gii 工具做过这个。提醒一下,以下是必要的步骤:

  1. 通过http://localhost/trackstar/index.php?r=gii导航到工具。

  2. 从可用生成器列表中单击Crud Generator链接。

  3. Model Class名称字段中键入User。相应的Controller ID将自动填充为User

  4. 然后,您将看到在生成之前预览每个文件的选项。单击生成按钮,它将在适当的位置生成所有相关的 CRUD 文件。

有了这个,我们可以在http://localhost/trackstar/index.php?r=user/index查看我们的用户列表页面。在上一章中,我们手动创建了一些用户,以便我们可以正确处理项目、问题和用户之间的关系。这就是为什么我们在这个页面上看到了一些用户。以下截图显示了我们如何显示这个页面:

用户 CRUD

我们还可以通过访问http://localhost/trackstar/index.php?r=user/create来查看新的创建用户表单。如果您当前未登录,您将首先被路由到登录页面,然后才能查看表单。因此,您可能需要使用demo/demoadmin/admin登录以查看此表单。

在我们首先在项目实体上,然后再次在问题上创建和使用 CRUD 操作功能后,我们现在非常熟悉这些功能最初是如何由 Gii 代码生成工具实现的。用于创建和更新的生成代码是一个很好的开始,但需要一些调整以满足特定的应用程序要求。我们刚刚为创建新用户生成的表单也不例外。它为在tbl_user表中定义的每个列都有一个输入表单字段。我们不希望将所有这些字段都暴露给用户输入。最后登录时间、创建时间和用户以及更新时间和用户的列应在提交表单后以编程方式设置。

更新我们的常见审计历史列

回到之前的章节,当我们介绍我们的项目问题CRUD 功能时,我们还注意到我们的表单有比应该更多的输入字段。由于我们已经定义了所有的数据库表都有相同的创建和更新时间和用户列,我们的每个自动生成的输入表单都暴露了这些字段。在第四章中处理项目创建表单时,我们完全忽略了这些字段,项目 CRUD。然后,在第五章中,管理问题,我们采取了一步措施,从表单中删除了这些字段的显示,但我们从未添加逻辑来在添加新行时正确设置这些值。

让我们花一点时间添加这个所需的逻辑。由于我们的实体表tbl_projecttbl_issuetbl_user都定义了相同的列,我们可以将我们的逻辑添加到一个公共基类中,然后让每个单独的 AR 类从这个新的基类扩展。这是将相同功能应用于相同类型实体的常见方法。然而,Yii 组件——即CComponent的任何实例或CComponent的派生类,这通常是 Yii 应用程序中大多数类的情况——为您提供了另一种,可能更灵活的选择。

组件行为

Yii 中的行为是实现IBehavior接口的类,其方法可以通过附加到组件而不是显式扩展类来扩展组件的功能。行为可以附加到多个组件,组件可以附加多个行为。跨组件重用行为使它们非常灵活,通过能够将多个行为附加到同一个组件,我们能够为我们的 Yii 组件类实现一种多重继承

我们将使用这种方法为我们的模型类添加所需的功能。我们采取这种方法的原因是,我们的其他模型类,IssueProject,也需要相同的逻辑。与其在每个 AR 模型类中重复代码,将功能放在行为中,然后将行为附加到模型类中,将允许我们在一个地方为每个 AR 模型类正确设置这些字段。

为了让组件使用行为的方法,行为必须附加到组件上。这只需要在组件上调用attachBehavior()方法就可以了:

$component->attachBehavior($name, $behavior);

在之前的代码中,$name是组件内行为的唯一标识符。一旦附加,组件就可以调用行为类中定义的方法:

$component->myBehaviorMethod();

在之前的代码中,myBehaviorMethod()$behavior类中被定义,但可以像在$component类中定义一样调用。

对于模型类,我们可以在behaviors()方法中添加我们想要的行为,这是我们将采取的方法。现在我们只需要创建一个要附加的行为。

事实上,Yii 框架打包的 Zii 扩展库已经有一个现成的行为,可以更新我们每个基础表上的日期时间列create_timeupdate_time。这个行为叫做CTimestampBehavior。所以,让我们开始使用这个行为。

让我们从我们的User模型类开始。将以下方法添加到protected/models/User.php中:

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

在这里,我们将 Zii 扩展库的CTimestampBehavior附加到我们的User模型类上。我们已经指定了创建时间和更新时间属性,并且还配置了行为,在创建新记录时设置更新时间。有了这个设置,我们可以试一下。创建一个新用户,你会看到create_timeupdate_time记录被自动插入。很酷,对吧?

组件行为

这很棒,但我们需要在其他模型类中重复这个过程。我们可以在每个模型类中复制behaviors()方法,并且在添加更多模型类时继续这样做。或者,我们可以将其放在一个通用的基类中,并让我们的每个模型类扩展这个新的基类。这样,我们只需要定义一次behaviors()方法。

当我们保存和更新记录时,我们还需要插入我们的create_user_idupdate_user_id列。我们可以以多种方式处理这个问题。由于一个组件可以附加多个行为,我们可以创建一个类似于CTimestampBehavior的新行为,用于更新创建和更新用户 ID 列。或者,我们可以简单地扩展CTimestampBehavior,并在这个子类中添加额外的功能。或者我们可以直接利用模型的beforeSave事件,并在那里设置我们需要的字段。在现实世界的应用中,扩展现有的行为以添加这个额外的功能可能是最合理的方法;然而,为了演示另一种方法,让我们直接利用活动记录的beforeSave事件,并在一个通用的基类中进行这个操作,所有我们的 AR 模型类都可以扩展这个基类。这样,当构建自己的 Yii 应用程序时,你将有机会接触到几种不同的方法,并有更多的选择。

所以,我们需要为我们的 AR 模型类创建一个新的基类。我们还将使这个新类成为abstract,因为它不应该直接实例化。首先,去掉User AR 类中的behaviors()方法,因为我们将把这个方法放在我们的基类中。然后创建一个新文件,protected/models/TrackStarActiveRecord.php,并添加以下代码:

<?php
abstract class TrackStarActiveRecord extends CActiveRecord
{
   /**
   * Prepares create_user_id and update_user_id attributes before saving.
   */

  protected function beforeSave()
  {

    if(null !== Yii::app()->user)
      $id=Yii::app()->user->id;
    else
      $id=1;

    if($this->isNewRecord)
      $this->create_user_id=$id;

    $this->update_user_id=$id;

    return parent::beforeSave();
  }

  /**
   * Attaches the timestamp behavior to update our create and update times
   */
  public function behaviors() 
  {
    return array(
       'CTimestampBehavior' => array(
         'class' => 'zii.behaviors.CTimestampBehavior',
         'createAttribute' => 'create_time',
         'updateAttribute' => 'update_time',
        'setUpdateOnCreate' => true,
      ),
     );
  }

}

在这里,正如讨论的那样,我们正在重写CActiveRecord::beforeSave()方法。这是CActiveRecord公开的许多事件之一,允许定制其流程工作流。有两种方法可以让我们进入记录保存工作流程,并在活动记录保存之前或之后执行任何必要的逻辑:beforeSave()afterSave()。在这种情况下,我们决定在保存活动记录之前明确设置我们的创建和更新用户字段,即在写入数据库之前。

我们通过使用属性$this->isNewRecord来确定我们是在处理新记录(即插入)还是现有记录(即更新),并相应地设置我们的字段。然后,我们确保调用父实现,通过返回parent::beforeSave()来确保它有机会做所有需要做的事情。我们对Yii::app()->user进行了NULL检查,以处理可能在 Web 应用程序上下文之外使用这个模型类的情况,例如在 Yii 控制台应用程序中(在后面的章节中介绍)。如果我们没有有效的用户,我们只是默认使用第一个用户,id = 1,我们可以设置为超级用户。

另外,正如讨论的那样,我们已经将behaviors()方法移到了这个基类中,这样所有扩展它的 AR 模型类都将具有这个行为附加。

为了尝试这个,我们现在需要修改现有的三个 AR 类Project.phpUser.phpIssue.php,使其扩展自我们的新抽象类,而不是直接扩展自CActiveRecord。因此,例如,而不是以下内容:

class User extends CActiveRecord
{
…}

我们需要有:

class User extends TrackStarActiveRecord
{ 
…}

我们需要对我们的其他模型类进行类似的更改。

现在,如果我们添加另一个新用户,我们应该看到我们的所有四个审计历史列都填充了时间戳和用户 ID。

现在,这些更改已经就位,我们应该从创建新项目、问题和用户的每个表单中删除这些字段(我们已经在上一章中从问题表单中删除了它们)。这些表单字段的 HTML 位于protected/views/project/_form.phpprotected/views/issue/_form.phpprotected/views/user/_form.php文件中。我们需要从这些文件中删除的行如下所示:

<div class="row">
    <?php echo $form->labelEx($model,'create_time'); ?>
    <?php echo $form->textField($model,'create_time'); ?>
    <?php echo $form->error($model,'create_time'); ?>
  </div>

  <div class="row">
    <?php echo $form->labelEx($model,'create_user_id'); ?>
    <?php echo $form->textField($model,'create_user_id'); ?>
    <?php echo $form->error($model,'create_user_id'); ?>
  </div>

  <div class="row">
    <?php echo $form->labelEx($model,'update_time'); ?>
    <?php echo $form->textField($model,'update_time'); ?>
    <?php echo $form->error($model,'update_time'); ?>
  </div>

  <div class="row">
    <?php echo $form->labelEx($model,'update_user_id'); ?>
    <?php echo $form->textField($model,'update_user_id'); ?>
    <?php echo $form->error($model,'update_user_id'); ?>
  </div>

并且从用户创建表单protected/views/user/_form.php中,我们也可以删除最后登录时间字段:

<div class="row">
    <?php echo $form->labelEx($model,'last_login_time'); ?>
    <?php echo $form->textField($model,'last_login_time'); ?>
    <?php echo $form->error($model,'last_login_time'); ?>
  </div>

由于我们正在从表单输入中删除这些字段,我们还应该删除相关规则方法中为这些字段定义的验证规则。这些验证规则旨在确保用户提交的数据有效且格式正确。删除规则还可以防止它们成为当我们获取所有提交的查询字符串或 POST 变量并将它们的值分配给我们的 AR 模型属性时的批量分配的一部分。例如,在 AR 模型的创建和更新控制器操作中,我们看到以下行:

$model->attributes=$_POST['User'];

这是对从提交的表单字段中的所有模型属性进行批量分配。作为一项额外的安全措施,这仅适用于为其分配了验证规则的属性。您可以使用CSafeValidator来标记模型属性,以便将其作为这种批量分配的安全属性。

由于这些字段不会由用户填写,并且我们不需要它们被大规模分配,我们可以删除这些规则。

好的,让我们把它们删除。打开protected/models/User.php,在rules()方法中删除以下两条规则:

array('create_user_id, update_user_id', 'numerical', 'integerOnly'=>true),
array('last_login_time, create_time, update_time', 'safe'),

项目和问题 AR 类定义了类似的规则,但并非完全相同。在删除这些规则时,请确保保留仍适用于用户输入字段的规则。

上面删除last_login_time属性的规则是有意的。我们也应该将其从用户输入字段中删除。这个字段需要在成功登录后自动更新。由于我们已经打开了视图文件并删除了其他字段,我们决定现在也删除这个字段。但是,在我们进行其他一些更改并涵盖其他一些主题之后,我们将等待添加必要的应用程序逻辑。

实际上,当我们还在User类的验证规则方法中时,我们应该做出另一个改变。我们希望确保每个用户的电子邮件和用户名都是唯一的。我们应该在提交表单时验证这一要求。此外,我们还应该验证提交的电子邮件数据是否符合标准的电子邮件格式。您可能还记得在第四章中,我们介绍了 Yii 的内置验证器,其中有两个非常适合我们的需求。我们将使用CEmailValidatorCUniqueValidator类来满足我们的验证需求。我们可以通过在rules()方法中添加以下两行代码来快速添加这些规则:

array('email, username', 'unique'),
array('email', 'email'),

整个User::rules()方法现在应该如下所示:

public function rules()
  {
    // NOTE: you should only define rules for those attributes that
    // will receive user inputs.
    return array(
      array('email', 'required'),
array('email, username, password', 'length', 'max'=>255,
array('email, username', 'unique'),
array('email', 'email'),
      // The following rule is used by search().
      // Please remove those attributes that should not be searched.
      array('id, email, username, password, last_login_time, create_time, create_user_id, update_time, update_user_id', 'safe', 'on'=>'search'),
    );
  }

上面规则中的unique声明是一个别名,指的是 Yii 的内置验证器CUniqueValidator。这验证了模型类属性在底层数据库表中的唯一性。通过添加这个验证规则,当尝试输入已经存在于数据库中的电子邮件和/或用户名时,我们将收到一个错误。此外,通过添加电子邮件验证,当电子邮件表单字段中的值不是正确的电子邮件格式时,我们将收到一个错误。

在上一章中创建tbl_user表时,我们添加了两个测试用户,以便我们有一些数据可以使用。这两个用户中的第一个用户的电子邮件地址是test1@notanaddress.com。尝试使用相同的电子邮件添加另一个用户。以下截图显示了尝试后收到的错误消息以及错误字段的高亮显示:

组件行为

提交一个不符合有效电子邮件格式的值也会产生错误消息。

添加密码确认字段

除了刚刚做的更改之外,我们还应该添加一个新字段,强制用户确认他们输入的密码。这是用户注册表单上的标准做法,有助于用户在输入这一重要信息时不出错。幸运的是,Yii 还带有另一个内置的验证器CCompareValidator,它正是你所想的那样。它比较两个属性的值,如果它们不相等,则返回错误。

为了利用这个内置的验证,我们需要在我们的模型类中添加一个新的属性。在User模型 AR 类的顶部添加以下属性:

public $password_repeat;

我们通过在要比较的属性名称后附加_repeat来命名此属性。比较验证器允许您指定任意两个属性进行比较,或将属性与常量值进行比较。如果在声明比较规则时未指定比较属性或值,它将默认查找以与要比较的属性相同的名称开头的属性,并在末尾附加_repeat。这就是我们以这种方式命名属性的原因。现在我们可以在User::rules()方法中添加一个简单的验证规则,如下所示:

array('password', 'compare'),

如果不使用_repeat约定,您需要指定要执行比较的属性。例如,如果我们想要将$password属性与名为$confirmPassword的属性进行比较,我们可以使用:

array('password', 'compare', 'compareAttribute'=>'confirmPassword'),

由于我们已经明确将$password_repeat属性添加到用户 AR 类中,并且没有为其定义验证规则,因此当调用setAttributes()方法时,我们还需要告诉模型类允许以批量方式设置此字段。如前所述,我们通过将新属性明确添加到User模型类的safe属性列表中来实现这一点。要做到这一点,请将以下内容添加到User::rules()数组中:

array('password_repeat', 'safe'),

让我们对验证规则做出一次更改。我们当前在用户表单上拥有的所有字段都应该是必填的。目前,我们的必填规则只适用于email字段。在我们对User::rules()方法进行更改时,让我们也将用户名和密码添加到此列表中:

array('email, username, password, password_repeat', 'required'),

注意

有关验证规则的更多信息,请参见:www.yiiframework.com/doc/guide/1.1/en/form.model#declaring-validation-rules

好的,现在我们所有的规则都已设置。但是,我们仍然需要向表单添加密码确认字段。现在让我们来做这件事。

要添加此字段,请打开protected/views/user/_form.php,并在密码字段下方添加以下代码块:

<div class="row">
    <?php echo $form->labelEx($model,'password_repeat'); ?>
    <?php echo $form->passwordField($model,'password_repeat',array('size'=>60,'maxlength'=>255)); ?>
    <?php echo $form->error($model,'password_repeat'); ?>
  </div>

在所有这些表单更改就位后,创建用户表单应如下截图所示:

添加密码确认字段

现在,如果我们尝试使用密码密码重复字段中的不同值提交表单,我们将会收到如下截图所示的错误:

添加密码确认字段

对密码进行哈希处理

在我们离开新用户创建过程之前,我们应该做的最后一个更改是在将用户的密码存储到数据库之前创建其哈希版本。在将敏感用户信息添加到持久存储之前应用单向哈希算法是一种非常常见的做法。

我们将利用CActiveRecord的另一种方法来将此逻辑添加到User.php AR 类中,该方法允许我们自定义默认的活动记录工作流程。这次我们将重写afterValidate()方法,并在验证所有输入字段但在保存记录之前对密码应用基本的单向哈希。

注意

与我们在设置创建和更新时间戳时使用CActiveRecord::beforeSave()方法类似,这里我们正在重写CActiveRecord::beforeValidate()方法。这是CActiveRecord公开的许多事件之一,允许自定义其流程工作流程。快速提醒一下,如果在调用 AR 类的save()方法时没有显式发送false作为参数,验证过程将被触发。该过程执行 AR 类中rules()方法中指定的验证。有两种公开的方法允许我们进入验证工作流程并在验证执行之前或之后执行任何必要的逻辑,即beforeValidate()afterValidate()。在这种情况下,我们决定在执行验证后立即对密码进行哈希处理。

打开User AR 类,并在类底部添加以下内容:

    /**
   * apply a hash on the password before we store it in the database
   */
  protected function afterValidate()
  {   
    parent::afterValidate();
  if(!$this->hasErrors())
      $this->password = $this->hashPassword($this->password);
  }

  /**
   * Generates the password hash.
   * @param string password
     * @return string hash
   */
    public function hashPassword($password)
  {
    return md5($password);
  }

注意

我们在上一章中提到过这一点,但值得再次提及。我们在这里使用单向 MD5 哈希算法是因为它易于使用,并且在 MySQL 和 PHP 的 5.x 版本中广泛可用。然而,现在已经知道 MD5 在安全方面作为单向哈希算法是“破解”的,因此不建议在生产环境中使用此哈希算法。请考虑在真正的生产应用程序中使用 Bcrypt。以下是一些提供有关 Bcrypt 更多信息的网址:

有了这个配置,它将在所有其他属性验证成功通过之后对密码进行哈希处理。

注意

这种方法对于全新的记录来说效果很好,但是对于更新来说,如果用户没有更新他/她的密码信息,就有可能对已经进行过哈希处理的值再次进行哈希处理。我们可以用多种方式来处理这个问题,但是为了简单起见,我们需要确保每次用户想要更新他们的用户数据时,我们都要求他们提供有效的密码。

现在我们有能力向我们的应用程序添加新用户。由于我们最初使用 Gii 工具的Crud Generator链接创建了这个表单,我们还为用户拥有了读取、更新和删除功能。通过添加一些新用户,查看他们的列表,更新一些信息,然后删除一些条目来测试一下,确保一切都按预期工作。(请记住,您需要以admin身份登录,而不是demo,才能执行删除操作。)

使用数据库对用户进行认证

正如我们所知,通过使用yiic命令创建我们的新应用程序,为我们创建了一个基本的登录表单和用户认证过程。这种认证方案非常简单。它会检查输入表单的用户名/密码值,如果它们是demo/demoadmin/admin,就会通过,否则就会失败。显然,这并不是一个永久的解决方案,而是一个构建的基础。我们将通过改变认证过程来使用我们已经作为模型的一部分拥有的tbl_user数据库表来构建。但在我们开始改变默认实现之前,让我们更仔细地看一下 Yii 是如何实现认证模型的。

介绍 Yii 认证模型

Yii 认证框架的核心是一个名为user的应用组件,通常情况下,它是一个实现了IWebUser接口的对象。我们默认实现所使用的具体类是框架类CWebUser。这个用户组件封装了应用程序当前用户的所有身份信息。这个组件在我们使用yiic工具创建应用程序时,作为自动生成的应用程序代码的一部分为我们配置好了。配置可以在protected/config/main.php文件的components数组元素下看到:

'user'=>array(
  // enable cookie-based authentication
  'allowAutoLogin'=>true,
),

由于它被配置为一个应用程序组件,名称为'user',我们可以在整个应用程序中的任何地方使用Yii::app()->user来访问它。

我们还注意到类属性allowAutoLogin也在这里设置了。这个属性默认值为false,但将其设置为true可以使用户信息存储在持久性浏览器 cookie 中。然后这些数据将用于在后续访问时自动对用户进行身份验证。这将允许我们在登录表单上有一个记住我复选框,这样用户可以选择的话,在后续访问网站时可以自动登录应用程序。

Yii 认证框架定义了一个单独的实体来容纳实际的认证逻辑。这被称为身份类,通常可以是任何实现了IUserIdentity接口的类。这个类的主要作用之一是封装认证逻辑,以便轻松地允许不同的实现。根据应用程序的要求,我们可能需要验证用户名和密码与存储在数据库中的值匹配,或者允许用户使用他们的 OpenID 凭据登录,或者集成现有的 LDAP 方法。将特定于认证方法的逻辑与应用程序登录过程的其余部分分离,使我们能够轻松地在这些实现之间切换。身份类提供了这种分离。

当我们最初创建应用程序时,一个用户身份类文件,即 protected/components/UserIdentity.php,是为我们生成的。它扩展了 Yii 框架类 CUserIdentity,这是一个使用用户名和密码的身份验证实现的基类。让我们更仔细地看一下为这个类生成的代码:

<?php
/**
 * UserIdentity represents the data needed to identity a user.
 * It contains the authentication method that checks if the provided
 * data can identify the user.
 */
class UserIdentity extends CUserIdentity
{
  /**
   * Authenticates a user.
   * The example implementation makes sure if the username and password
   * are both 'demo'.
   * In practical applications, this should be changed to authenticate
   * against some persistent user identity storage (e.g. database).
   * @return boolean whether authentication succeeds.
   */
  public function authenticate()
  {
    $users=array(
      // username => password
      'demo'=>'demo',
      'admin'=>'admin',
    );
    if(!isset($users[$this->username]))
      $this->errorCode=self::ERROR_USERNAME_INVALID;
    else if($users[$this->username]!==$this->password)
      $this->errorCode=self::ERROR_PASSWORD_INVALID;
    else
      $this->errorCode=self::ERROR_NONE;
    return !$this->errorCode;
  }
}

定义身份类的大部分工作是实现 authenticate() 方法。这是我们放置特定于身份验证方法的代码的地方。这个实现简单地使用硬编码的用户名/密码值 demo/demoadmin/admin。它检查这些值是否与用户名和密码类属性(在父类 CUserIdentity 中定义的属性)匹配,如果不匹配,它将设置并返回适当的错误代码。

为了更好地理解这些部分如何适应整个端到端的身份验证过程,让我们从登录表单开始逐步解释逻辑。如果我们导航到登录页面,http://localhost/trackstar/index.php?r=site/login,我们会看到一个简单的表单,允许输入用户名、密码,以及我们之前讨论过的记住我下次功能的可选复选框。提交这个表单会调用 SiteController::actionLogin() 方法中包含的逻辑。以下序列图描述了在成功登录时从提交表单开始发生的类交互。

引入 Yii 身份验证模型

这个过程从将表单模型类 LoginForm 上的类属性设置为提交的表单值开始。然后调用 LoginForm->validate() 方法,根据 rules() 方法中定义的规则验证这些属性值。这个方法定义如下:

public function rules()
{
  return array(
    // username and password are required
    array('username, password', 'required'),
    // rememberMe needs to be a boolean
    array('rememberMe', 'boolean'),
    // password needs to be authenticated
    array('password', 'authenticate'),
  );
}

最后一个规则规定,密码属性要使用自定义方法 authenticate() 进行验证,这个方法也在 LoginForm 类中定义如下:

/**
   * Authenticates the password.
   * This is the 'authenticate' validator as declared in rules().
   */
  public function authenticate($attribute,$params)
  {
    $this->_identity=new UserIdentity($this->username,$this->password);
    if(!$this->_identity->authenticate())
      $this->addError('password','Incorrect username or password.');
  }

继续按照序列图的顺序,LoginForm 中的密码验证调用了同一类中的 authenticate() 方法。该方法创建了一个正在使用的身份验证身份类的新实例,本例中是 /protected/components/UserIdentity.php,然后调用它的 authenticate() 方法。这个方法,UserIdentity::authenticate() 如下:

/**
   * Authenticates a user.
   * The example implementation makes sure if the username and password
   * are both 'demo'.
   * In practical applications, this should be changed to authenticate
   * against some persistent user identity storage (e.g. database).
   * @return boolean whether authentication succeeds.
   */
  public function authenticate()
  {
    $users=array(
      // username => password
      'demo'=>'demo',
      'admin'=>'admin',
    );
    if(!isset($users[$this->username]))
      $this->errorCode=self::ERROR_USERNAME_INVALID;
    else if($users[$this->username]!==$this->password)
      $this->errorCode=self::ERROR_PASSWORD_INVALID;
    else
      $this->errorCode=self::ERROR_NONE;
    return !$this->errorCode;
  }

这是为了使用用户名和密码进行身份验证。在这个实现中,只要用户名/密码组合是 demo/demoadmin/admin,这个方法就会返回 true。由于我们正在进行成功的登录,身份验证成功,然后 SiteController 调用 LoginForm::login() 方法,如下所示:

/**
   * Logs in the user using the given username and password in the model.
   * @return boolean whether login is successful
   */
  public function login()
  {
    if($this->_identity===null)
    {
      $this->_identity=new UserIdentity($this->username,$this->password);
      $this->_identity->authenticate();
    }
    if($this->_identity->errorCode===UserIdentity::ERROR_NONE)
    {
      $duration=$this->rememberMe ? 3600*24*30 : 0; // 30 days
      Yii::app()->user->login($this->_identity,$duration);
      return true;
    }
    else
      return false;
  }

我们可以看到,这反过来调用了 Yii::app()->user->login(即 CWebUser::login()),传入 CUserIdentity 类实例以及要设置自动登录的 cookie 的持续时间。

默认情况下,Web 应用程序配置为使用 Yii 框架类 CWebuser 作为用户应用组件。它的 login() 方法接受一个身份类和一个可选的持续时间参数,用于设置浏览器 cookie 的生存时间。在前面的代码中,我们看到如果在提交表单时选中了记住我复选框,这个时间被设置为 30 天。如果你不传入一个持续时间,它会被设置为零。零值将导致根本不创建任何 cookie。

CWebUser::login() 方法获取身份类中包含的信息,并将其保存在持久存储中,以供用户会话期间使用。默认情况下,这个存储是 PHP 会话存储。

完成所有这些后,由我们的控制器类最初调用的LoginForm上的login()方法返回true,表示成功登录。然后,控制器类将重定向到Yii::app()->user->returnUrl中的 URL 值。如果您希望确保用户被重定向回其先前的页面,即在他们决定(或被迫)登录之前在应用程序中的任何位置,可以在应用程序的某些页面上设置此值。此值默认为应用程序入口 URL。

更改身份验证实现

现在我们了解了整个身份验证过程,我们可以很容易地看到我们需要在哪里进行更改,以使用我们的tbl_user表来验证通过登录表单提交的用户名和密码凭据。我们可以简单地修改用户身份类中的authenticate()方法,以验证是否存在与提供的用户名和密码值匹配的行。由于目前在我们的UserIdentity.php类中除了 authenticate 方法之外没有其他内容,让我们完全用以下代码替换此文件的内容:

<?php

/**
 * UserIdentity represents the data needed to identity a user.
 * It contains the authentication method that checks if the provided
 * data can identity the user.
 */

class UserIdentity extends CUserIdentity
{
  private $_id;

  public function authenticate()
  {
    $user=User::model()->find('LOWER(username)=?',array(strtolower($this->username)));
    if($user===null)
      $this->errorCode=self::ERROR_USERNAME_INVALID;
    else if(!$user->validatePassword($this->password))
      $this->errorCode=self::ERROR_PASSWORD_INVALID;
    else
    {
      $this->_id=$user->id;
      $this->username=$user->username;
$this->setState('lastLogin', date("m/d/y g:i A", strtotime($user->last_login_time)));
      $user->saveAttributes(array(
        'last_login_time'=>date("Y-m-d H:i:s", time()),
      ));
      $this->errorCode=self::ERROR_NONE;
    }
    return $this->errorCode==self::ERROR_NONE;
  }

  public function getId()
  {
    return $this->_id;
  }
}

并且,由于我们将让我们的User模型类执行实际的密码验证,我们还需要向我们的User模型类添加以下方法:

/**
   * Checks if the given password is correct.
   * @param string the password to be validated
   * @return boolean whether the password is valid
   */
  public function validatePassword($password)
  {
    return $this->hashPassword($password)===$this->password;
  }

这个新代码有一些需要指出的地方。首先,它现在尝试通过创建一个新的User模型 AR 类实例来从tbl_user表中检索一行,其中用户名与UserIdentity类的属性值相同(请记住,这是设置为登录表单的值)。由于在创建新用户时我们强制用户名的唯一性,这应该最多找到一个匹配的行。如果找不到匹配的行,将设置错误消息以指示用户名不正确。如果找到匹配的行,它通过调用我们的新User::validatePassword()方法来比较密码。如果密码未通过验证,将设置错误消息以指示密码不正确。

如果身份验证成功,在方法返回之前还会发生一些其他事情。首先,我们在UserIdentity类上设置了一个新的属性,用于用户 ID。父类中的默认实现是返回 ID 的用户名。由于我们使用数据库,并且将数字主键作为我们唯一的用户标识符,我们希望确保在请求用户 ID 时设置和返回此值。例如,当执行代码Yii::app()->user->id时,我们希望确保从数据库返回唯一 ID,而不是用户名。

扩展用户属性

这里发生的第二件事是在用户身份上设置一个属性,该属性是从数据库返回的最后登录时间,然后还更新数据库中的last_login_time字段为当前时间。执行此操作的特定代码如下:

$this->setState('lastLogin', date("m/d/y g:i A", strtotime($user->last_login_time)));
$user->saveAttributes(array(
  'last_login_time'=>date("Y-m-d H:i:s", time()),
));

用户应用组件CWebUser从身份类中定义的显式 ID 和名称属性派生其用户属性,然后从称为identity states的数组中设置的name=>value对中派生。这些是可以在用户会话期间持久存在的额外用户值。作为这一点的例子,我们将名为lastLogin的属性设置为数据库中last_login_time字段的值。这样,在应用程序的任何地方,都可以通过以下方式访问此属性:

Yii::app()->user->lastLogin;

我们在存储最后登录时间与 ID 时采取不同的方法的原因是ID恰好是CUserIdentity类上明确定义的属性。因此,除了nameID之外,所有需要在会话期间持久存在的其他用户属性都可以以类似的方式设置。

注意

当启用基于 cookie 的身份验证(通过将CWebUser::allowAutoLogin设置为true)时,持久信息将存储在 cookie 中。因此,您不应以与我们存储用户最后登录时间相同的方式存储敏感信息(例如您的密码)。

有了这些更改,现在您需要为数据库中tbl_user表中定义的用户提供正确的用户名和密码组合。当然,使用demo/demoadmin/admin将不再起作用。试一试。您应该能够以本章早些时候创建的任何一个用户的身份登录。如果您跟着做,并且拥有与我们相同的用户数据,那么用户名:User One,密码:test1应该可以登录。

注意

现在我们已经修改了登录流程,以便对数据库进行身份验证,我们将无法访问项目、问题或用户实体的删除功能。原因是已经设置了授权检查,以确保用户是管理员才能访问。目前,我们的数据库用户都没有配置为授权管理员。不用担心,授权是下一章的重点,所以我们很快就能再次访问该功能。

在主页上显示最后登录时间

现在我们正在更新数据库中的最后登录时间,并在登录时将其保存到持久会话存储中,让我们继续在成功登录后的欢迎屏幕上显示这个时间。这也将帮助我们确信一切都按预期工作。

打开负责显示主页的默认视图文件protected/views/site/index.php。在欢迎语句下面添加以下突出显示的代码行:

<h1>Welcome to <i><?php echo CHtml::encode(Yii::app()->name); ?></i></h1>
<?php if(!Yii::app()->user->isGuest):?>
<p>
   You last logged in on <?php echo Yii::app()->user->lastLogin; ?>.  
</p>
<?php endif;?>

既然我们已经在这里,让我们继续删除所有其他自动生成的帮助文本,即我们刚刚添加的代码行下面的所有内容。保存并再次登录后,您应该看到类似以下截图的内容,显示欢迎消息,然后是格式化的时间,指示您上次成功登录的时间:

在主页上显示最后登录时间

总结

这一章是我们专注于用户管理、身份验证和授权的两章中的第一章。我们创建了管理应用程序用户的 CRUD 操作的能力,并在此过程中对新用户创建流程进行了许多调整。我们为所有活动记录类添加了一个新的基类,以便轻松管理存在于所有表上的审计历史表列。我们还更新了代码,以正确管理我们在数据库中存储的用户最后登录时间。在这样做的过程中,我们学习了如何利用CActiveRecord验证工作流来允许预验证/后验证和预保存/后保存处理。

然后,我们专注于理解 Yii 身份验证模型,以便增强它以满足我们应用程序的要求,以便用户凭据被验证为存储在数据库中的值。

现在我们已经涵盖了身份验证,我们可以将重点转向 Yii 身份验证和授权框架的第二部分,授权。这是下一章的重点。

第七章:用户访问控制

基于用户的 Web 应用程序,如我们的 TrackStar 应用程序,通常需要根据请求的发起者来控制对某些功能的访问。当我们谈论用户访问控制时,我们在高层次上指的是应用程序在进行请求时需要询问的一些问题。这些问题是:

  • 谁在发起请求?

  • 该用户是否有适当的权限来访问所请求的功能?

这些问题的答案有助于应用程序做出适当的响应。

在第六章中完成的工作为我们的应用程序提供了回答这些问题的能力。应用程序现在允许用户建立自己的身份验证凭据,并在用户登录时验证用户名和密码。成功登录后,应用程序确切地知道谁在发起后续的请求。

在本章中,我们将专注于帮助应用程序回答第二个问题。一旦用户提供了适当的身份识别,应用程序需要一种方法来确定他们是否也有权限执行所请求的操作。我们将通过利用 Yii 的用户访问控制功能来扩展我们的基本授权模型。Yii 提供了简单的访问控制过滤器以及更复杂的基于角色的访问控制RBAC)实现,以帮助我们满足用户授权的要求。在实现 TrackStar 应用程序的用户访问要求时,我们将更仔细地研究这两者。

功能规划

当我们在第三章中首次介绍我们的 TrackStar 应用程序时,我们提到应用程序有两个高级用户状态,即匿名和已验证。这只是区分了已成功登录(已验证)和未登录(匿名)的用户。我们还介绍了已验证用户在项目内拥有不同角色的概念。在特定项目中,用户可以担任以下三种角色之一:

  • 项目所有者对项目拥有全部的管理访问权限

  • 项目成员具有一些管理访问权限,但与项目所有者相比,访问权限更有限

  • 项目读者具有只读访问权限。这样的用户无法更改项目的内容

本章的重点是实施一种管理授予应用程序用户的访问控制的方法。我们需要一种方式来创建和管理我们的角色和权限,将它们分配给用户,并强制我们对每个用户角色想要的访问控制规则。

为了实现前面概述的目标,我们将在本章中专注于以下内容:

  • 实施一种策略,强制用户在获得任何项目或问题相关功能的访问权限之前先登录

  • 创建用户角色并将这些角色与特定的权限结构关联起来

  • 实现将用户分配到角色(及其相关权限)的能力

  • 确保我们的角色和权限结构存在于每个项目的基础上(即允许用户在不同项目中拥有不同的权限)

  • 实现将用户关联到项目以及同时关联到项目内的角色的能力

  • 在整个应用程序中实施必要的授权访问检查,以根据其权限适当地授予或拒绝应用程序用户的访问权限

幸运的是,Yii 自带了许多内置功能,帮助我们实现这些要求。所以,让我们开始吧。

访问控制过滤器

我们在第五章中首次介绍了filters,当我们在允许使用问题功能之前强制执行有效的项目上下文时。如果您还记得,我们在IssueController类中添加了一个类方法过滤器filterProjectContext(),以确保在对问题实体执行任何操作之前,我们有一个有效的项目上下文。Yii 提供了一种类似的方法,用于在控制器中逐个操作处理简单的访问控制。

Yii 框架提供了一个名为accessControl的过滤器。这个过滤器可以直接在控制器类中使用,以提供一个授权方案,用于验证用户是否可以访问特定的控制器操作。实际上,敏锐的读者会记得,当我们在第五章中实现projectContext过滤器时,我们注意到这个访问控制过滤器已经包含在我们的IssueControllerProjectController类的过滤器列表中,如下所示:

/**
 * @return array action filters
 */
public function filters()
{
return array(
'accessControl', // perform access control for CRUD operations
);
}

这是使用 Gii CRUD 代码生成工具生成的自动生成代码中包含的。自动生成的代码还覆盖了accessRules()方法,这是必要的,以便使用访问控制过滤器。在这个方法中,您定义实际的授权规则。

我们的 CRUD 操作的默认实现设置为允许任何人查看现有问题和项目的列表。但是,它限制了创建和更新的访问权限,只允许经过身份验证的用户,并进一步将删除操作限制为特殊的admin用户。您可能还记得,当我们首次在项目上实现 CRUD 操作时,我们必须先登录才能创建新项目。在处理问题和用户时也是如此。控制这种授权和访问的机制正是这个访问控制过滤器。让我们更仔细地看一下ProjectController.php类文件中的这个实现。

ProjectController类中有两个与访问控制相关的方法:filters()accessRules()filters()方法配置过滤器。

/**
 * @return array action filters
 */
public function filters()
{
return array(
'accessControl', // perform access control for CRUD operations
);
}

accessRules() 方法用于定义访问过滤器使用的授权规则,如下所示:

/**
* Specifies the access control rules.
* This method is used by the 'accessControl' filter.
* @return array access control rules
*/
public function accessRules()
{
return array(
array('allow',  // allow all users to perform 'index' and 'view' actions
'actions'=>array('index','view'),
'users'=>array('*'),
),
array('allow', // allow authenticated user to perform 'create' and 'update' actions
'actions'=>array('create','update'),
'users'=>array('@'),
),
array('allow', // allow admin user to perform 'admin' and 'delete' actions
'actions'=>array('admin','delete'),
'users'=>array('admin'),
),
array('deny',  // deny all users
'users'=>array('*'),
),
);
}

filters() 方法对我们来说已经很熟悉了。在这里,我们指定控制器类中要使用的所有过滤器。在这种情况下,我们只有一个accessControl,它是 Yii 框架提供的一个过滤器。这个过滤器使用另一个方法accessRules(),它定义了驱动访问限制的规则。

accessRules()方法中,指定了四条规则。每条规则都表示为一个数组。数组的第一个元素要么是allow,要么是deny。它们分别表示授予或拒绝访问。数组的其余部分由name=>value对组成,指定了规则的其余参数。

让我们先看一下之前定义的第一条规则:

array('allow',  // allow all users to perform 'index' and 'view' actions
'actions'=>array('index','view'),
'users'=>array('*'),
),

这条规则允许任何用户执行actionIndex()actionView()控制器操作。在'users'元素的值中使用的星号(*)是一种用于指定任何用户(匿名、经过身份验证或其他方式)的特殊字符。

现在让我们来看一下定义的第二条规则:

array('allow', // allow authenticated user to perform 'create' and 'update' actions
'actions'=>array('create','update'),
'users'=>array('@'),
),

这允许任何经过身份验证的用户访问actionCreate()actionUpdate()控制器操作。@特殊字符是一种指定任何经过身份验证的用户的方式。

第三条规则在以下代码片段中定义:

array('allow', // allow admin user to perform 'admin' and 'delete' actions
'actions'=>array('admin','delete'),
'users'=>array('admin'),
),

这条规则指定了一个名为admin的特定用户被允许访问actionAdmin()actionDelete()控制器操作。

最后,让我们更仔细地看一下第四条规则:

array('deny',  // deny all users
'users'=>array('*'),
),

这条规则拒绝所有用户访问所有控制器操作。我们稍后会更详细地解释这一点。

可以使用多个上下文参数来定义访问规则。前面提到的规则正在指定动作和用户来创建规则上下文,但是还有其他几个参数可以使用。以下是其中一些:

  • 控制器:指定规则应用的控制器 ID 数组。

  • 角色:指定规则适用的授权项(角色、操作和权限)列表。这利用了我们将在下一节讨论的 RBAC 功能。

  • IP 地址:指定此规则适用的客户端 IP 地址列表。

  • 动词:指定适用于此规则的 HTTP 请求类型(GET、POST 等)。

  • 表达式:指定一个 PHP 表达式,其值指示是否应用规则。

  • 动作:通过相应的动作 ID 指定动作方法,该规则应匹配到该动作。

  • 用户:指定规则应用的用户。当前应用用户的名称属性用于匹配。这里也可以使用以下三个特殊字符:

  1. *****:任何用户

  2. ?:匿名用户

  3. @:认证用户

如果没有指定用户,规则将适用于所有用户。

访问规则按照它们被指定的顺序逐一进行评估。与当前模式匹配的第一个规则确定授权结果。如果这个规则是一个允许规则,那么动作可以被执行;如果它是一个“拒绝”规则,那么动作就不能被执行;如果没有规则匹配上下文,动作仍然可以被执行。这就是前面提到的第四条规则的定义原因。如果我们没有在规则列表的末尾定义一个拒绝所有用户的规则,那么我们就无法实现我们期望的访问限制。举个例子,看看第二条规则。它指定认证用户可以访问 actioncreate()actionUpdate() 动作。然而,它并没有规定匿名用户被拒绝访问。它对匿名用户什么也没说。前面提到的第四条规则确保了所有其他不匹配前三个具体规则的请求被拒绝访问。

有了这个设置,对匿名用户拒绝访问所有项目、问题和用户相关功能的应用程序进行更改就很容易。我们只需要将用户数组值的特殊字符*更改为@特殊字符。这将只允许认证用户访问 actionIndex()actionView() 控制器动作。所有其他动作已经限制为认证用户。

现在,我们可以在我们的项目、问题和用户控制器类文件中每次进行三次更改。然而,我们有一个基础控制器类,每个类都是从中扩展出来的,即文件 protected/components/Controller.php 中的 Controller 类。因此,我们可以在这一个文件中添加我们的 CRUD 访问规则,然后从每个子类中删除它。我们还可以在定义规则时利用 controllers 上下文参数,以便它只适用于这三个控制器。

首先,让我们在我们的基础控制器类中添加必要的方法。打开 protected/components/Controller.php 并添加以下方法:

/**
 * Specifies the access control rules.
 * This method is used by the 'accessControl' filter.
 * @return array access control rules
 */
public function accessRules()
{
return array(
array('allow',  // allow all users to perform 'index' and 'view' actions
**'controllers'=>array('issue','project','user'),**
'actions'=>array('index','view'),
**'users'=>array('@'),**
),
array('allow', // allow authenticated user to perform 'create' and 'update' actions
**'controllers'=>array('issue','project','user'),**
'actions'=>array('create','update'),
'users'=>array('@'),
),
array('allow', // allow admin user to perform 'admin' and 'delete' actions
**'controllers'=>array('issue','project','user'),**
'actions'=>array('admin','delete'),
'users'=>array('admin'),
),
array('deny',  // deny all users
**'controllers'=>array('issue','project','user'),**
'users'=>array('*'),
),
);
}

在前面代码片段中突出显示的代码显示了我们所做的更改。我们已经为每个规则添加了 controllers 参数,并将索引和查看动作的用户更改为只允许认证用户。

现在我们可以从每个指定的控制器中删除这个方法。打开 ProjectController.phpIssueController.phpUserController.php 三个文件,并删除它们各自的 accessRules() 方法。

做出这些更改后,应用程序将在访问我们的项目问题用户功能之前要求登录。我们仍然允许匿名用户访问SiteController类的操作方法,因为这是我们的登录操作所在的地方。显然,如果我们尚未登录,我们必须能够访问登录页面。

基于角色的访问控制

现在我们已经使用简单的访问控制过滤器限制了经过身份验证的用户的访问权限,我们需要转而关注满足应用程序更具体的访问控制需求。正如我们提到的,用户将在项目中扮演特定的角色。项目将有所有者类型的用户,可以被视为项目管理员。他们将被授予操纵项目的所有访问权限。项目还将有成员类型的用户,他们将被授予对项目功能的一些访问权限,但是比所有者能够执行的操作要少。最后,项目可以有读者类型的用户,他们只能查看与项目相关的内容,而不能以任何方式更改它。为了根据用户的角色实现这种类型的访问控制,我们转向 Yii 的基于角色的访问控制功能,也简称为 RBAC。

RBAC 是计算机系统安全中管理经过身份验证用户的访问权限的一种成熟方法。简而言之,RBAC 方法在应用程序中定义角色。还定义了执行某些操作的权限,然后将其与角色关联起来。然后将用户分配给一个角色,并通过角色关联获得为该角色定义的权限。对于对 RBAC 概念和方法感兴趣的读者,有大量的文档可供参考。例如维基百科,en.wikipedia.org/wiki/Role-based_access_control。我们将专注于 Yii 对 RBAC 方法的具体实现。

Yii 对 RBAC 的实现简单、优雅且强大。在 Yii 中,RBAC 的基础是授权项的概念。授权项简单地是应用程序中执行操作的权限。这些权限可以被归类为角色任务操作,因此形成了一个权限层次结构。角色可以包括任务(或其他角色),任务可以包括操作(或其他任务),操作是最粒度的权限级别。

例如,在我们的 TrackStar 应用程序中,我们需要一个所有者类型的角色。因此,我们将创建一个角色类型的授权项,并将其命名为“所有者”。然后,这个角色可以包括诸如“用户管理”和“问题管理”之类的任务。这些任务可以进一步包括组成这些任务的原子操作。继续上面的例子,“用户管理”任务可以包括“创建新用户”、“编辑用户”和“删除用户”操作。这种层次结构允许继承这些权限,因此,以这个例子为例,如果一个用户被分配到所有者角色,他们就会继承对用户执行创建、编辑和删除操作的权限。

在 RBAC 中,通常你会将用户分配给一个或多个角色,用户会继承这些角色被分配的权限。在 Yii 中也是如此。然而,在 Yii 中,我们可以将用户与任何授权项关联,而不仅仅是角色类型的授权项。这使我们能够灵活地将特定权限与用户关联在任何粒度级别上。如果我们只想将“删除用户”操作授予特定用户,而不是给予他们所有者角色所具有的所有访问权限,我们可以简单地将用户与这个原子操作关联起来。这使得 Yii 中的 RBAC 非常灵活。

配置授权管理器

在我们可以建立授权层次结构,将用户分配给角色,并执行访问权限检查之前,我们需要配置授权管理器应用程序组件authManager。这个组件负责存储权限数据和管理权限之间的关系。它还提供了检查用户是否有权执行特定操作的方法。Yii 提供了两种类型的授权管理器CPhpAuthManagerCDbAuthManagerCPhpAuthManager使用 PHP 脚本文件来存储授权数据。CDbAuthManager,正如你可能已经猜到的,将授权数据存储在数据库中。authManager被配置为一个应用程序组件。配置授权管理器只需要简单地指定使用这两种类型中的哪一种,然后设置它的初始类属性值。

我们将使用数据库实现我们的应用程序。为了进行这个配置,打开主配置文件protected/config/main.php,并将以下内容添加到应用程序组件数组中:

// application components
'components'=>array(
…
'authManager'=>array(
'class'=>'CDbAuthManager',
'connectionID'=>'db',
),

这建立了一个名为authManager的新应用程序组件,指定了类类型为CDbAuthManager,并将connectionID类属性设置为我们的数据库连接组件。现在我们可以在我们的应用程序的任何地方使用Yii::app()->authManager来访问它。

创建 RBAC 数据库表

如前所述,CDbAuthManager类使用数据库表来存储权限数据。它期望一个特定的模式。该模式在框架文件YiiRoot/framework/web/auth/schema.sql中被识别。这是一个简单而优雅的模式,由三个表AuthItemAuthItemChildAuthAssignment组成。

AuthItem表保存了定义角色、任务或操作的授权项的信息。AuthItemChild表存储了形成我们授权项层次结构的父/子关系。最后,AuthAssignment表是一个关联表,保存了用户和授权项之间的关联。

因此,我们需要将这个表结构添加到我们的数据库中。就像我们之前做过的那样,我们将使用数据库迁移来进行这些更改。从命令行,导航到 TrackStar 应用程序的/protected目录,并创建迁移:

**$ cd /Webroot/trackstar/protected**
**$ ./yiic migrate create create_rbac_tables**

这将在protected/migrations/目录下创建一个根据迁移文件命名约定命名的新迁移文件(例如,m120619_015239_create_rbac_tables.php)。实现up()down()迁移方法如下:

public function up()
{
//create the auth item table
$this->createTable('tbl_auth_item', array(
'name' =>'varchar(64) NOT NULL',
'type' =>'integer NOT NULL',
'description' =>'text',
'bizrule' =>'text',
'data' =>'text',
'PRIMARY KEY (`name`)',
), 'ENGINE=InnoDB');

//create the auth item child table
$this->createTable('tbl_auth_item_child', array(
'parent' =>'varchar(64) NOT NULL',
'child' =>'varchar(64) NOT NULL',
'PRIMARY KEY (`parent`,`child`)',
), 'ENGINE=InnoDB');

//the tbl_auth_item_child.parent is a reference to tbl_auth_item.name
$this->addForeignKey("fk_auth_item_child_parent", "tbl_auth_item_child", "parent", "tbl_auth_item", "name", "CASCADE", "CASCADE");

//the tbl_auth_item_child.child is a reference to tbl_auth_item.name
$this->addForeignKey("fk_auth_item_child_child", "tbl_auth_item_child", "child", "tbl_auth_item", "name", "CASCADE", "CASCADE");

//create the auth assignment table
$this->createTable('tbl_auth_assignment', array(
'itemname' =>'varchar(64) NOT NULL',
'userid' =>'int(11) NOT NULL',
'bizrule' =>'text',
'data' =>'text',
'PRIMARY KEY (`itemname`,`userid`)',
), 'ENGINE=InnoDB');

//the tbl_auth_assignment.itemname is a reference 
//to tbl_auth_item.name
$this->addForeignKey(
"fk_auth_assignment_itemname", 
"tbl_auth_assignment", 
"itemname", 
"tbl_auth_item", 
"name", 
"CASCADE", 
"CASCADE"
);

//the tbl_auth_assignment.userid is a reference 
//to tbl_user.id
$this->addForeignKey(
"fk_auth_assignment_userid", 
"tbl_auth_assignment", 
"userid", 
"tbl_user", 
"id", 
"CASCADE", 
"CASCADE"
);
}

public function down()
{
$this->truncateTable('tbl_auth_assignment');
$this->truncateTable('tbl_auth_item_child');
$this->truncateTable('tbl_auth_item');
$this->dropTable('tbl_auth_assignment');
$this->dropTable('tbl_auth_item_child');
$this->dropTable('tbl_auth_item');
}

保存这些更改后,运行迁移以创建所需的结构:

**$ ./yiic migrate**

一旦必要的结构被创建,你会在屏幕上看到一个成功迁移的消息。

由于我们遵循了数据库表命名约定,我们需要修改我们的authManager组件配置,以指定我们特定的表名。打开/protected/config/main.php,并将表名规范添加到authManager组件中:

// application components
'components'=>array(
…
'authManager'=>array(
'class'=>'CDbAuthManager',
'connectionID'=>'db',
'itemTable' =>'tbl_auth_item',
'itemChildTable' =>'tbl_auth_item_child',
'assignmentTable' =>'tbl_auth_assignment',
),

现在授权管理器组件将确切地知道我们希望它使用哪些表来管理我们的授权结构。

注意

如果你需要关于如何使用 Yii 数据库迁移的提醒,请参考第四章,项目 CRUD,这个概念是在那里首次介绍的。

创建 RBAC 授权层次结构

在我们的trackstar数据库中添加了这些表之后,我们需要用我们的角色和权限填充它们。我们将使用authmanager组件提供的 API 来做到这一点。为了保持简单,我们只会定义角色和基本操作。我们现在不会设置任何正式的 RBAC 任务。以下图显示了我们希望定义的基本层次结构:

创建 RBAC 授权层次结构

该图显示了自上而下的继承关系。因此,所有者拥有所有在所有者框中列出的权限,同时继承来自成员和读者角色的所有权限。同样,成员继承自读者的权限。现在我们需要做的是在应用程序中建立这种权限层次结构。如前所述,实现这一点的一种方法是编写代码来利用authManager API。

使用 API 的示例代码如下,它创建了一个新角色和一个新操作,然后添加了角色和权限之间的关系:

$auth=Yii::app()->authManager;  
$role=$auth->createRole('owner');
$auth->createOperation('createProject','create a new project');    
$role->addChild('createProject');

通过这段代码,我们首先获得了authManager的实例。然后我们使用它的createRole()createOperation()addChild()API 方法来创建一个新的owner角色和一个名为createProject的新操作。然后我们将权限添加到所有者角色。这只是演示了我们需要的层次结构的一小部分的创建;我们在前面的图表中概述的所有其余关系都需要以类似的方式创建。

我们可以创建一个新的数据库迁移,并将我们的代码放在那里以填充我们的权限层次结构。然而,为了演示在 Yii 应用程序中使用控制台命令,我们将采取不同的方法。我们将编写一个简单的 shell 命令,在命令行上执行。这将扩展我们用于创建初始应用程序的yiic命令行工具的命令选项。

编写控制台应用程序命令

我们在第二章入门中介绍了yiic命令行工具,当我们创建了一个新的“Hello, World!”应用程序时,以及在第四章项目 CRUD中,当我们用它来最初创建我们的 TrackStar web 应用程序的结构时。在创建和运行数据库迁移时,我们继续使用它。

yiic工具是 Yii 中的一个控制台应用程序,用于以命令形式执行任务。我们已经使用webapp命令创建新的应用程序,并使用migrate命令创建新的迁移文件并执行数据库迁移。Yii 中的控制台应用程序可以通过编写自定义命令轻松扩展,这正是我们要做的。我们将通过编写一个新的命令行工具来扩展yiic命令工具集,以便我们可以构建 RBAC 授权。

为控制台应用程序编写新命令非常简单。命令只是一个从CConsoleCommand扩展的类。它的工作方式类似于控制器类,它将解析输入的命令行选项,并将请求分派到命令类中指定的操作,其默认为actionIndex()。类的名称应该与所需的命令名称完全相同,后面跟着“Command”。在我们的情况下,我们的命令将简单地是“Rbac”,所以我们将我们的类命名为RbacCommand。最后,为了使这个命令可用于yiic控制台应用程序,我们需要将我们的类保存到/protected/commands/目录中,这是控制台命令的默认位置。

因此,创建一个新文件/protected/commands/RbacCommand.php。这个文件的内容太长,无法包含在内,但可以从本章的可下载代码或gist.github.com/jeffwinesett中轻松获取。这个代码片段可以在gist.github.com/3779677中找到。

可下载代码中的注释应该有助于讲述这里发生的事情。我们重写了getHelp()的基类实现,以添加一个额外的描述行。我们将在一分钟内展示如何显示帮助。所有真正的操作都发生在我们添加的两个操作actionIndex()actionDelete()中。前者创建我们的 RBAC 层次结构,后者删除它。它们都确保应用程序有一个定义的有效authManager应用程序组件。然后,这两个操作允许用户在继续之前有最后一次取消请求的机会。如果使用此命令的用户表示他们想要继续,请求将继续。我们的两个操作都将继续清除 RBAC 表中先前输入的所有数据,而actionIndex()方法将创建一个新的授权层次结构。这里创建的层次结构正是我们之前讨论的那个。

我们可以看到,即使基于我们相当简单的层次结构,仍然需要大量的代码。通常,需要开发一个更直观的图形用户界面GUI)来包装这些授权管理器 API,以提供一个易于管理角色、任务和操作的界面。我们在这里采取的方法是建立快速 RBAC 权限结构的好解决方案,但不适合长期维护可能会发生重大变化的权限结构。

注意

在现实世界的应用程序中,您很可能需要一个不同的、更交互式的工具来帮助维护 RBAC 关系。Yii 扩展库(www.yiiframework.com/extensions/)提供了一些打包的解决方案。

有了这个文件,如果我们现在询问yiic工具帮助,我们将看到我们的新命令作为可用选项之一:

编写控制台应用程序命令

我们的rbac显示在列表中。但是,在我们尝试执行之前,我们需要为控制台应用程序配置authManager。您可能还记得,运行控制台应用程序时,会加载不同的配置文件,即/protected/config/console.php。我们需要在这个文件中添加与之前添加到main.php配置文件相同的authManager组件。打开console.php并将以下内容添加到组件列表中:

'authManager'=>array(
'class'=>'CDbAuthManager',
'connectionID'=>'db',
'itemTable' =>'tbl_auth_item',
'itemChildTable' =>'tbl_auth_item_child',
'assignmentTable' =>'tbl_auth_assignment',
),

有了这个,我们现在可以尝试我们的新命令:

编写控制台应用程序命令

这正是我们在命令类的getHelp()方法中添加的帮助文本。您当然可以更详细地添加更多细节。让我们实际运行命令。由于actionIndex()是默认值,我们不必指定操作:

编写控制台应用程序命令

我们的命令已经完成,并且我们已经向新的数据库表中添加了适当的数据,以生成我们的授权层次结构。

由于我们还添加了一个actionDelete()方法来删除我们的层次结构,您也可以尝试一下:

**$ ./yiic rbac delete**

在尝试这些操作完成后,确保再次运行命令以添加层次结构,因为我们需要它继续存在。

分配用户到角色

到目前为止,我们所做的一切都建立了一个授权层次结构,但尚未为用户分配权限。我们通过将用户分配到我们创建的三个角色之一,ownermemberreader来实现这一点。例如,如果我们想要将唯一用户 ID 为1的用户与member角色关联,我们将执行以下操作:

**$auth=Yii::app()->authManager;**
**$auth->assign('member',1);**

一旦建立了这些关系,检查用户的访问权限就变得很简单。我们只需询问应用程序用户组件当前用户是否具有权限。例如,如果我们想要检查当前用户是否被允许创建新问题,我们可以使用以下语法:

if( Yii::app()->user->checkAccess('createIssue'))
{
     //perform needed logic
}

在这个例子中,我们将用户 ID 1分配给成员角色,由于在我们的授权层次结构中,成员角色继承了createIssue权限,假设我们以用户1的身份登录到应用程序中,这个if()语句将评估为true

我们将在向项目添加新成员时添加此授权分配逻辑作为业务逻辑的一部分。我们将添加一个新表单,允许我们将用户添加到项目中,并在此过程中选择角色。但首先,我们需要解决用户角色需要在每个项目基础上实施的另一个方面。

在每个项目基础上为用户添加 RBAC 角色

我们现在已经建立了一个基本的 RBAC 授权模型,但这些关系适用于整个应用程序。TrackStar 应用程序的需求稍微复杂一些。我们需要在项目的上下文中为用户分配角色,而不仅仅是在整个应用程序中全局地分配。我们需要允许用户在不同的项目中担任不同的角色。例如,用户可能是一个项目的“读者”角色,第二个项目的“成员”角色,以及第三个项目的“所有者”角色。用户可以与许多项目相关联,并且他们被分配的角色需要特定于项目。

Yii 中的 RBAC 框架没有内置的东西可以满足这个要求。RBAC 模型只旨在建立角色和权限之间的关系。它不知道(也不应该知道)我们的 TrackStar 项目的任何信息。为了实现我们授权层次结构的这个额外维度,我们需要改变我们的数据库结构,以包含用户、项目和角色之间的关联。如果您还记得第五章中的内容,管理问题,我们已经创建了一个名为tbl_project_user_assignment的表,用于保存用户和项目之间的关联。我们可以修改这个表,以包含用户在项目中分配的角色。我们将添加一个新的迁移来修改我们的表:

**$ cd /Webroot/trackstar/protected/**
**$ ./yiic migrate create add_role_to_tbl_project_user_assignment**

现在打开新创建的迁移文件,并实现以下up()down()方法:

public function up()
{
$this->addColumn('tbl_project_user_assignment', 'role', 'varchar(64)');
//the tbl_project_user_assignment.role is a reference 
     //to tbl_auth_item.name
$this->addForeignKey('fk_project_user_role', 'tbl_project_user_assignment', 'role', 'tbl_auth_item', 'name', 'CASCADE', 'CASCADE');
}

public function down()
{
$this->dropForeignKey('fk_project_user_role', 'tbl_project_user_assignment');
$this->dropColumn('tbl_project_user_assignment', 'role');
}

最后运行迁移:

在每个项目基础上为用户添加 RBAC 角色

您将在屏幕底部看到消息“成功迁移”。

现在我们的表已经设置好,可以允许我们进行角色关联以及用户和项目之间的关联。

添加 RBAC 业务规则

虽然之前显示的数据库表将保存基本信息,以回答用户是否在特定项目的上下文中被分配了角色的问题,但我们仍然需要我们的 RBACauth层次结构来回答关于用户是否有权限执行某个功能的问题。尽管 Yii 中的 RBAC 模型不知道我们的 TrackStar 项目,但它具有一个非常强大的功能,我们可以利用它。当您创建授权项或将项分配给用户时,您可以关联一小段 PHP 代码,该代码将在Yii::app()->user->checkAccess()调用期间执行。一旦定义,这段代码必须在用户被授予权限之前返回true

这个功能的一个例子是在允许用户维护个人资料信息的应用程序中。在这种情况下,应用程序希望确保用户只有权限更新自己的个人资料信息,而不是其他人的。在这种情况下,我们可以创建一个名为“updateProfile”的授权项,然后关联一个业务规则,检查当前用户的 ID 是否与与个人资料信息相关联的用户 ID 相同。

在我们的情况下,我们将为角色分配关联一个业务规则。当我们将用户分配给特定角色时,我们还将关联一个业务规则,该规则将在项目的上下文中检查关系。checkAccess()方法还允许我们传递一个附加参数数组,供业务规则使用以执行其逻辑。我们将使用这个来传递当前项目上下文,以便业务规则可以调用Project AR 类的方法,以确定用户是否在该项目中被分配到该角色。

我们将为每个角色分配创建稍有不同的业务规则。例如,当将用户分配给所有者角色时,我们将使用以下规则:

$bizRule='return isset($params["project"]) && $params["project"]->isUserInRole("owner");';

角色成员读者的方法将会相似。

当我们调用checkAccess()方法时,我们还需要传递项目上下文。因此,现在在检查用户是否有权限执行例如createIssue操作时,代码将如下所示:

//add the project AR instance to the input params
$params=array('project'=>$project);
//pass in the params to the checkAccess call
if(Yii::app()->user->checkAccess('createIssue',$params))
{
     //proceed with issue creation logic
}

在前面的代码中,$project变量是与当前项目上下文相关联的Project AR 类实例(请记住,我们应用程序中的几乎所有功能都发生在项目的上下文中)。这个类实例是业务规则中使用的。业务规则调用Project::isUserInRole()方法,以确定用户是否在特定项目的角色中。

实现新的项目 AR 方法

现在我们已经修改了数据库结构,以容纳用户、角色和项目之间的关系,我们需要实现所需的逻辑来管理和验证该表中的数据。我们将在项目 AR 类中添加公共方法,以处理从该表中添加和删除数据以及验证行的存在。

我们需要在Project AR 类中添加一个公共方法,该方法将接受角色名称和用户 ID,并创建角色、用户和项目之间的关联。打开protected/models/Project.php文件,并添加以下方法:

public function assignUser($userId, $role)
{
$command = Yii::app()->db->createCommand();
$command->insert('tbl_project_user_assignment', array(
'role'=>$role,
'user_id'=>$userId,
'project_id'=>$this->id,
));
}

在这里,我们使用 Yii 框架的查询构建器方法直接插入数据库表,而不是使用活动记录方法。由于tbl_project_user_assignement只是一个关联表,并不代表我们模型的主要领域对象,因此有时更容易以更直接的方式管理这些类型表中的数据,而不是使用活动记录方法。

注意

有关在 Yii 中使用查询构建器的更多信息,请访问:

www.yiiframework.com/doc/guide/1.1/en/database.query-builder

我们还需要能够从项目中删除用户,并在这样做时,删除用户和项目之间的关联。因此,让我们也添加一个执行此操作的方法。

Project AR 类中添加以下方法:

public function removeUser($userId)
{
$command = Yii::app()->db->createCommand();
$command->delete(
'tbl_project_user_assignment', 
'user_id=:userId AND project_id=:projectId', 
array(':userId'=>$userId,':projectId'=>$this->id));
}

这只是从包含角色、用户和项目之间关联的表中删除行。

我们现在已经实现了添加和删除关联的方法。我们需要添加功能来确定给定用户是否与项目内的角色相关联。我们还将这作为公共方法添加到我们的Project AR 类中。

Project AR 模型类的底部添加以下方法:

public function allowCurrentUser($role)
{
$sql = "SELECT * FROM tbl_project_user_assignment WHERE project_id=:projectId AND user_id=:userId AND role=:role";
$command = Yii::app()->db->createCommand($sql);
$command->bindValue(":projectId", $this->id, PDO::PARAM_INT);
$command->bindValue(":userId", Yii::app()->user->getId(), PDO::PARAM_INT);
$command->bindValue(":role", $role, PDO::PARAM_STR);
return $command->execute()==1;
}

该方法展示了如何直接执行 SQL,而不是使用查询构建器。查询构建器非常有用,但对于简单的查询,直接执行 SQL 有时更容易,利用 Yii 的数据访问对象(DAO)。

注意

有关 Yii 的数据访问对象和在 Yii 中直接执行 SQL 的更多信息,请参阅:

www.yiiframework.com/doc/guide/1.1/en/database.dao

将用户添加到项目中

现在我们需要把所有这些放在一起。在第六章中,用户管理和授权中,我们添加了创建应用程序新用户的功能。然而,我们还没有办法将用户分配给特定的项目,并进一步将他们分配到这些项目中的角色。现在我们已经有了 RBAC 方法,我们需要构建这个新功能。

这个功能的实现涉及几个编码更改。然而,我们已经提供了类似的需要的更改的示例,并在之前的章节中涵盖了所有相关的概念。因此,我们将快速地进行这个过程,并且只是简要地强调一些我们还没有看到的东西。此时,读者应该能够在没有太多帮助的情况下进行所有这些更改,并被鼓励以实践的方式这样做。为了进一步鼓励这种练习,我们将首先列出我们要做的一切来满足这个新的功能需求。然后你可以关闭书本,在查看我们的实现之前尝试一些这样的操作。

为了实现这个目标,我们将执行以下操作:

  1. Project模型类中添加一个名为getUserRoleOptions()的新公共静态方法,该方法使用auth管理器的getRoles()方法返回一个有效的角色选项列表。我们将使用这个方法来填充表单中的角色选择下拉字段,以便在向项目添加新用户时选择用户角色。

  2. Project模型类中添加一个名为isUserInProject($user)的新公共方法,以确定用户是否已经与项目关联。我们将在表单提交时使用这个方法来进行验证规则,以便我们不会尝试将重复的用户添加到项目中。

  3. 添加一个名为ProjectUserForm的新表单模型类,继承自CFormModel,用于新的输入表单模型。在这个表单模型类中添加三个属性,即$username$role$project。还要添加验证规则,以确保用户名和角色都是必需的输入字段,并且用户名应该通过自定义的verify()类方法进行进一步验证。

这个验证方法应该尝试通过查找与输入用户名匹配的用户来创建一个新的 UserAR 类实例。如果尝试成功,它应该继续使用我们之前添加的assignUser($userId, $role)方法将用户关联到项目。我们还需要在本章前面实现的 RBAC 层次结构中将用户与角色关联起来。如果没有找到与用户名匹配的用户,它需要设置并返回一个错误。(如果需要,可以查看LoginForm::authenticate()方法作为自定义验证规则方法的示例。)

  1. 在 views/project 下添加一个名为adduser.php的新视图文件,用于显示我们向项目添加用户的新表单。这个表单只需要两个输入字段,用户名角色。角色应该是一个下拉选择列表。

  2. ProjectController类中添加一个名为actionAdduser()的新控制器动作方法,并修改其accessRules()方法以确保经过身份验证的成员可以访问它。这个新的动作方法负责呈现新的视图来显示表单,并在提交表单时处理后退。

再次鼓励读者首先尝试自己进行这些更改。我们在以下部分列出了我们的代码更改。

修改项目模型类

对于Project类,我们添加了两个新的公共方法,其中一个是静态的,因此可以在不需要特定类实例的情况下调用:

   /**
 * Returns an array of available roles in which a user can be placed when being added to a project
 */
public static function getUserRoleOptions()
{
return CHtml::listData(Yii::app()->authManager->getRoles(), 'name', 'name');
} 

/* 
 * Determines whether or not a user is already part of a project
 */
public function isUserInProject($user) 
{
$sql = "SELECT user_id FROM tbl_project_user_assignment WHERE project_id=:projectId AND user_id=:userId";
$command = Yii::app()->db->createCommand($sql);
$command->bindValue(":projectId", $this->id, PDO::PARAM_INT);
$command->bindValue(":userId", $user->id, PDO::PARAM_INT);
return $command->execute()==1;
}

添加新的表单模型类

就像在登录表单的方法中使用的那样,我们将创建一个新的表单模型类,作为存放我们的表单输入参数和集中验证的中心位置。这是一个相当简单的类,它继承自 Yii 类CFormModel,并具有映射到我们表单输入字段的属性,以及一个用于保存有效项目上下文的属性。我们需要项目上下文来能够向项目添加用户。整个类太长了,无法在这里列出,但可以轻松从本章附带的可下载代码中获取。独立的代码片段可以在[gist.github.com/3779690](http:// https://gist.github.com/3779690)上找到。

在下面的代码片段中,我们列出了我们以前没有见过的部分:

class ProjectUserForm extends CFormModel
{
…
      public function assign()
{
if($this->_user instanceof User)
{
//assign the user, in the specified role, to the project
$this->project->assignUser($this->_user->id, $this->role);  
//add the association, along with the RBAC biz rule, to our RBAC hierarchy
        $auth = Yii::app()->authManager; 
$bizRule='return isset($params["project"]) && $params["project"]->allowCurrentUser("'.$this->role.'");';  
$auth->assign($this->role,$this->_user->id, $bizRule);
                  return true;
}
            else
{
$this->addError('username','Error when attempting to assign this user to the project.'); 
return false;
}
      }

注意

为了简单起见,在createUsernameList()方法中,我们选择从数据库中选择所有用户来用于用户名列表。如果有大量用户,这可能会导致性能不佳。为了优化性能,在用户数量较多的情况下,您可能需要对其进行过滤和限制。

我们在可下载的代码部分中列出的部分是assign()方法,我们在其中为用户和角色之间的关联添加了一个 bizRule:

$auth = Yii::app()->authManager; 
$bizRule='return isset($params["project"]) && $params["project"]->isUserInRole("'.$this->role.'");';
$auth->assign($this->role,$user->id, $bizRule);

我们创建了一个Authmanager类的实例,用于建立用户与角色的分配。然而,在进行分配之前,我们需要创建业务规则。业务规则使用$params数组,首先检查数组中是否存在project元素,然后在项目 AR 类上调用isUserInRole()方法,该方法是该数组元素的值。我们明确向这个方法传递角色名。然后我们调用AuthManager::assign()方法来建立用户与角色之间的关联。

我们还添加了一个简单的公共方法createUsernameList(),返回数据库中所有用户名的数组。我们将使用这个数组来填充 Yii 的 UI 小部件CJuiAutoComplete的数据,我们将用它来填充用户名输入表单元素。正如它的名字所示,当我们在输入表单字段中输入时,它将根据这个数组中的元素提供选择建议。

向项目控制器添加新的动作方法

我们需要一个控制器动作来处理显示向项目添加新用户的表单的初始请求。我们将其放在ProjectController类中,并命名为actionAdduser()。其代码如下:

     /**
 * Provides a form so that project administrators can
 * associate other users to the project
 */
public function actionAdduser($id)
{
  $project = $this->loadModel($id);
  if(!Yii::app()->user->checkAccess('createUser', array('project'=>$project)))
{
  throw new CHttpException(403,'You are not authorized to perform this action.');
}

  $form=new ProjectUserForm; 
  // collect user input data
  if(isset($_POST['ProjectUserForm']))
  {
    $form->attributes=$_POST['ProjectUserForm'];
    $form->project = $project;
    // validate user input  
    if($form->validate())  
    {
        if($form->assign())
      {
       Yii::app()->user->setFlash('success',$form->username . " has been added to the project." ); 
       //reset the form for another user to be associated if desired
      $form->unsetAttributes();
      $form->clearErrors();
      }
    }
  }
$form->project = $project;
$this->render('adduser',array('model'=>$form)); 
}

这对我们来说都很熟悉。它处理了显示表单的初始GET请求,以及表单提交后的POST请求。它非常类似于我们的SiteController::actionLogin()方法。然而,在上一个代码片段中突出显示的代码是我们以前没有见过的。如果提交的表单请求成功,它会设置一个称为flash message的东西。Flash message 是一个临时消息,暂时存储在会话中。它只在当前和下一个请求中可用。在这里,我们使用我们的CWebUser应用用户组件的setFlash()方法来存储一个临时消息,表示请求成功。当我们在下一节讨论视图时,我们将看到如何访问此消息并将其显示给用户。

我们需要做的另一个更改是基本控制器类方法Controller::accessRules()。您还记得,我们将访问规则添加到这个基类中,以便它们适用于我们的每个用户、问题和项目控制器类。我们需要将这个新动作名称添加到基本访问规则列表中,以便允许已登录用户访问此动作:

public function accessRules()
{
return array(
array('allow',  // allow all users to perform 'index' and 'view' actions
'controllers'=>array('issue','project','user'),
'actions'=>array('index','view',**'addUser'**),
'users'=>array('@'),
),

添加新的视图文件来显示表单

我们的新动作方法调用->render('adduser')来渲染一个视图文件,所以我们需要创建一个。以下是我们对protected/views/project/adduser.php的实现的完整列表:

<?php
$this->pageTitle=Yii::app()->name . ' - Add User To Project';
$this->breadcrumbs=array(
$model->project->name=>array('view','id'=>$model->project->id),
'Add User',
);
$this->menu=array(
array('label'=>'Back To Project', 'url'=>array('view','id'=>$model->project->id)),
);
?>

<h1>Add User To <?php echo $model->project->name; ?></h1>

**<?php if(Yii::app()->user->hasFlash('success')):?>**
**<div class="successMessage">**
**<?php echo Yii::app()->user->getFlash('success'); ?>**
**</div>**
**<?phpendif; ?>**

<div class="form">
<?php $form=$this->beginWidget('CActiveForm'); ?>

<p class="note">Fields with <span class="required">*</span> are required.</p>

<div class="row">
<?php echo $form->labelEx($model,'username'); ?>
<?php
$this->widget('zii.widgets.jui.CJuiAutoComplete', array(
'name'=>'username',
'source'=>$model->createUsernameList(),
'model'=>$model,
'attribute'=>'username',
'options'=>array(
'minLength'=>'2',
),
'htmlOptions'=>array(
'style'=>'height:20px;'
),
));
?>
<?php echo $form->error($model,'username'); ?>
</div>

<div class="row">
<?php echo $form->labelEx($model,'role'); ?>
<?php
echo $form->dropDownList($model,'role', 
Project::getUserRoleOptions()); ?>
<?php echo $form->error($model,'role'); ?>
</div>

<div class="row buttons">
<?php echo CHtml::submitButton('Add User'); ?>
</div>

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

我们以前大部分都见过了。我们正在定义活动标签和活动表单元素,这些元素直接与我们的ProjectUserForm表单模型类相关联。我们使用我们在项目模型类上早期实施的静态方法填充下拉菜单。我们使用createUsernameList()方法填充我们的 Zii 库自动完成小部件(CJuiAutoComplete)数据,该方法已添加到项目用户表单模型类中。我们还在菜单选项中添加了一个简单的链接,以便返回到项目详细信息页面。

在上一个代码片段中突出显示的代码对我们来说是新的。这是一个示例,说明了我们在actionAdduser()方法中引入并使用的闪烁消息。我们通过询问同一用户组件是否有闪烁消息(使用hasFlash('succcess'))来访问我们使用setFlash()设置的消息。我们向hasFlash()方法提供了我们在设置消息时给它的确切名称。这是向用户提供有关其先前请求的一些简单反馈的好方法。

我们做的另一个小改变是在项目详细信息页面中添加了一个简单的链接,以便我们可以从应用程序中访问它。以下突出显示的行已添加到项目view.php视图文件的菜单数组中:

$this->menu=array(
…
array('label'=>'Add User To Project', 'url'=>array('project/adduser', 'id'=>$model->id)),
);

这使我们在查看项目详细信息时可以访问新表单。

将所有内容放在一起

有了所有这些变化,我们可以通过查看项目详细信息页面之一来导航到我们的新表单。例如,当通过http://localhost/trackstar/index.php?r=project/view&id=1查看项目 ID#1 时,在右侧列操作菜单中有一个超链接[将用户添加到项目],单击该链接应显示以下页面:

将所有内容放在一起

您可以使用我们以前构建的表单来创建新项目和用户,以确保将其中一些添加到应用程序中。然后,您可以尝试将用户添加到项目中。当您在用户名字段中输入时,您将看到自动完成的建议。如果您尝试添加一个不在用户数据库表中的用户,您应该会看到一个告诉您的错误。如果您尝试输入已添加到项目中的用户,您将收到一个告诉您的错误。在成功添加后,您将看到一个指示成功的简短闪烁消息。

现在我们有了将用户分配给项目并将它们添加到我们的 RBAC 授权层次结构的能力,我们应该改变我们添加新项目时的逻辑。添加新项目时,应将添加项目的用户分配为项目的“所有者”。这样,项目的创建者将对项目拥有完全的管理访问权限。我将把这留给读者作业。您可以通过下载附带本书的 TrackStar 应用程序的可用源代码来查看此练习的解决方案。

检查授权级别

完成本章中我们设定的任务的最后一件事是为我们实现的不同功能添加授权检查。在本章的早些时候,我们概述并实施了我们拥有的不同角色的 RBAC 授权层次结构。一切都已准备就绪,以允许或拒绝基于已授予项目内用户的权限的功能访问,但有一个例外。当尝试请求功能时,我们尚未实施必要的授权检查。该应用程序仍在使用在我们的项目、问题和用户控制器上定义的简单访问过滤器。我们将为我们的权限之一执行此操作,然后将其余实现留给读者作为练习。

回顾我们的授权层次结构,我们可以看到只有项目所有者才能向项目添加新用户。因此,让我们添加这个授权检查。除非当前用户在该项目的owner角色中,否则我们将隐藏项目详情页面上添加用户的链接(在实施之前,您应该确保您已经向项目添加了至少一个所有者和一个成员或读者,以便在完成后进行测试)。打开protected/views/project/view.php视图文件,在那里我们放置了添加新用户的菜单项。从菜单数组项中删除该数组元素,然后只有当checkAccess()方法返回true时,才将其推送到数组的末尾。以下代码显示了菜单项应该如何定义:

$this->menu=array(
array('label'=>'List Project', 'url'=>array('index')),
array('label'=>'Create Project', 'url'=>array('create')),
array('label'=>'Update Project', 'url'=>array('update', 'id'=>$model->id)),
array('label'=>'Delete Project', 'url'=>'#', 'linkOptions'=>array('submit'=>array('delete','id'=>$model->id),'confirm'=>'Are you sure you want to delete this item?')),
array('label'=>'Manage Project', 'url'=>array('admin')),
array('label'=>'Create Issue', 'url'=>array('issue/create', 'pid'=>$model->id)),

);
if(Yii::app()->user->checkAccess('createUser',array('project'=>$model)))
{
$this->menu[] = array('label'=>'Add User To Project', 'url'=>array('adduser', 'id'=>$model->id));
}

这实现了我们在本章中讨论过的相同方法。我们在当前用户上调用checkAccess()并发送我们想要检查的权限的名称。此外,由于我们的角色是在项目的上下文中的,我们将项目模型实例作为数组输入发送。这将允许已在授权分配中定义的业务规则执行。现在,如果我们以特定项目的项目所有者身份登录,并导航到该项目的详情页面,我们将看到添加新用户到项目的菜单选项。相反,如果我们以同一项目的memberreader角色的用户身份登录,并再次导航到详情页面,这个链接将不会显示。

当然,这并不会阻止一个精明的用户通过直接使用 URL 导航来获得这个功能。例如,即使作为项目#1 的reader角色的用户登录到应用程序,如果我直接导航到http://localhost/trackstar/index.php?r=project/adduser&id=1,我仍然可以访问表单。

为了防止这种情况发生,我们需要直接将我们的访问检查添加到动作方法本身。因此,在项目控制器类中的ProjectController::actionAdduser()方法中,我们可以添加检查:

public function actionAdduser($id)
{
$project = $this->loadModel($id);
if(!Yii::app()->user->checkAccess('createUser', array('project'=>$project)))
{
throw new CHttpException(403,'You are not authorized to perform this action.');
}

$form=new ProjectUserForm; 

现在,当我们尝试直接访问这个 URL 时,除非我们是项目的owner角色,否则我们将被拒绝访问。

我们不会逐个实现所有其他功能的访问检查。每个都将以类似的方式实现。我们把这留给读者作为一个练习。这个实现对于继续跟随本书中剩余的代码示例并不是必需的。

总结

在本章中,我们涵盖了很多内容。首先,我们介绍了 Yii 提供的基本访问控制过滤器,作为允许和拒绝对特定控制器动作方法访问的一种方法。我们使用这种方法来确保用户在获得任何主要功能的访问权限之前必须登录到该应用程序。然后,我们详细介绍了 Yii 的 RBAC 模型,它允许更复杂的访问控制方法。我们基于应用程序角色构建了整个用户授权层次结构。在这个过程中,我们介绍了在 Yii 中编写控制台应用程序,并介绍了这一出色功能的一些好处。然后,我们增加了新功能,允许向项目添加用户,并能够将他们分配到这些项目中的适当角色。最后,我们发现了如何在整个应用程序中实现所需的访问检查,以利用 RBAC 层次结构来适当地授予/拒绝功能功能的访问权限。

在下一章中,我们将为用户添加更多功能,其中之一是能够在我们的项目问题上留下评论。

第八章:添加用户评论

通过前两章中对用户管理的实施,我们的 Trackstar 应用程序真的开始成形了。我们的主要应用程序功能的大部分功能现在已经完成。现在我们可以开始专注于一些很好有的功能。我们将首先解决的是用户在项目问题上留下评论的能力。

用户参与关于项目问题的对话的能力是任何问题跟踪工具应提供的重要部分。实现这一目标的一种方法是允许用户直接在问题上留下评论。评论将形成关于问题的对话,并提供即时和历史背景,以帮助跟踪任何问题的整个生命周期。我们还将使用评论来演示 Yii 小部件的使用以及如何建立一个小部件模型来向用户提供内容(有关小部件的更多信息,请参见en.wikipedia.org/wiki/Portlet)。

功能规划

本章的目标是在 Trackstar 应用程序中实现功能,允许用户在问题上留下评论并阅读评论。当用户查看任何项目问题的详细信息时,他们应该能够阅读以前添加的所有评论,并在问题上创建新的评论。我们还希望在项目列表页面上添加一个小片段内容或小部件,以显示所有问题上最近留下的评论列表。这将是一个很好的方式,提供一个窗口进入最近的用户活动,并允许轻松访问最新的有活跃对话的问题。

以下是我们需要完成的高级任务列表:

  1. 设计并创建一个新的数据库表来支持评论。

  2. 创建与我们的新评论表相关的 Yii AR 类。

  3. 在问题详细页面直接添加一个表单,允许用户提交评论。

  4. 在问题的详细页面上显示与问题相关的所有评论列表。

  5. 利用 Yii 小部件在项目列表页面上显示最近评论的列表。

创建模型

我们首先需要创建一个新的表来存放我们的评论。正如您所期望的那样,我们将使用数据库迁移来对我们的数据库结构进行这个添加:

$ cd /Webroot/trackstar/protected
$ ./yiic migrate create create_user_comments_table

up()down()方法如下:

  public function up()
  {
    //create the issue table
    $this->createTable('tbl_comment', array(
      'id' => 'pk',
          'content' => 'text NOT NULL',
          'issue_id' => 'int(11) NOT NULL',
      'create_time' => 'datetime DEFAULT NULL',
      'create_user_id' => 'int(11) DEFAULT NULL',
      'update_time' => 'datetime DEFAULT NULL',
      'update_user_id' => 'int(11) DEFAULT NULL',
     ), 'ENGINE=InnoDB');

    //the tbl_comment.issue_id is a reference to tbl_issue.id 
    $this->addForeignKey("fk_comment_issue", "tbl_comment", "issue_id", "tbl_issue", "id", "CASCADE", "RESTRICT");

    //the tbl_issue.create_user_id is a reference to tbl_user.id 
    $this->addForeignKey("fk_comment_owner", "tbl_comment", "create_user_id", "tbl_user", "id", "RESTRICT, "RESTRICT");

    //the tbl_issue.updated_user_id is a reference to tbl_user.id 
    $this->addForeignKey("fk_comment_update_user", "tbl_comment", "update_user_id", "tbl_user", "id", "RESTRICT", "RESTRICT");

  }

  public function down()
  {
    $this->dropForeignKey('fk_comment_issue', 'tbl_comment');
    $this->dropForeignKey('fk_comment_owner', 'tbl_comment');
    $this->dropForeignKey('fk_comment_update_user', 'tbl_comment');
    $this->dropTable('tbl_comment');
  }

为了实现这个数据库更改,我们需要运行迁移:

$ ./yiic migrate

现在我们的数据库表已经就位,创建相关的 AR 类就很容易了。我们在前几章中已经看到了很多次。我们知道如何做。我们只需使用 Gii 代码创建工具的Model Generator命令,并根据我们新创建的tbl_comment表创建一个名为Comment的 AR 类。如果需要,可以参考第四章项目 CRUD和第五章管理问题,了解使用此工具创建模型类的所有细节。

使用 Gii 工具为评论创建模型类后,您会注意到为我们生成的代码已经定义了一些关系。这些关系是基于我们在tbl_comments表上定义的外键关系。以下是为我们创建的内容:

/**
   * @return array relational rules.
   */
  public function relations()
  {
    // NOTE: you may need to adjust the relation name and the related
    // class name for the relations automatically generated below.
    return array(
      'updateUser' => array(self::BELONGS_TO, 'User', 'update_user_id'),
      'issue' => array(self::BELONGS_TO, 'Issue', 'issue_id'),
      'createUser' => array(self::BELONGS_TO, 'User', 'create_user_id'),
    );
  }

我们可以看到我们有一个关系,指定评论属于一个问题。但我们还需要定义一个问题和它的评论之间的一对多关系。一个问题可以有多个评论。这个更改需要在Issue模型类中进行。

注意

如果我们在创建 Issue 模型的同时创建了我们的评论模型,这个关系就会为我们创建。

除此之外,我们还将添加一个关系作为统计查询,以便轻松检索与给定问题相关的评论数量。以下是我们对Issue::relations()方法所做的更改:

public function relations()
{
  return array(
    'requester' => array(self::BELONGS_TO, 'User', 'requester_id'),
    'owner' => array(self::BELONGS_TO, 'User', 'owner_id'),
    'project' => array(self::BELONGS_TO, 'Project', 'project_id'),
    'comments' => array(self::HAS_MANY, 'Comment', 'issue_id'),
    'commentCount' => array(self::STAT, 'Comment', 'issue_id'),
  );
}

这建立了问题和评论之间的一对多关系。它还定义了一个统计查询,允许我们轻松地检索任何给定问题实例的评论总数。

提示

统计查询

之前定义的commentCount关系是我们以前没有见过的一种新类型的关系。除了关联查询,Yii 还提供了所谓的统计或聚合关系。在对象之间存在一对多(HAS_MANY)或多对多(MANY_MANY)关系的情况下,这些关系非常有用。在这种情况下,我们可以定义统计关系,以便轻松地获取相关对象的总数。我们已经利用了这一点,在之前的关系声明中,以便轻松地检索任何给定问题实例的评论总数。有关在 Yii 中使用统计查询的更多信息,请参阅www.yiiframework.com/doc/guide/1.1/en/database.arr#statistical-query

我们还需要更改我们新创建的Comment AR 类,以扩展我们自定义的TrackStarActiveRecord基类,以便它从我们放置在beforeSave()方法中的逻辑中受益。只需修改类定义的开头,如下所示:

<?php
      /**
 * This is the model class for table "tbl_comment".
 */
class Comment extends TrackStarActiveRecord
{

我们将对Comment::relations()方法中的定义进行最后一次小的更改。在创建类时,关系属性已经为我们命名。让我们将名为createUser的属性更改为author,因为这个相关的用户代表评论的作者。这只是一个语义上的改变,但它将有助于使我们的代码更易于阅读和理解。将定义从'createUser' => array(self::BELONGS_TO, 'User', 'create_user_id'),更改为'author' => array(self::BELONGS_TO, 'User', 'create_user_id')

创建评论 CRUD

现在我们已经有了 AR 模型类,创建用于管理相关实体的 CRUD 脚手架很容易。只需使用 Gii 代码生成工具的 Crud 生成器命令,参数为 AR 类名Comment。我们在之前的章节中已经看到了这个很多次,所以我们不会在这里再详细介绍。如果需要,可以参考第四章,项目 CRUD和第五章,管理问题,了解使用 Gii 工具创建 CRUD 脚手架代码的所有细节。虽然我们不会立即为我们的评论实现完整的 CRUD 操作,但是有其他操作的脚手架是很好的。

在使用 Gii 的 Crud 生成器之后,只要我们登录,现在我们应该能够通过以下 URL 查看自动生成的评论提交表单:

http://localhost/trackstar/index.php?r=comment/create

修改脚手架以满足我们的要求

正如我们以前经常看到的那样,我们经常需要调整自动生成的脚手架代码,以满足应用程序的特定要求。首先,我们用于创建新评论的自动生成表单为tbl_comment数据库表中定义的每个列都有一个输入字段。实际上,我们并不希望所有这些字段都成为表单的一部分。事实上,我们希望大大简化这个表单,只有一个用于评论内容的输入字段。而且,我们不希望用户通过之前提到的 URL 访问表单,而是只能通过访问问题详情页面来添加评论。用户将在查看问题详情的页面上添加评论。我们希望朝着以下截图所示的方式构建:

修改脚手架以满足我们的要求

为了实现这一点,我们将修改我们的Issue控制器类,以处理评论表单的提交,并修改问题详细信息视图,以显示现有评论和新评论创建表单。此外,由于评论应该只在问题的上下文中创建,我们将在问题模型类中添加一个新方法来创建新评论。

添加评论

正如前面提到的,我们将让问题实例创建自己的评论。为此,我们希望在Issue AR 类中添加一个方法。以下是该方法:

/**
  * Adds a comment to this issue
  */
public function addComment($comment)
{
  $comment->issue_id=$this->id;
  return $comment->save();
}

该方法确保在保存新评论之前正确设置评论问题 ID。也就是说,当Issue的实例创建新评论时,我们知道该评论属于该问题。

有了这个方法,我们现在可以把重点转向问题控制器类。由于我们希望评论创建表单从IssueController::actionView()方法显示并将其数据发送回来,我们需要修改该方法。我们还将添加一个新的受保护方法来处理表单提交请求。首先,修改actionView()方法如下:

public function actionView($id)
{
    $issue=$this->loadModel($id);
    $comment=$this->createComment($issue);
    $this->render('view',array(
      'model'=>$issue,
         'comment'=>$comment,
    ));
}

然后,添加以下受保护方法来创建一个新评论并处理创建此问题的新评论的表单提交请求:

/**
  * Creates a new comment on an issue
  */
protected function createComment($issue)
{
  $comment=new Comment;  
  if(isset($_POST['Comment']))
  {
    $comment->attributes=$_POST['Comment'];
    if($issue->addComment($comment))
    {
      Yii::app()->user->setFlash('commentSubmitted',"Your comment has been added." );
      $this->refresh();
    }
  }
  return $comment;
}

我们的新受保护方法createComment()负责处理用户在问题上留下新评论时提交的POST请求。如果成功创建评论,我们设置一个闪存消息显示给用户,并进行页面刷新,以便我们的新评论将显示。当然,我们仍然需要修改我们的视图文件,以便所有这些显示给用户。对IssueController::actionView()所做的更改负责调用这个新方法,并为显示提供新评论实例。

显示表单

现在,我们需要修改我们的视图。首先,我们将创建一个新的视图文件来呈现我们的评论显示和评论输入表单。我们打算在另一个视图文件中显示此视图文件。因此,我们不希望再次显示所有一般页面组件,例如页眉导航和页脚信息。打算在其他视图文件中显示或不带任何额外装饰的视图文件称为partial视图。然后,您可以使用控制器方法renderPartial(),而不是render()方法。使用renderPartial()将仅呈现该视图文件中包含的内容,并且不会用任何其他内容装饰显示。当我们讨论使用布局和装饰视图文件时,我们将在第十章让它看起来不错中详细讨论这一点。

Yii 在创建部分视图文件时使用下划线(_)作为命名约定的前缀。由于我们将其呈现为部分视图,我们将遵循命名约定,并以下划线开头命名文件。在protected/views/issue/目录下创建一个名为_comments.php的新文件,并将以下代码添加到该文件中:

<?php foreach($comments as $comment): ?>
<div class="comment">
      <div class="author">
    <?php echo CHtml::encode($comment->author->username); ?>:
  </div>

  <div class="time">
    on <?php echo date('F j, Y \a\t h:i a',strtotime($comment->create_time)); ?>
  </div>

  <div class="content">
    <?php echo nl2br(CHtml::encode($comment->content)); ?>
  </div>
     <hr>
</div><!-- comment -->
<?php endforeach; ?>

该文件接受评论实例数组作为输入参数,并逐个显示它们。现在,我们需要修改问题详细信息的视图文件以使用这个新文件。我们通过打开protected/views/issue/view.php并在文件末尾添加以下内容来实现这一点:

<div id="comments">
  <?php if($model->commentCount>=1): ?>
    <h3>
      <?php echo $model->commentCount>1 ? $model->commentCount . ' comments' : 'One comment'; ?>
    </h3>

    <?php $this->renderPartial('_comments',array(
      'comments'=>$model->comments,
    )); ?>
  <?php endif; ?>

  <h3>Leave a Comment</h3>

  <?php if(Yii::app()->user->hasFlash('commentSubmitted')): ?>
    <div class="flash-success">
      <?php echo Yii::app()->user->getFlash('commentSubmitted'); ?>
    </div>
  <?php else: ?>
    <?php $this->renderPartial('/comment/_form',array(
      'model'=>$comment,
    )); ?>
  <?php endif; ?>

</div>

在这里,我们利用了我们之前添加到Issue AR 模型类的统计查询属性commentCount。这使我们能够快速确定特定问题是否有任何可用的评论。如果有评论,它将继续使用我们的_comments.php显示视图文件来呈现它们。然后显示我们在使用 Gii Crud Generator 功能时为我们创建的输入表单。它还会显示成功保存评论时设置的简单闪存消息。

我们需要做的最后一个改变是评论输入表单本身。正如我们过去多次看到的那样,为我们创建的表单在底层tbl_comment表中定义了每一列的输入字段。这不是我们想要显示给用户的。我们希望将其变成一个简单的输入表单,用户只需要提交评论内容。因此,打开包含输入表单的视图文件,即protected/views/comment/_form.php,并编辑如下:

<div class="form">
<?php $form=$this->beginWidget('CActiveForm', array(
  'id'=>'comment-form',
  'enableAjaxValidation'=>false,
)); ?>
       <p class="note">Fields with <span class="required">*</span> are required.</p>
       <?php echo $form->errorSummary($model); ?>
       <div class="row">
    <?php echo $form->labelEx($model,'content'); ?>
    <?php echo $form->textArea($model,'content',array('rows'=>6, 'cols'=>50)); ?>
    <?php echo $form->error($model,'content'); ?>
  </div>

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

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

</div>

有了这一切,我们可以访问问题列表页面查看评论表单。例如,如果我们访问http://localhost/trackstar/index.php?r=issue/view&id=111,我们将在页面底部看到以下评论输入表单:

显示表单

如果我们尝试提交评论而没有指定任何内容,我们将看到以下截图中所示的错误:

显示表单

然后,如果我们以User One的身份登录并提交评论My first test comment,我们将看到以下显示:

显示表单

创建一个最近评论的小部件

现在我们可以在问题上留下评论,我们将把重点转向本章的第二个目标。我们想要显示所有项目中留下的最近评论列表。这将提供应用程序中用户沟通活动的一个很好的快照。我们还希望以一种方式构建这个小的内容块,使它可以在站点的不同位置轻松重复使用。这在互联网上的许多网络门户应用程序中非常常见。这些小的内容片段通常被称为portlet,这也是为什么我们在本章开头提到构建 portlet 架构。您可以参考en.wikipedia.org/wiki/Portlet了解更多关于这个主题的信息。

介绍 CWidget

幸运的是,Yii 已经准备好帮助我们实现这种架构。Yii 提供了一个名为CWidget的组件类,非常适合实现这种类型的架构。Yii 的widgetCWidget类的一个实例(或其子类),通常嵌入在视图文件中以显示自包含、可重用的用户界面功能。我们将使用 Yii 的 widget 来构建一个最近评论组件,并在主项目详情页面上显示它,以便我们可以看到与项目相关的所有问题的评论活动。为了演示重用的便利性,我们将进一步显示一个最近评论列表,跨所有项目在项目列表页面上。

命名作用域

要开始创建我们的小部件,我们首先要修改我们的Comment AR 模型类,以返回最近添加的评论。为此,我们将利用 Yii 的 AR 模型类中的另一个特性——命名作用域。

命名作用域允许我们指定一个命名查询,提供了一种优雅的方式来定义检索 AR 对象列表时的 SQL where条件。命名作用域通常在CActiveRecord::scopes()方法中定义为name=>criteria对。例如,如果我们想定义一个名为recent的命名作用域,它将返回最近的五条评论;我们可以创建Comment::scopes()方法如下:

class Comment extends TrackStarActiveRecord
{
  ...
  public function scopes()
  {
    return array(
      'recent'=>array(
        'order'=>'create_time DESC',
        'limit'=>5,
      ),
    );
  }
...
}

现在,我们可以使用以下语法轻松检索最近评论的列表:

$comments=Comment::model()->recent()->findAll();

您还可以链接命名作用域。如果我们定义了另一个命名作用域,例如approved(如果我们的应用程序在显示评论之前需要经过批准过程),我们可以获取最近批准的评论列表,如下所示:

$comments=Comment::model()->recent()->approved()->findAll();

您可以看到通过将它们链接在一起,我们有一种灵活而强大的方式来在特定上下文中检索我们的对象。

命名范围必须出现在find调用的左侧(findfindAllfindByPk等),并且只能在类级上下文中使用。命名范围方法调用必须与ClassName::model()一起使用。有关命名范围的更多信息,请参见www.yiiframework.com/doc/guide/1.1/en/database.ar#named-scopes

命名范围也可以被参数化。在先前的评论recent命名范围中,我们在条件中硬编码了限制为5。然而,当我们调用该方法时,我们可能希望能够指定限制数量。这就是我们为评论设置命名范围的方式。要添加参数,我们以稍有不同的方式指定命名范围。我们不是使用scopes()方法来声明我们的范围,而是定义一个新的公共方法,其名称与范围名称相同。将以下方法添加到Comment AR 类中:

public function recent($limit=5)
{
  $this->getDbCriteria()->mergeWith(
    array(         
    'order'=>'t.create_time DESC',         
      'limit'=>$limit,     
    )
  );     
  return $this;
}

关于这个查询条件的一件事是在 order 值中使用了t。这是为了帮助在与另一个具有相同列名的相关表一起使用时。显然,当两个被连接的表具有相同的列名时,我们必须在查询中区分这两个表。例如,如果我们在相同的查询中使用这个查询来检索Issue AR 信息,tbl_issuetbl_comment表都有定义create_time列。我们试图按照tbl_comment表中的这一列进行排序,而不是在问题表中定义的那一列。在 Yii 的关系 AR 查询中,主表的别名固定为t,而关系表的别名默认情况下与相应的关系名称相同。因此,在这种情况下,我们指定t.create_time以指示我们要使用主表的列。

Yii 中关于关系 AR 查询的更多信息

有了这种方法,我们可以将命名范围与急切加载方法结合起来,以检索相关的Issue AR 实例。例如,假设我们想要获取与 ID 为1的项目相关的最后十条评论,我们可以使用以下方法:

$comments = Comment::model()->with(array('issue'=>array('condition'=>'project_id=1')))->recent(10)->findAll();

这个查询对我们来说是新的。在以前的查询中,我们没有使用许多这些选项。以前,我们使用不同的方法来执行关系查询:

  • 加载 AR 实例

  • relations()方法中定义的关系属性中访问

例如,如果我们想要查询与项目 ID#1 关联的所有问题,我们将使用类似以下两行代码的内容:

// First retrieve the project whose ID is 1
$project=Project::model()->findByPk(1);

// Then retrieve the project's issues (a relational query is actually being performed behind the scenes here)
$issues=$project->issues;

这种熟悉的方法使用了所谓的懒加载。当我们首次创建项目实例时,查询不会返回所有相关的问题。它只在以后明确请求它们时检索相关的问题,也就是当执行$project->issues时。这被称为“懒惰”,因为它等到以后请求时才加载问题。

这种方法非常方便,而且在那些不需要相关问题的情况下也可以非常高效。然而,在其他情况下,这种方法可能有些低效。例如,如果我们想要检索跨N项目的问题信息,那么使用这种懒惰的方法将涉及执行N个连接查询。根据N的大小,这可能非常低效。在这些情况下,我们有另一个选择。我们可以使用所谓的急切加载

急切加载方法在请求主 AR 实例的同时检索相关的 AR 实例。这是通过在 AR 查询的find()findAll()方法与with()方法一起使用来实现的。继续使用我们的项目示例,我们可以使用急切加载来检索所有项目的所有问题,只需执行以下一行代码:

//retrieve all project AR instances along with their associated issue AR instances
$projects = Project::model()->with('issues')->findAll();

现在,在这种情况下,$projects数组中的每个项目 AR 实例已经具有其关联的issues属性,该属性填充有Issue AR 实例的数组。这是通过使用单个连接查询实现的。

因此,让我们回顾一下我们检索特定项目的最后十条评论的示例:

$comments = Comment::model()->with(array('issue'=>array('condition'=>'project_id=1')))->recent(10)->findAll();

我们正在使用急切加载方法来检索问题以及评论,但这个方法稍微复杂一些。这个查询在tbl_commenttbl_issue表之间指定了一个连接。这个关系 AR 查询基本上会执行类似于以下 SQL 语句的操作:

SELECT tbl_comment.*, tbl_issue.* FROM tbl_comment LEFT OUTER JOIN tbl_issue ON (tbl_comment.issue_id=tbl_issue.id) WHERE (tbl_issue.project_id=1) ORDER BY tbl_comment.create_time DESC LIMIT 10;

掌握了 Yii 中延迟加载和急切加载的好处的知识后,我们应该调整IssueController::actionView()方法中加载问题模型的方式。由于我们已经修改了问题的详细视图以显示我们的评论,包括评论的作者,我们知道在调用IssueController::loadModel()时,使用急切加载方法加载评论以及它们各自的作者将更有效。为此,我们可以添加一个额外的参数作为简单的输入标志,以指示我们是否要加载评论。

修改IssueController::loadModel()方法如下:

   public function loadModel($id, $withComments=false)
  {
    if($withComments)
      $model = Issue::model()->with(array('comments'=>array('with'=>'author')))->findByPk($id);
    else
      $model=Issue::model()->findByPk($id);
    if($model===null)
      throw new CHttpException(404,'The requested page does not exist.');
    return $model;
  }

IssueController方法中有三个地方调用了loadModel()方法:actionViewactionUpdateactionDelete。当我们查看问题详情时,我们只需要关联的评论。因此,我们已经将默认设置为不检索关联的评论。我们只需要修改actionView()方法,在loadModel()调用中添加true

public function actionView($id)
{
  $issue=$this->loadModel($id, true);
....
}

有了这个设置,我们将加载问题以及其所有关联的评论,并且对于每条评论,我们将加载关联的作者信息,只需一次数据库调用。

创建小部件

现在,我们已经准备好创建我们的新小部件,以利用之前提到的所有更改来显示我们的最新评论。

正如我们之前提到的,Yii 中的小部件是从框架类CWidget或其子类扩展的类。我们将把我们的新小部件添加到protected/components/目录中,因为该目录的内容已经在主配置文件中指定为在应用程序中自动加载。这样,我们就不必在每次使用时显式导入该类。我们将称我们的小部件为RecentComments,并在该目录中添加一个同名的.php文件。将以下类定义添加到这个新创建的RecentComments.php文件中:

<?php
/**
     * RecentCommentsWidget is a Yii widget used to display a list of recent comments 
     */
class RecentCommentsWidget extends CWidget
{
    private $_comments;  
    public $displayLimit = 5;
    public $projectId = null;

    public function init()
        {
          if(null !== $this->projectId)
        $this->_comments = Comment::model()->with(array('issue'=>array('condition'=>'project_id='.$this->projectId)))->recent($this->displayLimit)->findAll();
      else
        $this->_comments = Comment::model()->recent($this->displayLimit)->findAll();
        }  

        public function getData()
        {
          return $this->_comments;
        }

        public function run()
        {
            // this method is called by CController::endWidget()    
            $this->render('recentCommentsWidget');
        }
}

创建新小部件时的主要工作是重写基类的init()run()方法。init()方法初始化小部件,并在其属性被初始化后调用。run()方法执行小部件。在这种情况下,我们只需通过请求基于$displayLimit$projectId属性的最新评论来初始化小部件,使用我们之前讨论过的查询。小部件本身的执行只是简单地呈现其关联的视图文件,我们还没有创建。按照惯例,小部件的视图文件放在与小部件相同的目录中的views/目录中,并且与小部件同名,但以小写字母开头。遵循这个惯例,创建一个新文件,其完全限定的路径是protected/components/views/recentCommentsWidget.php。创建后,在该文件中添加以下内容:

<ul>
  <?php foreach($this->getData() as $comment): ?>  
    <div class="author">
      <?php echo $comment->author->username; ?> added a comment.
    </div>
    <div class="issue">      
       <?php echo CHtml::link(CHtml::encode($comment->issue->name), array('issue/view', 'id'=>$comment->issue->id)); ?>
      </div>

  <?php endforeach; ?>
</ul>

这调用了RecentCommentsWidget::getData()方法,该方法返回一个评论数组。然后遍历每个评论,显示添加评论的人以及留下评论的相关问题。

为了看到结果,我们需要将这个小部件嵌入到现有的控制器视图文件中。如前所述,我们希望在项目列表页面上使用这个小部件,以显示所有项目的最近评论,并且在特定项目详情页面上,只显示该特定项目的最近评论。

让我们从项目列表页面开始。负责显示该内容的视图文件是protected/views/project/index.php。打开该文件,并在底部添加以下内容:

<?php $this->widget('RecentCommentsWidget'); ?>  

如果我们现在查看项目列表页面http://localhost/trackstar/index.php?r=project,我们会看到类似以下截图的内容:

创建小部件

现在,我们通过调用小部件将我们的新最近评论数据嵌入到页面中。这很好,但我们可以进一步将我们的小部件显示为应用程序中所有其他潜在小部件的一致方式。我们可以利用 Yii 为我们提供的另一个类CPortlet来实现这一点。

介绍 CPortlet

CPortlet是 Zii 的一部分,它是 Yii 捆绑的官方扩展类库。它为所有小部件提供了一个不错的基类。它将允许我们渲染一个漂亮的标题以及一致的 HTML 标记,这样应用程序中的所有小部件都可以很容易地以类似的方式进行样式设置。一旦我们有一个渲染内容的小部件,比如我们的RecentCommentsWidget,我们可以简单地使用我们小部件的渲染内容作为CPortlet的内容,CPortlet本身也是一个小部件,因为它也是从CWidget继承而来。我们可以通过在CPortletbeginWidget()endWiget()调用之间放置我们对RecentComments小部件的调用来实现这一点,如下所示:

<?php $this->beginWidget('zii.widgets.CPortlet', array(
  'title'=>'Recent Comments',
));  

$this->widget('RecentCommentsWidget');

$this->endWidget(); ?>

由于CPortlet提供了一个标题属性,我们将其设置为对我们的 portlet 有意义的内容。然后,我们使用RecentComments小部件的渲染内容来为 portlet 小部件提供内容。这样做的最终结果如下截图所示:

介绍 CPortlet

这与我们之前的情况并没有太大的变化,但现在我们已经将我们的内容放入了一个一致的容器中,这个容器已经在整个网站中使用。请注意右侧列菜单内容块和我们新创建的最近评论内容块之间的相似之处。我相信你不会感到意外,右侧列菜单块也是在CPortlet容器中显示的。查看protected/views/layouts/column2.php,这是一个在我们最初创建应用程序时由yiic webapp命令自动生成的文件,会发现以下代码:

<?php
  $this->beginWidget('zii.widgets.CPortlet', array(
    'title'=>'Operations',
  ));
  $this->widget('zii.widgets.CMenu', array(
    'items'=>$this->menu,
    'htmlOptions'=>array('class'=>'operations'),
  ));
  $this->endWidget();
?>

因此,看来应用程序一直在利用小部件!

将我们的小部件添加到另一个页面

让我们还将我们的小部件添加到项目详情页面,并将评论限制为与特定项目相关的评论。

protected/views/project/view.php文件的末尾添加以下内容:

<?php $this->beginWidget('zii.widgets.CPortlet', array(
  'title'=>'Recent Comments On This Project',
));  

$this->widget('RecentCommentsWidget', array('projectId'=>$model->id));

$this->endWidget(); ?>

这基本上与我们添加到项目列表页面的内容相同,只是我们通过向调用添加一个name=>value对的数组来初始化小部件的$projectId属性。

如果现在访问特定项目详情页面,我们应该会看到类似以下截图的内容:

将我们的小部件添加到另一个页面

上述截图显示了项目#1的详情页面,该项目有一个关联的问题,该问题只有一个评论,如截图所示。您可能需要添加一些问题和这些问题的评论,以生成类似的显示。现在我们有一种方法可以在整个网站的任何地方以一致且易于维护的方式显示最近的评论。

总结

通过本章,我们已经开始为我们的 Trackstar 应用程序添加功能,这些功能已经成为当今大多数基于用户的 Web 应用程序所期望的。用户在应用程序内部相互通信的能力是成功的问题管理系统的重要组成部分。

当我们创建了这一重要功能时,我们能够更深入地了解如何编写关系 AR 查询。我们还介绍了称为小部件和门户网站的内容组件。这使我们能够开发小的内容块,并能够在应用程序的任何地方使用它们。这种方法极大地增加了重用性、一致性和易于维护性。

在下一章中,我们将在这里创建的最近评论小部件的基础上构建,并将我们小部件生成的内容作为 RSS 订阅公开,以便用户可以跟踪应用程序或项目的活动,而无需访问应用程序。

第九章:添加 RSS 网络订阅

在上一章中,我们添加了用户在问题上留下评论的功能,并显示这些评论的列表,利用小部件架构使我们能够在整个应用程序中轻松和一致地显示该列表。在本章中,我们将建立在此功能的基础上,并将这些评论列表公开为 RSS 数据订阅。此外,我们将使用另一个开源框架 Zend 框架中现有的订阅功能,以演示 Yii 应用程序如何轻松地与其他框架和库集成。

功能规划

本章的目标是使用从用户生成的评论创建 RSS 订阅。我们应该允许用户订阅跨所有项目的评论订阅,以及订阅单个项目的订阅。幸运的是,我们之前构建的小部件功能已经具有返回所有项目的最新评论列表以及限制数据到一个特定项目的能力。因此,我们已经编写了访问必要数据的适当方法。本章的大部分内容将集中在将这些数据放入正确的格式以发布为 RSS 订阅,并在我们的应用程序中添加链接以允许用户订阅这些订阅。

以下是我们将完成的一系列高级任务列表,以实现这些目标:

  • 下载并安装 Zend 框架到 Yii 应用程序中

  • 在控制器类中创建一个新的操作,以响应订阅请求并以 RSS 格式返回适当的数据

  • 更改我们的 URL 结构以便使用

  • 将我们新创建的订阅添加到项目列表页面以及每个单独项目的详细页面

一点背景-内容联合,RSS 和 Zend 框架

网络内容联合已经存在多年,但在过去几年才获得了巨大的流行。网络内容联合是指以标准化格式发布信息,以便其他网站可以轻松使用,并且可以轻松被阅读应用程序消费。许多新闻网站长期以来一直在电子联合他们的内容,但互联网上博客的大规模爆炸已经将内容联合(称为订阅)变成了几乎每个网站都期望的功能。我们的 TrackStar 应用程序也不例外。

真正简单的联合RSS)是一种 XML 格式规范,为网络内容联合提供了一个标准。还有其他可以使用的格式,但由于 RSS 在大多数网站中的压倒性流行,我们将专注于以这种格式提供我们的订阅。

Zend 被称为“PHP 公司”。他们提供的产品之一是 Zend 框架,用于帮助应用程序开发。该框架提供了可以并入其他框架应用程序的组件。Yii 足够灵活,可以让我们使用其他框架的部分。我们将只使用 Zend 框架库的一个组件,称为Zend_Feed,这样我们就不必编写所有底层的“管道”代码来生成我们的 RSS 格式的网络订阅。有关 Zend_Feed 的更多信息,请访问www.zendframework.com/manual/en/zend.feed.html

安装 Zend 框架

由于我们使用 Zend 框架来帮助支持我们的 RSS 需求,因此我们首先需要下载并安装该框架。要下载框架文件,请访问www.zend.com/community/downloads。由于我们只会使用该框架的一个组件,因此最小版本的框架就足够了。我们使用的是 1.1.12 版本。

当您扩展下载的框架文件时,您应该看到以下高级目录和文件结构:

  • INSTALL.txt

  • LICENSE.txt

  • README.txt

  • bin/

  • library/

为了在我们的 Yii 应用程序中使用这个框架,我们需要移动应用程序目录结构中的一些文件。让我们在应用程序的/protected目录下创建一个名为vendors/的新目录。然后,将 Zend Framework 目录/library/Zend移动到这个新创建的目录下。一切就位后,确保protected/vendors/Zend/Feed.php存在于 TrackStar 应用程序中。

使用 Zend_Feed

Zend_Feed是 Zend Framework 的一个小组件,它封装了创建 Web 源的所有复杂性,提供了一个简单易用的接口。它将帮助我们在很短的时间内建立一个工作的、经过测试的、符合 RSS 标准的数据源。我们所需要做的就是按照 Zend_Feed 期望的格式对我们的评论数据进行格式化,它会完成其余的工作。

我们需要一个地方来存放处理我们的数据源请求的代码。我们可以为此创建一个新的控制器,但为了保持简单,我们将只是在我们的主CommentController.php文件中添加一个新的操作方法来处理请求。我们将整个方法列在这里,然后逐步讨论它的功能。

Open up CommentController.php and add the following public method:
/**
   * Uses Zend Feed to return an RSS formatted comments data feed
   */
  public function actionFeed()
  {
    if(isset($_GET['pid'])) 
    {
      $comments = Comment::model()->with(array(
                'issue'=>array(
                  'condition'=>'project_id=:projectId', 
                  'params'=>array(':projectId'=>intval($_GET['pid'])),
                )))->recent(20)->findAll();      
    }
    else   
      $comments = Comment::model()->recent(20)->findAll();  

    //convert from an array of comment AR class instances to an name=>value array for Zend
    $entries=array(); 

    foreach($comments as $comment)
    {

        $entries[]=array(
                'title'=>$comment->issue->name,     
                'link'=>CHtml::encode($this->createAbsoluteUrl('issue/view',array('id'=>$comment->issue->id))),  
                'description'=> $comment->author->username . ' says:<br>' . $comment->content,
                'lastUpdate'=>strtotime($comment->create_time),   
                'author'=>CHtml::encode($comment->author->username),
         );
    }  

    //now use the Zend Feed class to generate the Feed
    // generate and render RSS feed
    $feed=Zend_Feed::importArray(array(
         'title'   => 'Trackstar Project Comments Feed',
         'link'    => $this->createAbsoluteUrl(''),
         'charset' => 'UTF-8',
         'entries' => $entries,      
     ), 'rss');

    $feed->send();

  }

这一切都相当简单。首先,我们检查输入请求查询字符串是否存在pid参数,这表明特定项目 ID。请记住,我们希望可选地允许数据源将内容限制为与单个项目相关的评论。接下来,我们使用与上一章中用于填充小部件的相同方法来检索最多 20 条最近的评论列表,可以是跨所有项目,或者如果指定了项目 ID,则特定于该项目。

您可能还记得,这个方法返回一个Comment AR 类实例的数组。我们遍历这个返回的数组,并将数据转换为Zend_Feed组件接受的格式。Zend_Feed接受一个简单的数组,其中包含元素本身是包含每个评论条目数据的数组。每个单独的条目都是一个简单的name=>value对的关联数组。为了符合特定的 RSS 格式,我们的每个单独的条目必须至少包含一个标题、一个链接和一个描述。我们还添加了两个可选字段,一个称为lastUpdateZend_Feed将其转换为 RSS 字段pubDate,另一个用于指定作者。

我们利用了一些额外的辅助方法,以便以正确的格式获取数据。首先,我们使用控制器的createAbsoluteUrl()方法,而不仅仅是createUrl()方法,以生成一个完全合格的 URL。使用createAbsoluteUrl()将生成类似于以下的链接:

http://localhost/trackstar/index.php?r=issue/view&id=5而不仅仅是/index.php?r=issue/view&id=5

此外,为了避免由 PHP 的DOMDocument::createElement()方法生成的unterminated entity reference等错误,该方法被Zend_Feed用于生成 RSS XML,我们需要使用我们方便的辅助函数CHtml::encode将所有适用的字符转换为 HTML 实体。因此,我们对链接进行编码,以便像http://localhost/trackstar/index.php?r=issue/view&id=5这样的 URL 将被转换为http://localhost/trackstar/index.php?r=issue/view&amp;id=5

我们还需要对将以 RSS 格式呈现的其他数据执行此操作。描述和标题字段都生成为CDATA块,因此在这些字段上不需要使用编码。

一旦所有条目都被正确填充和格式化,我们就使用 Zend_Feed 的importArray()方法,该方法接受一个数组来构造 RSS 源。最后,一旦从输入条目数组构建了源类并返回,我们就调用该类的send()方法。这将返回适当格式的 RSS XML 和适当的标头给客户端。

我们需要在CommentController.php文件和类中进行一些配置更改,然后才能使其正常工作。我们需要在评论控制器中包含一些 Zend 框架文件。在CommentController.php的顶部添加以下语句:

Yii::import('application.vendors.*');
require_once('Zend/Feed.php');
require_once('Zend/Feed/Rss.php');

最后,修改CommentController::accessRules()方法,允许任何用户访问我们新添加的actionFeed()方法:

public function accessRules()
  {
    return array(
      array('allow',  // allow all users to perform 'index' and 'view' actions
 **'actions'=>array('index','view','feed'),**
        'users'=>array('*'),
      ),

事实上就是这样。如果我们现在导航到http://localhost/trackstar/index.php?r=comment/feed,我们就可以查看我们的努力成果。由于浏览器对 RSS feed 的显示方式不同,您的体验可能与下面的截图有所不同。如果在 Firefox 浏览器中查看,您应该看到以下截图:

使用 Zend_Feed

然而,在 Chrome 浏览器中查看时,我们看到原始的 XML 被显示出来,如下面的截图所示:

使用 Zend_Feed

这可能取决于您的版本。您可能还会被提示选择要安装的可用 RSS 阅读器扩展,例如 Google Reader 或 Chrome 的 RSS Feed Reader 扩展。

创建用户友好的 URL

到目前为止,在我们的开发过程中,我们一直在使用 Yii 应用程序 URL 结构的默认格式。这种格式在第二章中讨论过,入门,在回顾我们的请求路由一节中使用了查询字符串的方法。我们有主要参数“r”,代表路由,后面跟着 controllerID/actionID 对,然后是特定 action 方法需要的可选查询字符串参数。我们为我们的新 feed 创建的 URL 也不例外。它是一个又长又笨重,可以说是丑陋的 URL。肯定有更好的方法!事实上确实如此。

我们可以通过使用所谓的路径格式使先前提到的 URL 看起来更清晰、更易理解,这种格式消除了查询字符串,并将GET参数放入 URL 的路径信息部分:

以我们的评论 feed URL 为例,我们将不再使用http://localhost/trackstar/index.php?r=comment/feed,而是使用http://localhost/trackstar/index.php/comment/feed/

而且,我们不需要为每个请求指定入口脚本。我们还可以利用 Yii 的请求路由配置选项来消除指定 controllerID/actionID 对的需要。我们的请求可能看起来像这样:

http://localhost/trackstar/commentfeed

另外,通常情况下,特别是在 feed 的 URL 中,最后会指定.xml扩展名。因此,如果我们能够修改我们的 URL,使其看起来像下面这样,那就太好了:

http://localhost/trackstar/commentfeed.xml

这大大简化了用户的 URL,并且也是 URL 被主要搜索引擎正确索引的绝佳格式(通常称为“搜索引擎友好的 URL”)。让我们看看如何使用 Yii 的 URL 管理功能来修改我们的 URL 以匹配这种期望的格式。

使用 URL 管理器

Yii 中内置的 URL 管理器是一个应用程序组件,可以在protected/config/main.php文件中进行配置。让我们打开该文件,并在 components 数组中添加一个新的 URL 管理器组件声明:

'urlManager'=>array(
    'urlFormat'=>'path',
 ),    

只要我们坚持使用默认的并将组件命名为urlManager,我们就不需要指定组件的类,因为在CWebApplication.php框架类中预先声明为CUrlManager.php

通过这个简单的添加,我们的 URL 结构已经在整个站点中改变为路径格式。例如,以前,如果我们想要查看 ID 为 1 的特定问题,我们使用以下 URL 进行请求:

http://localhost/trackstar/index.php?r=issue/view&id=1

现在,通过这些更改,我们的 URL 看起来是这样的:

http://localhost/trackstar/index.php/issue/view/id/1

您会注意到我们所做的更改已经影响了应用程序中生成的所有 URL。要查看这一点,再次访问我们的订阅,转到http://localhost/trackstar/index.php/comment/feed/。我们注意到,所有我们的问题链接都已经被重新格式化为这个新的结构。这都归功于我们一贯使用控制器方法和其他辅助方法来生成我们的 URL。我们只需在一个配置文件中更改 URL 格式,这些更改就会自动传播到整个应用程序。

我们的 URL 看起来更好了,但我们仍然有入口脚本index.php,并且我们还不能在我们的订阅 URL 的末尾添加.xml后缀。因此,让我们隐藏index.php文件作为 URL 的一部分,并设置请求路由以理解对commentfeed.xml的请求实际上意味着对CommentController::actionFeed()的请求。让我们先解决后者。

配置路由规则

Yii URL 管理器允许我们指定规则来定义 URL 的解析和创建方式。规则由定义路由和模式组成。模式用于匹配 URL 的路径信息部分,以确定使用哪个规则来解析或创建 URL。模式可以包含使用语法 ParamName:RegExp 的命名参数。在解析 URL 时,匹配的规则将从路径信息中提取这些命名参数,并将它们放入 $_GET 变量中。当应用程序创建 URL 时,匹配的规则将从 $_GET 中提取命名参数,并将它们放入创建的 URL 的路径信息部分。如果模式以 /* 结尾,这意味着可以在 URL 的路径信息部分附加额外的 GET 参数。

要指定 URL 规则,将CUrlManager文件的rules属性设置为规则数组,格式为pattern=>route

例如,让我们看看以下两条规则:

'urlManager'=>array(
  'urlFormat'=>'path',
  'rules'=>array(
  'issues'=>'issue/index',
  'issue/<id:\d+>/*'=>'issue/view',
  ),
)

这段代码中指定了两条规则。第一条规则表示,如果用户请求 URL http://localhost/trackstar/index.php/issues,则应该被视为 http://localhost/trackstar/index.php/issue/index,在构建 URL 时也是一样的。因此,例如,如果我们在应用程序中使用控制器的 createUrl('issue/index') 方法创建 URL,它将生成 /trackstar/index.php/issues 而不是 /trackstar/index.php/issue/index

第二条规则包含一个命名参数id,使用<ParamName:RegExp>语法指定。它表示,例如,如果用户请求 URL http://localhost/trackstar/index.php/issue/1,则应该被视为 http://localhost/trackstar/index.php/issue/view/id/1。在构建这样的 URL 时也是一样的。

路由也可以被指定为一个数组本身,以允许设置其他属性,比如 URL 后缀以及路由是否应该被视为区分大小写。当我们为我们的评论订阅指定规则时,我们将利用这些属性。

让我们将以下规则添加到我们的urlManager应用程序组件配置中:

'urlManager'=>array(
        'urlFormat'=>'path',   
 **'rules'=>array(   'commentfeed'=>array('comment/feed', 'urlSuffix'=>'.xml', 'caseSensitive'=>false),**
      ), 
), 

在这里,我们使用了urlSuffix属性来指定我们期望的 URL.xml后缀。

现在我们可以通过以下 URL 访问我们的订阅:

http://localhost/trackstar/index.php/commentFeed.xml

从 URL 中删除入口脚本

现在我们只需要从 URL 中删除index.php部分。这可以通过以下两个步骤完成:

  1. 修改 Web 服务器配置,将所有不对应现有文件或目录的请求重定向到index.php

  2. urlManager组件的showScriptName属性设置为false

第一步处理了应用程序如何路由请求,而后者处理了应用程序中 URL 的创建方式。

由于我们使用 Apache HTTP 服务器,我们可以通过在应用程序根目录中创建一个.htaccess文件并向该文件添加以下指令来执行第一步:

# Turning on the rewrite engine is necessary for the following rules and features.
# FollowSymLinks must be enabled for this to work.
<IfModule mod_rewrite.c>
  Options +FollowSymlinks
  RewriteEngine On
</IfModule>

# Unless an explicit file or directory exists, redirect all request to Yii entry script
<IfModule mod_rewrite.c>
  RewriteCond %{REQUEST_FILENAME} !-f
  RewriteCond %{REQUEST_FILENAME} !-d
  RewriteRule . index.php
</IfModule>

注意

这种方法仅适用于 Apache HTTP 服务器。如果使用不同的 Web 服务器,您将需要查阅 Web 服务器重写规则。还要注意,这些信息可以放在主 Apache 配置文件中,作为使用.htaccess文件方法的替代方法。

有了这个.htaccess文件,我们现在可以通过导航到http://localhost/trackstar/commentfeed.xml(或http://localhost/trackstar/commentFeed.xml,因为我们将大小写敏感性设置为 false)来访问我们的源。

然而,即使有了这个,如果我们在应用程序中使用控制器方法或CHtml助手方法之一来创建我们的 URL,比如在控制器类中执行$this->createAbsoluteUrl('comment/feed');,它将生成以下 URL,其中 URL 中仍然包含index.php

http://localhost/trackstar/index.php/commentfeed.xml

为了指示它在生成 URL 时不使用条目脚本名称,我们需要在urlManager组件上设置该属性。我们在main.php配置文件中再次执行此操作,如下所示:

'urlManager'=>array(
    'urlFormat'=>'path',   
    'rules'=>array(
       'commentfeed'=>array('comment/feed', 'urlSuffix'=>'.xml', 'caseSensitive'=>false),
  ), 
 **'showScriptName'=>false,**
 ),   

为了处理 URL 中项目 ID 的添加,我们需要将评论源数据限制为与特定项目相关联的评论,为此我们需要添加另一条规则,如下所示:

'urlManager'=>array(
        'urlFormat'=>'path',   
        'rules'=>array(   
 **'<pid:\d+>/commentfeed'=>array('comment/feed', 'urlSuffix'=>'.xml', 'caseSensitive'=>false),**
         'commentfeed'=>array('comment/feed', 'urlSuffix'=>'.xml', 'caseSensitive'=>false),
      ), 
      'showScriptName'=>false,
),

这个规则还使用了<Parameter:RegEx>语法来指定一个模式,以允许在 URL 的commentfeed.xml部分之前指定项目 ID。有了这个规则,我们可以将我们的 RSS 源限制为特定项目的评论。例如,如果我们只想要与项目#2相关联的评论,URL 格式将是:

http://localhost/trackstar/2/commentfeed.xml

添加订阅链接

现在我们已经创建了我们的源并改变了 URL 结构,使其更加用户友好和搜索引擎友好,我们需要添加用户订阅源的功能。其中一种方法是在我们想要添加 RSS 源链接的页面渲染之前添加以下代码。让我们在项目列表页面以及特定项目详细信息页面都这样做。我们将从项目列表页面开始。这个页面由ProjectController::actionIndex()方法渲染。修改该方法如下:

public function actionIndex()
{
    $dataProvider=new CActiveDataProvider('Project');

 **Yii::app()->clientScript->registerLinkTag(**
 **'alternate',**
 **'application/rss+xml',**
 **$this->createUrl('comment/feed'));**

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

这里显示的突出显示的代码将添加以下内容到渲染的 HTML 的<head>标签中:

<link rel="alternate" type="application/rss+xml" href="/commentfeed.xml" />

在许多浏览器中,这将自动生成一个小的 RSS 源图标在地址栏中。以下截图显示了 Safari 地址栏中这个图标的样子:

添加订阅链接

我们进行类似的更改,以将此链接添加到特定项目详细信息页面。这些页面的渲染由ProjectController::actionView()方法处理。修改该方法如下:

public function actionView($id)
  {
    $issueDataProvider=new CActiveDataProvider('Issue', array(
      'criteria'=>array(
         'condition'=>'project_id=:projectId',
         'params'=>array(':projectId'=>$this->loadModel($id)->id),
       ),
       'pagination'=>array(
         'pageSize'=>1,
       ),
     ));

 **Yii::app()->clientScript->registerLinkTag(**
 **'alternate',**
 **'application/rss+xml',**
 **$this->createUrl('comment/feed',array('pid'=>$this->loadModel($id)->id)));**

    $this->render('view',array(
      'model'=>$this->loadModel($id),
      'issueDataProvider'=>$issueDataProvider,
    ));

  }

这几乎与我们添加到索引方法中的内容相同,只是我们正在指定项目 ID,以便我们的评论条目仅限于与该项目相关联的条目。类似的图标现在将显示在我们项目详细信息页面的地址栏中。单击这些图标允许用户订阅这些评论源。

注意

registerLinkTag()方法还允许您在第四个参数中指定媒体属性,然后您可以进一步指定其他支持的属性作为name=>value对的数组,作为第五个参数。有关使用此方法的更多信息,请参见www.yiiframework.com/doc/api/1.1/CClientScript/#registerLinkTag-detail

摘要

本章展示了如何轻松地将 Yii 与其他外部框架集成。我们特别使用了流行的 Zend Framework 来进行演示,并能够快速地向我们的应用程序添加符合 RSS 标准的 Web 订阅。虽然我们特别使用了Zend_Feed,但我们真正演示了如何将 Zend Framework 的任何组件集成到应用程序中。这进一步扩展了 Yii 已经非常丰富的功能,使 Yii 应用程序变得非常功能丰富。

我们还了解了 Yii 中的 URL 管理功能,并在整个应用程序中改变了我们的 URL 格式,使其更加用户和搜索引擎友好。这是改进我们应用程序外观和感觉的第一步,这是我们到目前为止非常忽视的事情。在下一章中,我们将更仔细地研究 Yii 应用程序的展示层。样式、主题以及通常使事物看起来好看将是下一章的重点。

第十章:让它看起来不错

在上一章中,我们通过使我们的 URL 对用户和搜索引擎爬虫更具吸引力,为我们的应用程序增添了一些美感。在本章中,我们将更多地关注我们应用程序的外观和感觉,涵盖 Yii 中布局和主题的主题。我们将专注于一个人采取的方法和可用的工具,以帮助设计 Yii 应用程序的前端,而不是设计本身。因此,本章将更多地关注如何使您的应用程序看起来不错,而不是花费大量时间专门设计我们的 TrackStar 应用程序以实际看起来不错。

功能规划

本章旨在专注于前端。我们希望为我们的网站创建一个可重用且能够动态实现的新外观。我们还希望在不覆盖或删除当前设计的情况下实现这一点。最后,我们将深入研究 Yii 的国际化功能,以更好地了解如何适应来自不同地理区域的用户。

以下是我们需要完成的高级任务列表,以实现这些目标:

  • 通过创建新的布局、CSS 和其他资产文件来为我们的应用程序创建一个新的前端设计

  • 使用 Yii 的国际化和本地化功能来帮助将应用程序的一部分翻译成新语言

使用布局进行设计

您可能已经注意到的一件事是,我们在不添加任何显式导航以访问此功能的情况下向我们的应用程序添加了大量功能。我们的主页尚未从我们构建的默认应用程序更改。我们的新应用程序创建时的导航项与我们创建新应用程序时的导航项相同。我们需要更改我们的基本导航,以更好地反映应用程序中存在的基本功能。

到目前为止,我们尚未完全涵盖我们的应用程序如何使用负责显示内容的所有视图文件。我们知道我们的视图文件负责显示我们的数据和承载响应每个页面请求的返回的 HTML。当我们创建新的控制器操作时,我们经常创建新的视图来处理这些操作方法返回的内容的显示。这些视图中的大多数都非常特定于它们支持的操作方法,并且不会跨多个页面使用。但是,有一些东西,例如主菜单导航,可以在整个站点的多个页面上使用。这些类型的 UI 组件更适合驻留在所谓的布局文件中。

Yii 中的布局是用于装饰其他视图文件的特殊视图文件。布局通常包含跨多个视图文件共同的标记或其他用户界面组件。当使用布局来呈现视图文件时,Yii 会将视图文件嵌入布局中。

指定布局

可以指定布局的两个主要位置。一个是CWebApplication本身的$layout属性。这默认为protected/views/layouts/main.php。与所有应用程序设置一样,这可以在主配置文件protected/config/main.php中被覆盖。例如,如果我们创建了一个新的布局文件protected/views/layouts/newlayout.php,并希望将此新文件用作我们的应用程序范围的布局文件,我们可以修改我们的主config.php文件来设置布局属性如下:

return array(
  ...
  'layout'=>'newlayout',

文件名不带.php扩展名,并且相对于CWebApplication$layoutPath属性指定,该属性默认为Webroot/protected/views/layouts(如果此位置不适合您的应用程序需求,则可以类似地覆盖它)。

另一个指定布局的地方是通过设置控制器类的$layout属性。这允许更细粒度地控制每个控制器的布局。这是在生成初始应用程序时指定的方式。使用yiic工具创建我们的初始应用程序时,自动创建了一个控制器基类Webroot/protected/components/Controller.php,所有其他控制器类都是从这个类继承的。打开这个文件会发现$layout属性已经设置为column1。在更细粒度的控制器级别设置布局文件将覆盖CWebApplication类中的设置。

应用和使用布局

在调用CController::render()方法时,布局文件的使用是隐含的。也就是说,当您调用render()方法来渲染一个视图文件时,Yii 将把视图文件的内容嵌入到控制器类中指定的布局文件中,或者嵌入到应用程序级别指定的布局文件中。您可以通过调用CController::renderPartial()方法来避免对渲染的视图文件应用任何布局装饰。

如前所述,布局文件通常用于装饰其他视图文件。布局的一个示例用途是为每个页面提供一致的页眉和页脚布局。当调用render()方法时,幕后发生的是首先将调用发送到指定视图文件的renderPartial()。这个输出存储在一个名为$content的变量中,然后可以在布局文件中使用。因此,一个非常简单的布局文件可能如下所示:

<!DOCTYPE html>
<html>
<head>
<title>Title of the document</title>
</head>
<body>
  <div id="header">
    Some Header Content Here
  </div>

  <div id="content">
    <?php echo $content; ?>
  </div>

  <div id="footer">
      Some Footer Content Here
  </div>
</body>
</html>

实际上让我们试一试。创建一个名为newlayout.php的新文件,并将其放在布局文件的默认目录/protected/views/layouts/中。将前面的 HTML 内容添加到此文件中并保存。现在我们将通过修改我们的站点控制器来使用这个新布局。打开SiteController.php并通过在这个类中显式添加它来覆盖基类中设置的布局属性,如下所示:

class SiteController extends Controller
{

  public $layout='newlayout';

这将把布局文件设置为newlayout.php,但仅适用于这个控制器。现在,每当我们在SiteController中调用render()方法时,将使用newlayout.php布局文件。

SiteController负责渲染的一个页面是登录页面。让我们来看看该页面,以验证这些更改。如果我们导航到http://localhost/trackstar/site/login(假设我们还没有登录),我们现在看到类似以下截图的东西:

应用和使用布局

如果我们简单地注释掉我们刚刚添加的$layout属性并再次刷新登录页面,我们将回到使用原始的main.php布局,并且我们的页面现在将恢复到之前的样子。

解构 main.php 布局文件

到目前为止,我们的应用程序页面都使用main.php布局文件来提供主要的布局标记。在开始对我们的页面布局和设计进行更改之前,最好先仔细查看一下这个主要布局文件。您可以从本章的可下载代码中完整查看它,或者在gist.github.com/3781042上查看独立文件。

第一行到第五行可能会让你觉得有些熟悉:

<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html  xml:lang="en" lang="en">
<head>
  <meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
  <meta name="language" content="en" />

这些行定义了一个标准的 HTML 文档类型声明,后面是一个开始的<html>元素,然后是我们的<head>元素的开始。在<head>标记内,我们首先有一个<meta>标记来声明标准的XHTML-compliant uft-8字符编码,然后是另一个<meta>标记,指定English作为网站编写的主要语言。

介绍 Blueprint CSS 框架

以下几行以注释<!—blueprint CSS framework -->开头,可能对您来说不太熟悉。Yii 的另一个很棒的地方是,在适当的时候,它利用其他最佳框架,Blueprint CSS 框架就是一个例子。

Blueprint CSS 框架是在我们最初创建应用程序时使用yiic工具时作为副产品包含在应用程序中的。它包含在内是为了帮助标准化 CSS 开发。Blueprint 是一个 CSS 网格框架。它有助于标准化您的 CSS,提供跨浏览器兼容性,并在 HTML 元素放置方面提供一致性,有助于减少 CSS 错误。它提供了许多屏幕和打印友好的布局定义,并通过提供您所需的所有 CSS 来快速启动设计,使您的设计看起来不错并且位置正确。有关 Blueprint 框架的更多信息,请访问www.blueprintcss.org/

因此,以下代码行是 Blueprint CSS 框架所必需的和特定的:

<!-- blueprint CSS framework -->
<link rel="stylesheet" type="text/css" href="<?php echo Yii::app()->request->baseUrl; ?>/css/screen.css" media="screen, projection" />
<link rel="stylesheet" type="text/css" href="<?php echo Yii::app()->request->baseUrl; ?>/css/print.css" media="print" />
<!--[if lt IE 8]>
<link rel="stylesheet" type="text/css" href="<?php echo Yii::app()->request->baseUrl; ?>/css/ie.css" media="screen, projection" />
<![endif]-->

调用Yii::app()->request->baseUrl;在这里用于获取应用程序的相对 URL。

了解 Blueprint 安装

Yii 绝不要求使用 Blueprint。但是,由于默认应用程序生成包括该框架,了解其安装和使用将是有益的。

Blueprint 的典型安装首先涉及下载框架文件,然后将其三个.css文件放入 Yii 应用程序的主css目录中。如果我们在 TrackStar 应用程序的主Webroot/css目录下查看,我们已经看到包含了这三个文件:

  • ie.css

  • print.css

  • screen.css

所以幸运的是,基本安装已经完成。为了利用该框架,先前的<link>标签需要放置在每个网页的<head>标签下。这就是为什么这些声明是在布局文件中进行的。

接下来的两个<link>标签如下:

<link rel="stylesheet" type="text/css" href="<?php echo Yii::app()->request->baseUrl; ?>/css/main.css" />
<link rel="stylesheet" type="text/css" href="<?php echo Yii::app()->request->baseUrl; ?>/css/form.css" />

这些<link>标签定义了一些自定义的css定义,用于提供布局声明,除了 Blueprint 文件中指定的声明之外。您应该始终将任何自定义定义放在 Blueprint 提供的定义下面,以便您的自定义声明优先。

设置页面标题

根据每个页面设置特定且有意义的页面标题对于搜索引擎索引您网站页面和希望将您网站特定页面加为书签的用户来说非常重要。我们主要布局文件中的下一行指定了浏览器中的页面标题:

<title><?php echo CHtml::encode($this->pageTitle); ?></title>

请记住,在视图文件中,$this指的是最初呈现视图的控制器类实例。$pageTitle属性在 Yii 的CController基类中定义,并将默认为动作名称,后跟控制器名称。这在特定控制器类中甚至在每个特定视图文件中都可以轻松自定义。

定义页面页眉

通常情况下,网站被设计为在许多页面上重复具有一致的页眉内容。我们主要布局文件中的接下来几行定义了页面页眉的区域:

<body>
<div class="container" id="page">

  <div id="header">
    <div id="logo"><?php echo CHtml::encode(Yii::app()->name); ?></div>
  </div><!-- header -->

第一个带有container类的<div>标签是 Blueprint 框架所必需的,以便将内容显示为网格。

注意

再次,使用 Blueprint CSS Grid 框架或任何其他 CSS 框架并不是 Yii 的要求。它只是为了帮助您在需要时快速启动设计布局。

接下来的三行布置了我们在这些页面上看到的主要内容的第一部分。它们显示了应用程序的名称。到目前为止,它一直显示文本My Web Application。我相信这让你们中的一些人感到疯狂。尽管我们以后可能会更改为使用标志图像,但让我们继续将其更改为我们应用程序的真实名称TrackStar

我们可以在 HTML 中直接硬编码这个名称。然而,如果我们修改应用程序配置以反映我们的新名称,这些更改将在整个网站的任何地方传播,无论Yii::app()->name在哪里使用。我相信你现在可以轻松地在睡梦中做出这个简单的改变。只需打开主config.php文件/protected/config/main.php,在那里我们定义了应用程序配置设置,并将name属性的值从'name'=>'My Web Application'更改为新值'name'=>'TrackStar'

保存文件,刷新浏览器,主页的标题现在应该看起来类似于以下截图:

定义页面标题

我们立即注意到在上一个截图中已经在两个地方进行了更改。恰好我们的主页内容的视图文件/protected/views/site/index.php也使用了应用程序名称属性。由于我们在应用程序配置文件中进行了更改,我们的更改在两个地方都得到了反映。

由于名称属性是您可能决定在某个时候更改的内容,因此也定义应用程序id属性是一个好习惯。这个属性被框架用来创建唯一的签名键作为访问会话变量、缓存数据和其他令牌的前缀。如果没有指定id属性,则将使用name属性。因此更改它可能会使这些数据无效。让我们也为我们的应用程序定义一个id属性。这是添加到protected/config/main.php中的,就像我们为name属性所做的那样。我们可以使用与我们的名称相同的值:

'id'=>'TrackStar',

显示菜单导航项

主站点的导航控件通常在 Web 应用程序的多个页面上重复出现,并且将其放在布局中使得重复使用非常容易。我们主要布局文件中的下一个标记和代码块定义了顶级菜单项:

<div id="mainmenu">
  <?php $this->widget('zii.widgets.CMenu',array(
    'items'=>array(
      array('label'=>'Home', 'url'=>array('/site/index')),
      array('label'=>'About', 'url'=>array('/site/page', 'view'=>'about')),
      array('label'=>'Contact', 'url'=>array('/site/contact')),
      array('label'=>'Login', 'url'=>array('/site/login'), 'visible'=>Yii::app()->user->isGuest),
      array('label'=>'Logout ('.Yii::app()->user->name.')', 'url'=>array('/site/logout'), 'visible'=>!Yii::app()->user->isGuest)
    ),
  )); ?>
</div><!-- mainmenu -->

在这里,我们看到 Zii 组件之一称为CMenu正在被使用。我们在第八章中介绍了 Zii,添加用户评论。为了唤起你的记忆,Zii 扩展库是 Yii 开发团队开发的一组扩展。这个库与核心 Yii 框架一起打包。任何这些扩展都可以在 Yii 应用程序中轻松使用,只需通过使用路径别名引用所需的扩展类文件,形式为zii.path.to.ClassName。根别名zii由应用程序预定义,其余路径相对于这个框架目录。由于这个 Zii 菜单扩展位于您的文件系统上的YiiRoot/zii/widgets/CMenu.php,所以我们可以在应用程序代码中简单地使用zii.widgets.CMenu来引用它。

CMenu接受一个提供菜单项的关联数组。每个项目数组包括一个将要显示的label,一个该项目应链接到的 URL,以及一个可选的第三个值visible,它是一个boolean值,指示是否应该显示该菜单项。在这里,当定义登录注销菜单项时使用了这个。我们只希望登录菜单项在用户尚未登录时显示为可点击链接。反之,我们只希望注销菜单链接在用户已经登录时显示。数组中的 visible 元素的使用允许我们根据用户是否已登录动态显示这些链接。使用Yii::app()->user->isGuest是为了这个目的。如果用户未登录,则返回true,如果用户已登录,则返回false。我相信你已经注意到,登录选项在您登录时会变成应用程序主菜单中的注销选项,反之亦然。

让我们更新我们的菜单,为用户提供导航到我们特定的 TrackStar 功能的方法。首先,我们不希望匿名用户能够访问任何真正的功能,除了登录。因此,我们需要确保登录页面更多或更少地成为匿名用户的主页。此外,已登录用户的主页应该只是他们项目的列表。我们将通过进行以下更改来实现这一点:

  1. 将我们应用程序的默认主页 URL 更改为项目列表页面,而不仅仅是site/index

  2. 将默认控制器SiteController中的默认操作更改为登录操作。这样,任何访问顶级 URL http://localhost/trackstar/ 的匿名用户都将被重定向到登录页面。

  3. 修改我们的actionLogin()方法,如果用户已经登录,则将用户重定向到项目列表页面。

  4. 主页菜单项更改为项目,并将 URL 更改为项目列表页面。

这些都是我们需要做出的简单更改。从顶部开始,我们可以在主应用程序config.php文件中更改主页 URL 应用程序属性。打开protected/config/main.php并将以下name=>value对添加到返回的数组中:

'homeUrl'=>'/trackstar/project',

这就是需要做出的所有更改。

对于下一个更改,打开protected/controllers/SiteController.php并将以下内容添加到控制器类的顶部:

public $defaultAction = 'login';

这将默认操作设置为登录。现在,如果您访问应用程序的顶级 URL http://localhost/trackstar/,您应该被带到登录页面。唯一的问题是,无论您是否已经登录,您都将继续从这个顶级 URL 被带到登录页面。让我们通过实施上一个列表的第 3 步来解决这个问题。在SiteController中的actionLogin()方法中添加以下代码:

public function actionLogin()
{

  if(!Yii::app()->user->isGuest) 
     {
          $this->redirect(Yii::app()->homeUrl);
     }

这将把所有已登录用户重定向到应用程序的homeUrl,我们刚刚将其设置为项目列表页面。

最后,让我们修改CMenu小部件的输入数组,以更改主页菜单项的规范。在main.php布局文件中更改该代码块,并用以下内容替换array('label'=>'Home', 'url'=>array('/site/index')),这一行:

array('label'=>'Projects', 'url'=>array('/project')),

通过这个替换,我们之前概述的所有更改都已经就位。现在,如果我们以匿名用户身份访问 TrackStar 应用程序,我们将被引导到登录页面。如果我们点击项目链接,我们仍然会被引导到登录页面。我们仍然可以访问关于联系页面,这对于匿名用户来说是可以的。如果我们登录,我们将被引导到项目列表页面。现在,如果我们点击项目链接,我们将被允许查看项目列表。

创建面包屑导航

回到我们的main.php布局文件,跟随菜单小部件之后的三行代码定义了另一个 Zii 扩展小部件,称为CBreadcrumbs

<?php $this->widget('zii.widgets.CBreadcrumbs', array(
  'links'=>$this->breadcrumbs,
)); ?><!-- breadcrumbs -->

这是另一个 Zii 小部件,可用于显示指示当前页面位置的链接列表,相对于整个网站中的其他页面。例如,格式为项目 >> 项目 1 >> 编辑的链接导航列表表示用户正在查看项目 1 的编辑页面。这对用户找回起点(即所有项目的列表)以及轻松查看他们在网站页面层次结构中的位置非常有帮助。这就是为什么它被称为面包屑。许多网站在其设计中实现了这种类型的 UI 导航组件。

要使用此小部件,我们需要配置其links属性,该属性指定要显示的链接。此属性的预期值是定义从起始点到正在查看的特定页面的面包屑路径的数组。使用我们之前的示例,我们可以将links数组指定如下:

array(
  'Projects'=>array('project/index'),
  'Project 1'=>array('project/view','id'=>1),
  'Edit',
  )

breadcrumbs小部件默认情况下会根据应用程序配置设置homeUrl自动添加顶级主页链接。因此,从前面的代码片段生成的面包屑将如下所示:

主页 >> 项目 >> 项目 1 >> 编辑

由于我们明确将应用程序的$homeUrl属性设置为项目列表页面,所以在这种情况下我们的前两个链接是相同的。布局文件中的代码将链接属性设置为呈现视图的控制器类的$breadcrumbs属性。您可以在使用 Gii 代码生成工具创建控制器文件时为我们自动生成的几个视图文件中明确看到这一点。例如,如果您查看protected/views/project/update.php,您将在该文件的顶部看到以下代码片段:

$this->breadcrumbs=array(
  'Projects'=>array('index'),
  $model->name=>array('view','id'=>$model->id),
  'Update',
);

如果我们在网站上导航到该页面,我们将看到主导航栏下方生成的以下导航面包屑:

创建面包屑导航

指定被布局装饰的内容

布局文件中的下一行显示了被该布局文件装饰的视图文件的内容放置位置:

<?php echo $content; ?>

这在本章的前面已经讨论过。当您在控制器类中使用$this->render()来显示特定的视图文件时,隐含了使用布局文件。这个方法的一部分是将呈现的特定视图文件中的所有内容放入一个名为$content的特殊变量中,然后将其提供给布局文件。因此,如果我们再次以项目更新视图文件为例,$content的内容将是包含在文件protected/views/project/update.php中的呈现内容。

定义页脚

页眉区域一样,通常情况下网站被设计为在许多页面上重复显示一致的页脚内容。我们的main.php布局文件的最后几行定义了每个页面的一致页脚

<div id="footer">
    Copyright &copy; <?php echo date('Y'); ?> by My Company.<br/>
    All Rights Reserved.<br/>
    <?php echo Yii::powered(); ?>
</div><!-- footer -->

这里没有什么特别的,但我们应该继续更新它以反映我们特定的网站。我们可以将前面的代码片段中的My Company简单地更改为TrackStar,然后完成。刷新网站中的页面现在将显示我们的页脚,如下面的截图所示:

定义页脚

嵌套布局

尽管我们在页面上看到的原始布局确实使用了文件protected/layouts/main.php,但这并不是全部。当我们的初始应用程序创建时,所有控制器都被创建为扩展自位于protected/components/Controller.php的基础控制器。如果我们偷看一下这个文件,我们会看到布局属性被明确定义。但它并没有指定主布局文件。相反,它将column1指定为所有子类的默认布局文件。您可能已经注意到,当新应用程序创建时,还为我们生成了一些布局文件,全部位于protected/views/layouts/目录中:

  • column1.php

  • column2.php

  • main.php

因此,除非在子类中明确覆盖,否则我们的控制器将column1.php定义为主要布局文件,而不是main.php

你可能会问,为什么我们要花那么多时间去了解main.php呢?嗯,事实证明,column1.php布局文件本身也被main.php布局文件装饰。因此,不仅可以通过布局文件装饰普通视图文件,而且布局文件本身也可以被其他布局文件装饰,形成嵌套布局文件的层次结构。这样可以极大地提高设计的灵活性,也极大地减少了视图文件中的重复标记的需要。让我们更仔细地看看column1.php,看看是如何实现这一点的。

该文件的内容如下:

<?php $this->beginContent('//layouts/main'); ?>
<div id="content">
  <?php echo $content; ?>
</div><!-- content -->
<?php $this->endContent(); ?>

在这里,我们看到了一些以前没有见过的方法的使用。基本控制器方法beginContent()endContent()被用来用指定的视图装饰封闭的内容。这里指定的视图是我们的主布局页面'//layouts/main'beginContent()方法实际上使用了内置的 Yii 小部件CContentDecorator,其主要目的是允许嵌套布局。因此,beginContent()endContent()之间的任何内容都将使用在beginContent()调用中指定的视图进行装饰。如果未指定任何内容,它将使用在控制器级别指定的默认布局,或者如果在控制器级别未指定,则使用应用程序级别的默认布局。

注意

在前面的代码片段中,我们看到视图文件被双斜杠'//'指定。在这种情况下,将在应用程序的视图路径下搜索视图,而不是在当前活动模块的视图路径下搜索。这迫使它使用主应用程序视图路径,而不是模块的视图路径。模块是下一章的主题。

其余部分就像普通的布局文件一样。当呈现此column1.php布局文件时,特定视图文件中的所有标记都将包含在变量$content中,然后此布局文件中包含的其他标记将再次包含在变量$content中,以供最终呈现主父布局文件main.php使用。

让我们通过一个示例来走一遍。以登录视图的呈现为例,即SiteController::actionLogin()方法中的以下代码:

$this->render('login');

在幕后,正在执行以下步骤:

  1. 呈现特定视图文件/protected/views/site/login.php中的所有内容,并通过变量$content将该内容提供给控制器中指定的布局文件,在这种情况下是column1.php

  2. 由于column1.php本身被布局main.php装饰,所以在beingContent()endContent()调用之间的内容再次被呈现,并通过$content变量再次提供给main.php文件。

  3. 布局文件main.php被呈现并返回给用户,包含了登录页面的特定视图文件的内容以及“嵌套”布局文件column1.php的内容。

当我们最初创建应用程序时,自动生成的另一个布局文件是column2.php。您可能不会感到惊讶地发现,该文件布局了一个两列设计。我们可以在项目页面中看到这个布局的使用,其中右侧显示了一个小子菜单操作小部件。该布局的内容如下,我们可以看到也使用了相同的方法来实现嵌套布局。

<?php $this->beginContent('//layouts/main'); ?>
<div class="span-19">
  <div id="content">
    <?php echo $content; ?>
  </div><!-- content -->
</div>
<div class="span-5 last">
  <div id="sidebar">
  <?php
    $this->beginWidget('zii.widgets.CPortlet', array(
      'title'=>'Operations',
    ));
    $this->widget('zii.widgets.CMenu', array(
      'items'=>$this->menu,
      'htmlOptions'=>array('class'=>'operations'),
    ));
    $this->endWidget();
  ?>
  </div><!-- sidebar -->
</div>
<?php $this->endContent(); ?>

创建主题

主题提供了一种系统化的方式来定制 Web 应用程序的设计布局。 MVC 架构的许多好处之一是将演示与其他“后端”内容分离。主题通过允许您在运行时轻松而显着地改变 Web 应用程序的整体外观和感觉,充分利用了这种分离。 Yii 允许极其简单地应用主题,以提供 Web 应用程序设计的更大灵活性。

在 Yii 中构建主题

在 Yii 中,每个主题都表示为一个目录,包含视图文件、布局文件和相关资源文件,如图像、CSS 文件和 JavaScript 文件。主题的名称与其目录名称相同。默认情况下,所有主题都位于相同的WebRoot/themes目录下。当然,与所有其他应用程序设置一样,可以配置默认目录为其他目录。要这样做,只需修改themeManager应用程序组件的basePath属性和baseUrl属性。

主题目录下的内容应该以与应用程序基本路径下相同的方式进行组织。因此,所有视图文件都位于views/目录下,布局视图文件位于views/layouts/下,系统视图文件位于views/system/下。例如,如果我们创建了一个名为custom的新主题,并且想要用这个主题下的新视图替换ProjectController的更新视图,我们需要创建一个新的update.php视图文件,并将其保存在我们的应用项目中,路径为themes/custom/views/project/update.php

创建主题

让我们试试看,给我们的 TrackStar 应用程序做一点小改变。我们需要给我们的新主题命名,并在Webroot/themes目录下创建一个同名的目录。我们将发挥我们的极端创造力,将我们的新主题命名为newtheme

Webroot/themes/newtheme位置创建一个新目录来保存这个新主题。然后在这个新创建的目录下,创建另外两个新目录,分别叫做css/views/。前者不是主题系统所必需的,但有助于我们组织 CSS。后者是必需的,如果我们要对默认视图文件进行任何修改,而我们是要修改的。因为我们要稍微改变main.php布局文件,所以在这个新创建的views/目录下需要再创建一个名为layouts/的目录(记住目录结构需要与默认的Webroot/protected/views/目录中的相同)。

现在让我们做一些改变。由于我们的视图文件标记已经引用了Webroot/css/main.css文件中当前定义的css类和id名称,所以最快的路径到应用程序的新外观是以此为起点,并根据需要进行更改。当然,这不是必需的,因为我们可以在新主题中重新创建应用程序的每个视图文件。但是为了保持简单,我们将通过对为我们创建应用程序时自动生成的main.css文件以及主要布局文件main.php进行一些更改来创建我们的新主题。

首先,让我们复制这两个文件并将它们放在我们的新主题目录中。将文件Webroot/css/main.css复制到新位置Webroot/themes/newtheme/css/main.css,并将文件Webroot/protected/views/layouts/main.php复制到新位置Webroot/themes/newtheme/views/layouts/main.php

现在我们可以打开新复制的main.css文件,删除内容,并添加必要的样式来创建我们的新主题。为了我们的示例,我们将使用本章可下载代码中提供的 CSS,或者在gist.github.com/3779729上提供的独立文件。

您可能已经注意到,一些更改引用了我们项目中尚不存在的图像文件。我们在 body 声明中添加了一个images/background.gif图像引用,#mainmenu ID 声明中引用了一个新的images/bg2.gif图像,以及#header ID 声明中引用了一个新的images/header.jpg图像。这些都可以在可下载的源代码中找到。我们将把这些新图像放在css/目录中的一个图像目录中,即Webroot/themes/newtheme/css/images/

这些更改生效后,我们需要对新主题中的main.php布局文件进行一些小的调整。首先,我们需要修改<head>元素中的标记,以正确引用我们的新main.css文件。目前,main.css文件是通过以下行引入的:

<link rel="stylesheet" type="text/css" href="<?php echo Yii::app()->request->baseUrl; ?>/css/main.css" />

这引用了应用程序请求的baseUrl属性来构建到 CSS 文件的相对路径。然而,我们想要使用我们新主题中的main.css文件。为此,我们可以依靠主题管理器应用程序组件,默认定义使用 Yii 内置的CThemeManager.php类。我们访问主题管理器的方式与访问其他应用程序组件的方式相同。因此,我们应该使用主题管理器定义的基本 URL,它知道应用程序在任何给定时间使用的主题。修改前面提到的/themes/newtheme/views/layouts/main.php中的代码如下:

<link rel="stylesheet" type="text/css" href="<?php echo Yii::app()->theme->baseUrl; ?>/css/main.css" />

一旦我们配置应用程序使用我们的新主题(这是我们尚未完成的),这个baseUrl将解析为我们的主题目录所在的相对路径。

我们需要做的另一个小改变是从头部中移除应用程序标题的显示。由于我们修改了 CSS 以使用新的图像文件来提供我们的头部和标志信息,我们不需要在这个部分显示应用程序名称。因此,在/themes/newtheme/views/layouts/main.php中,我们只需要改变以下代码:

<div id="header">
  <div id="logo"><?php echo CHtml::encode(Yii::app()->name); ?></div>
</div><!-- header -->

将上述代码修改如下:

<div id="header"></div><!-- header image is embedded into the #header declaration in main.css -->

我们已经放置了一个注释来提醒我们头部图像的定义位置。

现在一旦我们配置应用程序使用我们的新主题,它将首先在主题目录中查找main.php布局文件,如果存在的话就使用该文件。

配置应用程序使用主题

好的,有了我们现在创建并放置好的newtheme主题,我们需要告诉应用程序使用这个主题。这样做非常容易。只需通过改变主应用程序配置文件来修改主应用程序的theme属性设置。到目前为止,我们已经成为了这样做的老手。只需在/protected/config/main.php文件中的返回数组中添加以下name=>value对:

'theme'=>'newtheme',

一旦保存了这个更改,我们的应用程序现在使用我们新创建的主题,并且有了全新的外观。当我们查看登录页面时,也就是我们的默认主页(如果没有登录),我们现在看到了以下截图中所示的内容:

配置应用程序使用主题

当然,这并不是一个巨大的改变。我们保持了改动相当小,但它们确实展示了创建新主题的过程。应用程序首先会在这个新主题中查找视图文件,如果存在的话就使用它们,否则会从默认位置获取。你可以看到给应用程序赋予新的外观和感觉是多么容易。你可以为每个季节或基于不同的心情创建一个新主题,然后根据需要快速轻松地改变应用程序以适应季节或心情。

将网站翻译成其他语言

在结束本章之前,我们将讨论 Yii 中的国际化(i18n)和本地化(l10n)。国际化指的是以一种可以适应各种语言而无需进行基础工程更改的方式设计软件应用程序的过程。本地化指的是将国际化的软件应用程序适应特定地理位置或语言的过程,通过添加与地区相关的格式化和翻译文本。Yii 以以下方式支持这些功能:

  • 它为几乎每种语言和地区提供了地区数据

  • 它提供了辅助翻译文本消息字符串和文件的服务

  • 它提供了与地区相关的日期和时间格式化

  • 它提供了与地区相关的数字格式化

定义地区和语言

区域是指定义用户语言、国家和可能与用户位置相关的任何其他用户界面首选项的一组参数。它通常由一个语言标识符和一个区域标识符组成的复合ID来标识。例如,en_us的区域 ID 代表美国地区的英语。为了保持一致,Yii 中的所有区域 ID 都标准化为小写的LanguageIDLanguageID_RegionID格式(例如,enen_us)。

在 Yii 中,区域数据表示为CLocale类的实例或其子类。它提供特定于区域的信息,包括货币和数字符号、货币、数字、日期和时间格式,以及月份、星期几等日期相关名称。通过区域 ID,可以通过使用静态方法CLocale::getInstance($localeID)或使用应用程序来获取相应的CLocale实例。以下示例代码使用应用程序组件基于en_us区域标识符创建一个新实例:

Yii::app()->getLocale('en_us');

Yii 几乎为每种语言和地区提供了区域数据。这些数据来自通用区域数据存储库(cldr.unicode.org/),存储在根据各自区域 ID 命名的文件中,并位于 Yii 框架目录framework/i18n/data/中。因此,在上一个示例中创建新的CLocale实例时,用于填充属性的数据来自文件framework/i18n/data/en_us.php。如果您查看此目录,您将看到许多语言和地区的数据文件。

回到我们的例子,如果我们想要获取特定于美国地区的英语月份名称,我们可以执行以下代码:

$locale = Yii::app()->getLocale('en_us');
print_r($locale->monthNames);

其输出将产生以下结果:

定义区域和语言

如果我们想要意大利语的相同月份名称,我们可以执行相同的操作,但创建一个不同的CLocale实例:

$locale = Yii::app()->getLocale('it');
print_r($locale->monthNames);

现在我们的输出将产生以下结果:

定义区域和语言

第一个实例基于数据文件framework/i18n/data/en_us.php,后者基于framework/i18n/data/it.php。如果需要,可以配置应用程序的localeDataPath属性,以指定一个自定义目录,您可以在其中添加自定义区域设置数据文件。

执行语言翻译

也许i18n最受欢迎的功能是语言翻译。如前所述,Yii 提供了消息翻译和视图文件翻译。前者将单个文本消息翻译为所需的语言,后者将整个文件翻译为所需的语言。

翻译请求包括要翻译的对象(文本字符串或文件)、对象所在的源语言以及要将对象翻译为的目标语言。Yii 应用程序区分其目标语言和源语言。目标语言是我们针对用户的语言(或区域),而语言是指应用程序文件所写的语言。到目前为止,我们的 TrackStar 应用程序是用英语编写的,也是针对英语用户的。因此,到目前为止,我们的目标语言和源语言是相同的。Yii 的国际化功能,包括翻译,仅在这两种语言不同时适用。

执行消息翻译

通过调用以下应用程序方法执行消息翻译:

Yii::t(string $category, string $message, array $params=array ( ), string $source=NULL, string $language=NULL)

该方法将消息从源语言翻译为目标语言。

在翻译消息时,必须指定类别,以便允许消息在不同类别(上下文)下进行不同的翻译。类别Yii保留用于 Yii 框架核心代码使用的消息。

消息也可以包含参数占位符,这些占位符在调用Yii::t()时将被实际参数值替换。以下示例描述了错误消息的翻译。这个消息翻译请求将在原始消息中用实际的$errorCode值替换{errorCode}占位符:

Yii::t('category', 'The error: "{errorCode}" was encountered during the last request.',     array('{errorCode}'=>$errorCode));

翻译消息存储在称为消息源的存储库中。消息源表示为CMessageSource的实例或其子类的实例。当调用Yii::t()时,它将在消息源中查找消息,并在找到时返回其翻译版本。

Yii 提供以下类型的消息源:

  • CPhpMessageSource:这是默认的消息源。消息翻译存储为 PHP 数组中的键值对。原始消息是键,翻译后的消息是值。每个数组表示特定类别消息的翻译,并存储在一个单独的 PHP 脚本文件中,文件名为类别名。相同语言的 PHP 翻译文件存储在以区域 ID 命名的相同目录下。所有这些目录都位于由basePath指定的目录下。

  • CGettextMessageSource:消息翻译存储为GNU Gettext文件。

  • CDbMessageSource:消息翻译存储在数据库表中。

消息源作为应用程序组件加载。Yii 预先声明了一个名为messages的应用程序组件,用于存储用户应用程序中使用的消息。默认情况下,此消息源的类型是CPhpMessageSource,用于存储 PHP 翻译文件的基本路径是protected/messages

一个示例将有助于将所有这些内容整合在一起。让我们将登录表单上的表单字段标签翻译成一个我们称为Reversish的虚构语言。Reversish是通过将英语单词或短语倒转来书写的。所以这里是我们登录表单字段标签的 Reversish 翻译:

英文 Reversish
用户名 Emanresu
密码 Drowssap
Remember me next time Emit txen em rebmemer

我们将使用默认的CPhpMessageSource实现来存储我们的消息翻译。所以我们需要做的第一件事是创建一个包含我们翻译的 PHP 文件。我们将把区域 ID 设置为rev,并且现在只是称为类别default。我们需要在消息基本目录下创建一个遵循格式/localeID/CategoryName.php的新文件。所以我们需要在/protected/messages/rev/default.php下创建一个新文件,然后在该文件中添加以下翻译数组:

<?php
return array(
    'Username' => 'Emanresu',
    'Password' => 'Drowssap',
    'Remember me next time' => 'Emit txen em rebmemer',
);

接下来,我们需要将应用程序目标语言设置为 Reversish。我们可以在应用程序配置文件中执行此操作,以便影响整个站点。只需在/protected/config/main.php文件中的返回数组中添加以下name=>value对:

'language'=>'rev',

现在我们需要做的最后一件事是调用Yii::t(),以便我们的登录表单字段标签通过翻译发送。这些表单字段标签在LoginForm::attributeLabels()方法中定义。用以下代码替换整个方法:

/**
   * Declares attribute labels.
   */
  public function attributeLabels()
  {
    return array(
      'rememberMe'=>Yii::t('default','Remember me next time'),
      'username'=>Yii::t('default', 'Username'),
      'password'=>Yii::t('default', 'Password'),
    );
  }

现在,如果我们再次访问我们的登录表单,我们将看到一个新的 Reversish 版本,如下面的截图所示:

执行消息翻译

执行文件翻译

Yii 还提供了根据应用程序的目标区域设置使用不同文件的能力。文件翻译是通过调用应用程序方法 CApplication::findLocalizedFile() 来实现的。该方法接受文件的路径,并将在具有与目标区域 ID 相同名称的目录下查找具有相同名称的文件。目标区域 ID 要么作为方法的显式输入指定,要么作为应用程序配置中指定的内容。

让我们试一试。我们真正需要做的就是创建适当的翻译文件。我们将继续翻译登录表单。因此,我们创建一个新的视图文件 /protected/views/site/rev/login.php,然后添加我们的翻译内容。同样,这太长了,无法完整列出,但您可以在可下载的代码文件或独立内容中查看 gist.github.com/3779850

我们已经在主配置文件中为应用程序设置了目标语言,并在调用 render('login') 时,获取本地化文件的调用将在幕后为我们处理。因此,有了这个文件,我们的登录表单现在看起来如下截图所示:

执行文件翻译

总结

在这一章中,我们已经看到 Yii 应用程序如何让您快速轻松地改进设计。我们介绍了布局文件的概念,并介绍了如何在应用程序中使用这些文件来布置需要在许多不同的网页上以类似方式实现的内容和设计。这也向我们介绍了 CMenuCBreadcrumbs 内置小部件,它们在每个页面上提供了非常易于使用的 UI 导航结构。

然后,我们介绍了 Web 应用程序中主题的概念以及如何在 Yii 中创建它们。我们看到主题允许您轻松地为现有的 Web 应用程序提供新的外观,并允许您重新设计应用程序,而无需重建任何功能或“后端”。

最后,我们通过 i18n 和语言翻译的视角来看应用程序的面貌变化。我们学会了如何设置应用程序的目标区域,以启用本地化设置和语言翻译。

在本章和之前的章节中,我们已经多次提到“模块”,但尚未深入了解它们在 Yii 应用程序中的具体内容。这将是下一章的重点。

第十一章:使用 Yii 模块

到目前为止,我们已经为我们的 TrackStar 应用程序添加了许多功能。如果你回想一下第七章,“用户访问控制”,我们介绍了用户访问控制,根据用户角色层次结构限制某些功能。这在按项目基础限制对一些管理功能的访问上非常有帮助。例如,在特定项目中,您可能不希望允许团队的所有成员删除项目。我们使用基于角色的访问控制实现,将用户分配到项目中的特定角色,然后根据这些角色允许/限制对功能的访问。

然而,我们尚未解决的是应用程序整体的管理需求。像 TrackStar 这样的 Web 应用程序通常需要具有完全访问权限的特殊用户。一个例子是能够管理系统中每个用户的所有 CRUD 操作,而不管项目如何。我们应用程序的完整管理员应该能够登录并删除或更新任何用户、任何项目、任何问题,管理所有评论等。此外,通常情况下,我们构建适用于整个应用程序的额外功能,例如能够向所有用户留下站点范围的系统消息,管理电子邮件活动,打开/关闭某些应用程序功能,管理角色和权限层次结构本身,更改站点主题等。由于向管理员公开的功能可能与向普通用户公开的功能差异很大,因此将这些功能与应用程序的其余部分分开是一个很好的主意。我们将通过在 Yii 中构建所有我们的管理功能来实现这种分离,这被称为模块

功能规划

在这一章中,我们将专注于以下细粒度的开发任务:

  • 创建一个新模块来容纳管理功能

  • 为管理员添加系统范围消息的能力,以在项目列表页面上查看

  • 将新主题应用于模块

  • 创建一个新的数据库表来保存系统消息数据

  • 为我们的系统消息生成所有 CRUD 功能

  • 将对新模块内的所有功能的访问限制为管理员用户

  • 在项目列表页面上显示新的系统消息

使用模块

Yii 中的模块非常类似于包含在较大应用程序中的整个小型应用程序。它具有非常相似的结构,包含模型、视图、控制器和其他支持组件。但是,模块本身不能作为独立应用程序部署;它们必须驻留在一个应用程序中。

模块在以模块化方式构建应用程序方面非常有用。大型应用程序通常可以分成离散的应用程序功能,可以使用模块分别构建。网站功能,如添加用户论坛或用户博客,或站点管理员功能,是一些可以从主要站点功能中分割出来的示例,使它们可以在将来的项目中轻松重复使用。我们将使用一个模块来在我们的应用程序中创建一个独特的位置,以容纳我们的管理功能。

创建一个模块

使用我们的好朋友 Gii 创建一个新模块非常简单。在我们的 URL 更改就位后,该工具现在可以通过http://localhost/trackstar/gii访问。导航到那里,并在左侧菜单中选择模块生成器选项。您将看到以下截图:

创建一个模块

我们需要为模块提供一个唯一的名称。由于我们正在创建一个 admin 模块,我们将非常有创意地给它命名为admin。在Module ID字段中输入这个名称,然后单击Preview按钮。如下截图所示,它将向您展示它打算生成的所有文件,允许您在创建它们之前预览每个文件:

创建模块

单击Generate按钮,让它创建所有这些文件。您需要确保您的/protected文件夹对 Web 服务器进程是可写的,以便它可以自动创建必要的目录和文件。以下截图显示了成功生成模块的情况:

创建模块

让我们更仔细地看看模块生成器为我们创建了什么。在 Yii 中,模块被组织为一个目录,其名称与模块的唯一名称相同。默认情况下,所有模块目录都位于protected/modules下。每个模块目录的结构与我们主应用程序的结构非常相似。这个命令为我们做的事情是为 admin 模块创建目录结构的骨架。由于这是我们的第一个模块,顶级目录protected/modules被创建,然后在其下创建了一个admin/目录。以下截图显示了执行module命令时创建的所有目录和文件:

创建模块

模块必须有一个module类,该类直接或从CWebModule的子类扩展。模块类名称是通过组合模块 ID(即我们创建模块admin时提供的名称)和字符串Module来创建的。模块 ID 的第一个字母也被大写。所以在我们的情况下,我们的 admin 模块类文件名为AdminModule.php。模块类用作存储模块代码中共享信息的中心位置。例如,我们可以使用CWebModuleparams属性来存储模块特定的参数,并使用其components属性在模块级别共享应用程序组件。这个模块类的作用类似于应用程序类对整个应用程序的作用。所以CWebModule对我们的模块来说就像CWebApplication对我们的应用程序一样。

使用模块

就像成功创建消息所指示的那样,在我们可以使用新模块之前,我们需要配置主应用程序的modules属性,以便包含它供使用。在我们向应用程序添加gii模块时,我们就已经这样做了,这使我们能够访问 Gii 代码生成工具。我们在主配置文件protected/config/main.php中进行了这些更改。以下突出显示的代码指示了必要的更改:

'modules'=>array(
      'gii'=>array(
            'class'=>'system.gii.GiiModule',
            'password'=>'iamadmin',
      ),
 **'admin',**
   ),

保存这些更改后,我们的新admin模块已经准备好供使用。我们可以通过访问http://localhost/trackstar/admin/default/index来查看为我们创建的简单索引页面。用于访问我们模块中页面的请求路由结构与我们主应用程序页面的结构类似,只是我们还需要在路由中包含moduleID目录。我们的路由将具有一般形式/moduleID/controllerID/actionID。因此,URL 请求/admin/default/index正在请求admin模块的默认控制器的索引方法。当我们访问这个页面时,我们会看到类似以下截图的内容:

使用模块

模块布局

我们会注意到,在上一章中创建的主题 newtheme 也被应用到了我们的模块上。原因是我们的模块控制器类扩展了 protected/components/Controller.php,它将其布局指定为 $layout='//layouts/column1'。关键在于这个定义前面的双斜杠。这指定我们使用主应用程序路径而不是特定模块路径来查找布局文件。因此,我们得到的布局文件与我们的应用程序的其余部分相同。如果我们将其改为单斜杠而不是双斜杠,我们会看到我们的 admin 模块根本没有应用布局。请尝试一下。原因是现在,只有单斜杠,即 $layout='/layouts/column1',它正在在模块内寻找布局文件而不是父应用程序。请继续进行此更改,并在我们继续进行时保持单斜杠定义。

您可以在模块中几乎可以单独配置所有内容,包括布局文件的默认路径。Web 模块的默认布局路径是 /protected/modules/[moduleID]/views/layouts,在我们的情况下是 admin。我们可以看到在这个目录下没有文件,因此没有默认布局可应用于模块。

由于我们指定了一个主题,我们的情况稍微复杂一些。我们还可以在这个主题中管理所有模块视图文件,包括模块布局视图文件。如果我们这样做,我们需要添加到我们的主题目录结构以适应我们的新模块。目录结构非常符合预期。它的一般形式是 /themes/[themeName]/views/[moduleID]/layouts/ 用于布局文件,/themes/[themeName]/views/[moduleID]/[controllerID]/ 用于控制器视图文件。

为了帮助澄清这一点,让我们来看一下 Yii 在尝试决定为我们的新 admin 模块使用哪些视图文件时的决策过程。如前所述,如果我们在布局视图文件之前使用双斜杠("//")指定,它将查找父应用程序以找到布局文件。但让我们看看当我们使用单斜杠并要求它在模块内找到适当的布局文件时的情况。在单斜杠的情况下,当在我们的 admin 模块的 DefaultController.php 文件中发出 $this->render('index') 时,正在发生以下情况:

  1. 由于调用了 render(),而不是 renderPartial(),它将尝试用布局文件装饰指定的 index.php 视图文件。由于我们的应用程序当前配置为使用名为 newtheme 的主题,它将首先在此主题目录下查找布局文件。我们的新模块的 DefaultController 类扩展了我们的应用程序组件 Controller.php,它将 column1 指定为其 $layout 属性。这个属性没有被覆盖,所以它也是 DefaultController 的布局文件。最后,由于这一切都发生在 admin 模块内部,Yii 首先寻找以下布局文件:

/themes/newtheme/views/admin/layouts/column1.php

(请注意在此目录结构中包含 moduleID。)

  1. 这个文件不存在,所以它会回到模块的默认位置查找。如前所述,默认布局目录对每个模块都是特定的。所以在这种情况下,它将尝试定位以下布局文件:

/protected/modules/admin/views/layouts/column1.php

  1. 这个文件也不存在,所以将无法应用布局。现在它将尝试渲染指定的 index.php 视图文件而不使用布局。然而,由于我们已经为这个应用程序指定了特定的 newtheme 主题,它将首先寻找以下视图文件:

/themes/newtheme/views/admin/default/index.php

  1. 这个文件也不存在,所以它会再次在这个模块(AdminModule)的默认位置内寻找这个控制器(DefaultController.php),即/protected/modules/admin/views/default/index.php

这解释了为什么页面http://localhost/trackstar/admin/default/index在没有任何布局的情况下呈现(在我们使用单斜杠作为布局文件声明的前缀时,$layout='/layouts/column1')。为了现在完全分开和简单,让我们将我们的视图文件管理在模块的默认位置,而不是在newtheme主题下。此外,让我们将我们的admin模块应用与我们原始应用程序相同的设计,即在应用新主题之前应用的应用程序外观。这样,我们的admin页面将与我们的正常应用程序页面有非常不同的外观,这将帮助我们记住我们处于特殊的管理部分,但我们不必花时间设计新的外观。

应用布局

首先让我们为我们的模块设置一个默认布局值。我们在模块类/protected/modules/AdminModule.phpinit()方法中设置模块范围的配置设置。因此,打开该文件并添加以下突出显示的代码:

class AdminModule extends CWebModule
{
  public function init()
  {
    // this method is called when the module is being created
    // you may place code here to customize the module or the application

    // import the module-level models and components
    $this->setImport(array(
      'admin.models.*',
      'admin.components.*',
    ));

 **$this->layout = 'main';**

  }

这样,如果我们没有在更细粒度的级别上指定布局文件,比如在控制器类中,所有模块视图都将由模块默认布局目录/protected/modules/admin/views/layouts/中的main.php布局文件装饰。

现在当然,我们需要创建这个文件。从主应用程序中复制两个布局文件/protected/views/layouts/main.php/protected/views/layouts/column1.php,并将它们都放在/protected/modules/admin/views/layouts/目录中。在将这些文件复制到新位置后,我们需要对它们进行一些小的更改。

首先让我们修改column1.php。在调用beginContent()时删除对//layouts/main的显式引用:

**<?php $this->beginContent(); ?>**
<div id="content">
  <?php echo $content; ?>
</div><!-- content -->
<?php $this->endContent(); ?>

在调用beginContent()时不指定输入文件将导致它使用我们模块的默认布局,我们刚刚设置为我们新复制的main.php文件。

现在让我们对main.php布局文件进行一些更改。我们将在应用程序标题文本中添加管理控制台,以强调我们处于应用程序的一个独立部分。我们还将修改菜单项,添加一个链接到管理首页,以及一个链接返回到主站点。我们可以从菜单中删除关于联系链接,因为我们不需要在管理部分重复这些选项。文件的添加如下所示:

...
<div class="container" id="page">

  <div id="header">
 **<div id="logo"><?php echo CHtml::encode(Yii::app()->name) . " Admin Console"; ?></div>**
  </div><!-- header -->

  <div id="mainmenu">
    <?php $this->widget('zii.widgets.CMenu',array(
      'items'=>array(
 **array('label'=>'Back To Main Site', 'url'=>array('/project')),**
 **array('label'=>'Admin', 'url'=>array('/admin/default/index')),**
        array('label'=>'Login', 'url'=>array('/site/login'), 'visible'=>Yii::app()->user->isGuest),
        array('label'=>'Logout ('.Yii::app()->user->name.')', 'url'=>array('/site/logout'), 'visible'=>!Yii::app()->user->isGuest)
      ),
    )); ?>
  </div><!-- mainmenu -->

我们可以保持文件的其余部分不变。现在,如果我们访问我们的admin模块页面http://localhost/trackstar/admin/default/index,我们会看到以下截图:

应用布局

如果我们点击返回主站点链接,我们会看到我们被带回了主应用程序的新主题版本。

限制管理员访问

你可能已经注意到的一个问题是,任何人,包括访客用户,都可以访问我们的新admin模块。我们正在构建这个管理模块来暴露应用程序功能,这些功能只能让具有管理权限的用户访问。因此,我们需要解决这个问题。

幸运的是,我们已经在应用程序中实现了 RBAC 访问模型,在第七章中,用户访问控制。现在我们需要做的就是扩展它,包括一个新的管理员角色,并为该角色提供新的权限。

如果您还记得第七章中的内容,用户访问控制,我们使用了 Yii 的console命令来实现我们的 RBAC 结构。我们需要添加到其中。因此,打开包含该console命令的文件/protected/commands/shell/RbacCommand.php,并在我们创建owner角色的地方添加以下代码:

//create a general task-level permission for admins
 $this->_authManager->createTask("adminManagement", "access to the application administration functionality");   
 //create the site admin role, and add the appropriate permissions   
$role=$this->_authManager->createRole("admin"); 
$role->addChild("owner");
$role->addChild("reader"); 
$role->addChild("member");
$role->addChild("adminManagement");
//ensure we have one admin in the system (force it to be user id #1)
$this->_authManager->assign("admin",1);

这将创建一个名为adminManagement的新任务和一个名为admin的新角色。然后,它将添加ownerreadermember角色以及adminManagement任务作为子级,以便admin角色从所有这些角色继承权限。最后,它将分配admin角色给我们系统中的第一个用户,以确保我们至少有一个管理员可以访问我们的管理模块。

现在我们必须重新运行命令以更新数据库的这些更改。要这样做,只需使用rbac命令运行yiic命令行工具:

**% cd Webroot/trackstar/protected**
**% ./yiic rbac**

注意

随着添加了这个额外的角色,我们还应该更新在提示时显示的消息文本,以继续指示将创建第四个角色。我们将把这留给读者来练习。这些更改已经在可下载的代码文件中进行了更改,供您参考。

有了这些对我们的 RBAC 模型的更改,我们可以在AdminModule::beforeControllerAction()方法中添加对admin模块的访问检查,以便除非用户处于admin角色,否则不会执行admin模块中的任何内容:

public function beforeControllerAction($controller, $action)
{
  if(parent::beforeControllerAction($controller, $action))
  {
    // this method is called before any module controller action is performed
    // you may place customized code here
 **if( !Yii::app()->user->checkAccess("admin") )**
 **{**
 **throw new CHttpException(403,Yii::t('application','You are not authorized to perform this action.'));**
 **}**
 **return true;**
  }
  else
    return false;
}

有了这个,如果一个尚未被分配admin角色的用户现在尝试访问管理模块中的任何页面,他们将收到一个 HTTP 403 授权错误页面。例如,如果您尚未登录并尝试访问管理页面,您将收到以下结果:

限制管理员访问

对于任何尚未分配给admin角色的用户也是如此。

现在我们可以有条件地将管理部分的链接添加到我们主应用程序菜单中。这样,具有管理访问权限的用户就不必记住繁琐的 URL 来导航到管理控制台。提醒一下,我们的主应用程序菜单位于应用程序的主题默认应用程序布局文件/themes/newtheme/views/layouts/main.php中。打开该文件并将以下突出显示的代码添加到菜单部分:

<div id="mainmenu">
  <?php $this->widget('zii.widgets.CMenu',array(
    'items'=>array(
      array('label'=>'Projects', 'url'=>array('/project')),
      array('label'=>'About', 'url'=>array('/site/page', 'view'=>'about')),
      array('label'=>'Contact', 'url'=>array('/site/contact')),
 **array('label'=>'Admin', 'url'=>array('/admin/default/index'), 'visible'=>Yii::app()->user->checkAccess("admin")),**
      array('label'=>'Login', 'url'=>array('/site/login'), 'visible'=>Yii::app()->user->isGuest),
      array('label'=>'Logout ('.Yii::app()->user->name.')', 'url'=>array('/site/logout'), 'visible'=>!Yii::app()->user->isGuest)
    ),
  )); ?>
</div><!-- mainmenu -->

现在,当以具有admin访问权限的用户(在我们的情况下,我们将其设置为user id = 1,“用户一”)登录到应用程序时,我们将在顶部导航中看到一个新的链接,该链接将带我们进入我们新添加的站点管理部分。

限制管理员访问

添加系统范围的消息

模块可以被视为一个小型应用程序本身,向模块添加功能实际上与向主应用程序添加功能的过程相同。让我们为管理员添加一些新功能;我们将添加管理用户首次登录到应用程序时显示的系统范围消息的功能。

创建数据库表

通常情况下,对于全新的功能,我们需要一个地方来存储我们的数据。我们需要创建一个新表来存储我们的系统范围消息。对于我们的示例,我们可以保持这个非常简单。这是我们表的定义:

CREATE TABLE `tbl_sys_message` 
( 
  `id` INTEGER NOT NULL PRIMARY KEY AUTO_INCREMENT,
  `message` TEXT NOT NULL, 
  `create_time` DATETIME,
  `create_user_id` INTEGER,
  `update_time` DATETIME,
  `update_user_id` INTEGER  
) 

当然,当添加这个新表时,我们将创建一个新的数据库迁移来管理我们的更改。

**% cd Webroot/trackstar/protected**
**% ./yiic migrate create_system_messages_table**

这些命令在protected/migrations/目录下创建一个新的迁移文件。这个文件的内容可以从可下载的代码或可在gist.github.com/3785282上找到的独立代码片段中获取。(我们没有包括类名;请记住,您的文件名和相应的类将具有不同的时间戳前缀。)

一旦这个文件就位,我们就可以运行我们的迁移来添加这个新表:

**% cd Webroot/trackstar/protected**
**% ./yiic migrate**

创建我们的模型和 CRUD 脚手架

现在我们已经创建了表,下一步是使用我们喜爱的工具 Gii 代码生成器生成model类。我们将首先使用Model Generator选项创建model类,然后使用Crud Generator选项创建基本的脚手架,以便快速与这个模型进行交互。前往 Gii 工具表单以创建新的模型(http://localhost/trackstar/gii/model)。这一次,由于我们是在模块的上下文中进行操作,我们需要明确指定模型路径。填写表单中的值,如下面截图所示(当然,你的Code Template路径值应该根据你的本地设置具体而定):

创建我们的模型和 CRUD 脚手架

注意,我们将Model Path文本框更改为application.modules.admin.models。点击Generate按钮生成Model Class值。

现在我们可以以类似的方式创建 CRUD 脚手架。我们之前所做的和现在要做的唯一真正的区别是我们要指定model类的位置在admin模块中。从 Gii 工具中选择Crud Generator选项后,填写Model ClassController ID表单字段,如下截图所示:

创建我们的模型和 CRUD 脚手架

这告诉工具我们的model类在admin模块下,我们的控制器类以及与此代码生成相关的所有其他文件也应该放在admin模块中。

首先点击Preview按钮,然后点击Generate完成创建。下面的截图显示了此操作创建的所有文件列表:

创建我们的模型和 CRUD 脚手架

添加到我们新功能的链接

让我们在主admin模块导航中添加一个新的菜单项,链接到我们新创建的消息功能。打开包含我们模块主菜单导航的文件/protected/modules/admin/views/layouts/main.php,并向菜单小部件添加以下array项:

array('label'=>'System Messages', 'url'=>array('/admin/sysMessage/idex')),

如果我们在http://localhost/trackstar/admin/sysMessage/create查看新的系统消息,我们会看到以下内容:

添加到我们新功能的链接

我们新系统消息功能的自动生成控制器和视图文件是使用主应用程序的两列布局文件创建的。如果你查看SysMessageController.php类文件,你会看到布局定义如下:

public $layout='//layouts/column2';

注意前面的双斜杠。所以我们可以看到我们新添加的 admin 功能没有使用我们admin模块的布局文件。我们可以修改controller类以使用我们现有的单列布局文件,或者我们可以在我们的模块布局文件中添加一个两列布局文件。后者会稍微容易一些,而且看起来也更好,因为所有的视图文件都被创建为在第二个右侧列中显示它们的子菜单项(即链接到所有 CRUD 功能)。我们还需要修改我们新创建的模型类和相应的表单,以删除一些不需要的表单字段。以下是我们需要做的全部内容:

  1. 将主应用程序中的两列布局复制到我们的模块中,即将/protected/views/layouts/column2.php复制到/protected/modules/admin/views/layouts/column2.php

  2. 在新复制的column2.php文件的第一行,将//layouts/main作为beginContent()方法调用的输入删除。

  3. 修改SysMessage模型类以扩展TrackstarActiveRecord。(如果你记得的话,这会自动更新我们的create_time/userupdate_time/user属性。)

  4. 修改SysMessageController控制器类,以使用模块目录中的新column2.php布局文件,而不是主应用程序中的文件。自动生成的代码已经指定了$layout='//layouts/column2',但我们需要将其简单地改为$layout='/layouts/column2'

  5. 由于我们正在扩展TrackstarActiveRecord,我们可以从自动生成的 sys-messages 创建表单中删除不必要的字段,并从模型类中删除它们的相关规则。例如,从modules/admin/views/sysMessage/_form.php中删除以下表单字段:

<div class="row">
    <?php echo $form->labelEx($model,'create_time'); ?>
    <?php echo $form->textField($model,'create_time'); ?>
    <?php echo $form->error($model,'create_time'); ?>
  </div>

  <div class="row">
    <?php echo $form->labelEx($model,'create_user_id'); ?>
    <?php echo $form->textField($model,'create_user_id'); ?>
    <?php echo $form->error($model,'create_user_id'); ?>
  </div>

  <div class="row">
    <?php echo $form->labelEx($model,'update_time'); ?>
    <?php echo $form->textField($model,'update_time'); ?>
    <?php echo $form->error($model,'update_time'); ?>
  </div>

  <div class="row">
    <?php echo $form->labelEx($model,'update_user_id'); ?>
    <?php echo $form->textField($model,'update_user_id'); ?>
    <?php echo $form->error($model,'update_user_id'); ?>
  </div> 
  1. 然后从SysMessage::rules()方法中更改这两条规则:
array('create_user, update_user', 'numerical', 'integerOnly'=>true), and array('create_time, update_time', 'safe'),

重要的是只为用户可以输入的那些字段指定规则。对于已定义规则的字段,可以从POSTGET请求中以批量方式设置,并且保留不希望用户访问的字段的规则可能会导致安全问题。

我们应该做的最后一次更改是更新我们简单的访问规则,以反映只有admin角色的用户才能访问我们的操作方法的要求。这主要是为了说明目的,因为我们已经在AdminModule::beforeControlerAction方法中使用我们的 RBAC 模型方法处理了访问。实际上,我们可以完全删除accessRules()方法。但是,让我们更新它们以反映要求,以便您可以看到使用访问规则方法将如何工作。在SysMessageController::accessRules()方法中,将整个内容更改为以下内容:

public function accessRules()
{
  return array(
    array('allow',  // allow only users in the 'admin' role access to our actions
      'actions'=>array('index','view', 'create', 'update', 'admin', 'delete'),
      'roles'=>array('admin'),
    ),
    array('deny',  // deny all users
      'users'=>array('*'),
    ),
  );
}

好的,有了所有这些,现在如果我们访问http://localhost/trackstar/admin/sysMessage/create来访问我们的新消息输入表单,我们将看到类似以下截图的内容:

添加到我们的新功能的链接

填写此表单,消息为Hello Users! This is your admin speaking...,然后单击Create。应用程序将重定向您到这条新创建消息的详细列表页面,如下截图所示:

添加到我们的新功能的链接

向用户显示消息

现在我们的系统中有一条消息,让我们在应用程序主页上向用户显示它。

导入新的模型类以进行应用程序范围的访问

为了从应用程序的任何地方访问新创建的模型,我们需要将其作为应用程序配置的一部分导入。修改protected/config/main.php以包括新的admin module models文件夹:

// autoloading model and component classes
'import'=>array(
  'application.models.*',
  'application.components.*',
 **'application.modules.admin.models.*',**
),

选择最近更新的消息

我们将限制显示只有一条消息,并且我们将根据表中的update_time列选择最近更新的消息。由于我们想要将其添加到主项目列表页面,我们需要修改ProjectController::actionIndex()方法。通过添加以下突出显示的代码来修改该方法:

public function actionIndex()
  {
      $dataProvider=new CActiveDataProvider('Project');

      Yii::app()->clientScript->registerLinkTag(
          'alternate',
          'application/rss+xml',
          $this->createUrl('comment/feed'));

 **//get the latest system message to display based on the update_time column**
 **$sysMessage = SysMessage::model()->find(array(**
 **'order'=>'t.update_time DESC',**
 **));**
 **if($sysMessage !== null)**
 **$message = $sysMessage->message;**
 **else**
 **$message = null;**

      $this->render('index',array(
        'dataProvider'=>$dataProvider,
 **'sysMessage'=>$message,**
      ));
  }

现在我们需要修改我们的视图文件来显示这个新的内容。将以下代码添加到views/project/index.php,就在<h1>Projects</h1>标题文本上方:

<?php if($sysMessage !== null):?>
    <div class="sys-message">
        <?php echo $sysMessage; ?>
    </div>
<?php endif; ?>

现在当我们访问我们的项目列表页面(即我们应用程序的主页)时,我们可以看到它显示如下截图所示:

选择最近更新的消息

添加一点设计调整

好的,这做到了我们想要的,但是这条消息对用户来说并不是很突出。让我们通过向我们的主 CSS 文件(/themes/newtheme/css/main.css)添加一小段代码来改变这一点:

div.sys-message
{
  padding:.8em;
  margin-bottom:1em;
  border:3px solid #ddd;
  background:#9EEFFF;
  color:#FF330A;
  border-color:#00849E;
}

有了这个,我们的消息现在在页面上真的很突出。以下截图显示了具有这些更改的消息:

添加一点设计调整

有人可能会认为这个设计调整有点过分。用户可能会因为不得不整天盯着这些消息颜色而感到头疼。与其淡化颜色,不如使用一点 JavaScript 在 5 秒后淡出消息。由于我们将在用户访问这个主页时每次显示消息,防止他们盯着它太久可能会更好。

我们将简化操作,并利用 Yii 随附的强大 JavaScript 框架 jQuery。jQuery是一个开源的 JavaScript 库,简化了 HTML 文档对象模型DOM)和 JavaScript 之间的交互。深入了解 jQuery 的细节超出了本书的范围。值得访问其文档以更加了解其特性。由于 Yii 随附了 jQuery,您可以在视图文件中简单地注册 jQuery 代码,Yii 将为您包含核心 jQuery 库。

我们还将使用应用程序助手组件CClientScript来为我们在生成的网页中注册 jQuery JavaScript 代码。它将确保它已被放置在适当的位置,并已被正确标记和格式化。

因此,让我们修改之前添加的内容,包括一个 JavaScript 片段来淡出消息。用以下内容替换我们刚刚添加到views/project/index.php的内容:

<?php if($sysMessage != null):?>
    <div class="sys-message">
        <?php echo $sysMessage; ?>
    </div>
<?php
  Yii::app()->clientScript->registerScript(
     'fadeAndHideEffect',
     '$(".sys-message").animate({opacity: 1.0}, 5000).fadeOut("slow");'
  );
endif; ?>

现在,如果我们重新加载主项目列表页面,我们会看到消息在 5 秒后淡出。有关您可以轻松添加到页面的酷炫 jQuery 效果的更多信息,请查看api.jquery.com/category/effects/上提供的 JQuery API 文档。

最后,为了确信一切都按预期工作,您可以添加另一条系统范围的消息。由于这条更新时间更近的消息将显示在项目列表页面上。

总结

在本章中,我们介绍了 Yii 模块的概念,并通过使用一个模块来创建站点的管理部分来演示了它的实用性。我们演示了如何创建一个新模块,如何更改模块的布局和主题,如何在模块内添加应用程序功能,甚至如何利用现有的 RBAC 模型,将授权访问控制应用于模块内的功能。我们还演示了如何使用 jQuery 为我们的应用程序增添一些 UI 效果。

通过添加这个管理界面,我们现在已经把应用程序的所有主要部分都放在了适当的位置。虽然应用程序非常简单,但我们觉得现在是时候为其准备投入生产了。下一章将重点介绍如何为我们的应用程序准备生产部署。

第十二章:投产准备

尽管我们的应用程序缺乏大量的功能功能,我们(虽然是想象中的)截止日期正在临近,我们(同样是想象中的)客户对将应用程序投入生产环境感到焦虑。尽管我们的应用程序在生产中真正见到天日可能还需要一些时间,但现在是时候让应用程序“准备投产”了。在我们的最后一个开发章节中,我们将做到这一点。

功能规划

为了实现我们的应用程序为生产环境做好准备的目标,我们将专注于以下细粒度的任务:

  • 实现 Yii 的应用程序日志记录框架,以确保我们记录关于关键生产错误和事件的信息

  • 实现 Yii 的应用程序错误处理框架,以确保我们在生产中正确处理错误,并了解这在生产环境和开发环境中的工作方式有所不同

  • 实现应用程序数据缓存以帮助提高性能

日志记录

日志记录是一个在应用程序开发的这个后期阶段应该被讨论的话题。在软件应用程序的故障排除中,信息、警告和严重错误消息是非常宝贵的,尤其是对于那些在生产环境中由真实用户使用的应用程序。

作为开发人员,我们都熟悉这个故事。您已经满足了您正在构建的应用程序的所有功能要求。所有单元和功能测试都通过了。应用程序已经通过了 QA 的批准,每个人都对它准备投产感到很满意。但是一旦它投入使用,并且承受着真实用户的真实生产负载,它的行为就会出乎意料。一个良好的日志记录策略可能会成为快速解决问题和回滚数周甚至数月的辛苦工作之间的区别。

Yii 提供了灵活和可扩展的日志记录功能。记录的数据可以根据日志级别和消息类别进行分类。使用级别和类别过滤器,日志消息可以进一步路由到不同的目的地,例如写入磁盘上的文件,存储在数据库中,发送给管理员作为电子邮件,或在浏览器窗口中显示。

消息记录

我们的应用程序实际上一直在每个请求时记录许多信息消息。当初始应用程序被创建时,它被配置为处于调试模式,而在此模式下,Yii 框架本身会记录信息消息。我们实际上看不到这些消息,因为默认情况下它们被记录到内存中。因此,它们只在请求的生命周期内存在。

应用程序是否处于调试模式由根目录index.php文件中的以下行控制:

defined('YII_DEBUG') or define('YII_DEBUG',true);

为了查看被记录的内容,让我们在我们的SiteController类中快速创建一个动作方法来显示这些消息:

public function actionShowLog()
{
  echo "Logged Messages:<br><br>";
CVarDumper::dump(Yii::getLogger()->getLogs());
}

在这里,我们使用 Yii 的CVarDumper辅助类,这是var_dumpprint_r的改进版本,因为它能够正确处理递归引用对象。

如果我们通过发出请求http://localhost/trackstar/site/showLog来调用此动作,我们会看到类似以下截图的内容:

消息记录

如果我们注释掉在index.php中定义的全局应用程序调试变量,并刷新页面,我们会注意到一个空数组;也就是说,没有记录任何内容。这是因为这种系统级别的调试信息级别的日志记录是通过调用Yii::trace来实现的,只有在应用程序处于特殊的调试模式时才会记录这些消息。

我们可以使用两种静态应用程序方法之一在 Yii 应用程序中记录消息:

  • Yii::log($message, $level, $category);

  • Yii::trace($message, $category);

正如前面提到的,这两种方法之间的主要区别在于Yii::trace仅在应用程序处于调试模式时记录消息。

类别和级别

在使用Yii::log()记录消息时,我们需要指定它的类别和级别。类别是一个字符串,用于为被记录的消息提供额外的上下文。这个字符串可以是任何你喜欢的,但许多人使用的约定是一个格式为xxx.yyy.zzz的字符串,类似于路径别名。例如,如果在我们的应用程序的SiteController类中记录了一条消息,我们可以选择使用类别application.controllers.SiteController

除了指定类别,使用Yii::log时,我们还可以指定消息的级别。级别可以被认为是消息的严重程度。您可以定义自己的级别,但通常它们具有以下值之一:

  • 跟踪:这个级别通常用于跟踪应用程序在开发过程中的执行流程。

  • 信息:这是用于记录一般信息。如果没有指定级别,则这是默认级别。

  • 概要:这是用于性能概要功能,稍后在本章中描述。

  • 警告:这是用于警告消息。

  • 错误:这是用于致命错误消息。

添加登录消息日志

例如,让我们向我们的用户登录方法添加一些日志记录。我们将在方法开始时提供一些基本的调试信息,以指示方法正在执行。然后,我们将在成功登录时记录一条信息,以及在登录失败时记录一条警告信息。根据以下突出显示的代码修改我们的SiteController::actionLogin()方法(整个方法在可下载的代码中已经存在,或者您可以从gist.github.com/3791860下载独立的方法)。

public function actionLogin()
{
 **Yii::trace("The actionLogin() method is being requested", "application.controllers.SiteController");**
    …

    // collect user input data
    if(isset($_POST['LoginForm']))
    {
      …  
if($model->validate() && $model->login()) 
      {
 **Yii::log("Successful login of user: " . Yii::app()->user->id, "info", "application.controllers.SiteController");**
        $this->redirect(Yii::app()->user->returnUrl);
 **}**
 **else**
 **{**
 **Yii::log("Failed login attempt", "warning", "application.controllers.SiteController");**
 **}**

    }
    …
}

如果我们现在成功登录(或进行了失败的尝试)并访问我们的页面查看日志,我们看不到它们(如果您注释掉了调试模式声明,请确保您已经将应用程序重新放回调试模式进行此练习)。同样,原因是,默认情况下,Yii 中的日志实现只是将消息存储在内存中。它们在请求完成时消失。这并不是非常有用。我们需要将它们路由到一个更持久的存储区域,这样我们就可以在生成它们的请求之外查看它们。

消息路由

正如我们之前提到的,默认情况下,使用Yii::logYii::trace记录的消息被保存在内存中。通常,如果这些消息在浏览器窗口中显示,保存到一些持久存储(如文件中),在数据库中,或作为电子邮件发送,它们会更有用。Yii 的消息路由允许将日志消息路由到不同的目的地。

在 Yii 中,消息路由由CLogRouter应用组件管理。它允许您定义日志消息应路由到的目的地列表。

为了利用这个消息路由,我们需要在protected/config/main.php配置文件中配置CLogRouter应用组件。我们通过设置它的 routes 属性与所需的日志消息目的地进行配置。

如果我们打开主配置文件,我们会看到一些配置已经提供(再次感谢使用yiic webapp命令最初创建我们的应用程序)。以下内容已在我们的配置中定义:

'log'=>array
  'class'=>'CLogRouter',
  'routes'=>array(
    array(
      'class'=>'CFileLogRoute',
      'levels'=>'error, warning',
    ),
    // uncomment the following to show log messages on web pages
    /*
    array(
      'class'=>'CWebLogRoute',
    ),
    */
  ),
),

log应用组件配置为使用框架类CLogRouter。当然,如果您有日志要求没有完全满足基础框架实现,您也可以创建和使用自定义子类;但在我们的情况下,这将工作得很好。

在先前配置中类定义之后的是routes属性的定义。在这种情况下,只指定了一个路由。这个路由使用了 Yii 框架的消息路由类CFileLogRouteCFileLogRoute消息路由类使用文件系统保存消息。默认情况下,消息被记录在应用运行时目录下的一个文件中,即/protected/runtime/application.log。实际上,如果您一直在跟着我们并且有自己的应用程序,您可以查看这个文件,会看到框架记录的几条消息。levels规定只有日志级别为errorwarning的消息才会被路由到这个文件。在先前代码中被注释掉的部分指定了另一个路由CWebLogRoute。如果使用,这将把消息路由到当前请求的网页上。以下是 Yii 1.1 版本当前可用的消息路由列表:

  • CDbLogRoute:将消息保存在数据库表中

  • CEmailLogRoute:将消息发送到指定的电子邮件地址

  • CFileLogRoute:将消息保存在应用程序的runtime目录下的文件中,或者您选择的任何其他目录中

  • CWebLogRoute:在当前网页末尾显示消息

  • CProfileLogRoute:在当前网页末尾显示分析消息

我们在SiteController::actionLogin()方法中添加的日志记录使用了Yii::trace来记录一条消息,然后使用Yii::log来记录另外两条消息。使用Yii::trace时,日志级别会自动设置为trace。当使用Yii::log时,如果登录成功,我们指定为info日志级别,但如果登录尝试失败,则为warning级别。让我们修改日志路由配置,将traceinfo级别的消息写入到一个新的、单独的文件infoMessages.log中,该文件与我们的application.log文件在同一目录中。另外,让我们配置它将警告消息写入到浏览器。为此,我们将对配置进行以下更改(已突出显示):

'log'=>array(
  'class'=>'CLogRouter',
  'routes'=>array(
    array(
      'class'=>'CFileLogRoute',
 **'levels'=>'error',**
 **),**
 **array(**
 **'class'=>'CFileLogRoute',**
 **'levels'=>'info, trace',**
 **'logFile'=>'infoMessages.log',**
 **),**
 **array(**
 **'class'=>'CWebLogRoute',**
 **'levels'=>'warning',**
 **),**

现在,在保存这些更改后,让我们尝试不同的场景。首先,尝试成功的登录。这样做将把我们的两条登录消息写入到我们的新的/protected/runtime/infoMessages.log文件中,一条是 trace,另一条是记录成功登录。成功登录后,查看该文件会显示以下内容(完整列表被截断以节省一些树木):

.....
**2012/06/15 00:31:52 [trace] [application.controllers.SiteController] The actionLogin() method is being requested**
2012/06/15 00:31:52 [trace] [system.web.CModule] Loading "user" application component
2012/06/15 00:31:52 [trace] [system.web.CModule] Loading "session" application component
2012/06/15 00:31:52 [trace] [system.web.CModule] Loading "db"                                                                                                                                                                                                                                                                                                                                                                                                                             application component
2012/06/15 00:31:52 [trace] [system.db.CDbConnection] Opening DB connection
.....
**2012/06/15 00:31:52 [info] [application.controllers.SiteController] Successful login of user: 1**
.....

如您所见,其中有很多内容,不仅仅是我们的两条消息!但我们的两条确实显示出来了;它们在先前的列表中是加粗的。现在我们将所有的 trace 消息路由到这个新文件中,所有框架的 trace 消息也会显示在这里。这实际上非常有信息量,真的有助于您了解请求在框架中的生命周期。在幕后有很多事情发生。当将此应用程序移至生产环境时,我们显然会关闭这种冗长的日志记录。在非调试模式下,我们只会看到我们的单个info级别消息。但在追踪错误和弄清楚应用程序在做什么时,这种详细级别的信息非常有用。知道它在需要时/如果需要时存在是非常令人安心的。

现在让我们尝试失败的登录尝试场景。如果我们现在注销并再次尝试登录,但这次指定不正确的凭据以强制登录失败,我们会看到我们的警告级别显示在返回的网页底部,就像我们配置的那样。以下屏幕截图显示了显示此警告:

消息路由

使用CFileLogRouter消息路由器时,日志文件存储在logPath属性下,并且文件名由logFile方法指定。这个日志路由器的另一个很棒的功能是自动日志文件轮换。如果日志文件的大小大于maxFileSize属性中设置的值(以千字节为单位),则会执行轮换,将当前日志文件重命名为带有.1后缀的文件。所有现有的日志文件都向后移动一个位置,即.2.3.1.2。属性maxLogFiles可用于指定要保留多少个文件。

注意

如果在应用程序中使用die;exit;来终止执行,日志消息可能无法正确写入其预期的目的地。如果需要显式终止 Yii 应用程序的执行,请使用Yii::app()->end()。这提供了应用程序成功写出日志消息的机会。此外,CLogger组件具有一个$autoDump属性,如果设置为true,将允许实时将日志消息写入其目的地(即在调用->log()时)。由于潜在的性能影响,这应仅用于调试目的,但可以是一个非常有价值的调试选项。

处理错误

正确处理软件应用程序中不可避免发生的错误非常重要。这又是一个话题,可以说应该在编写应用程序之前就已经涵盖了,而不是在这个晚期阶段。幸运的是,由于我们一直在依赖 Yii 框架内的工具来自动生成我们的核心应用程序骨架,我们的应用程序已经在利用 Yii 的一些错误处理功能。

Yii 提供了一个基于 PHP 5 异常的完整错误处理框架,这是通过集中的点处理程序中的异常情况的内置机制。当主 Yii 应用程序组件被创建来处理传入的用户请求时,它会注册其CApplication::handleError()方法来处理 PHP 警告和通知,并注册其CApplication::handleException()方法来处理未捕获的 PHP 异常。因此,如果在应用程序执行期间发生 PHP 警告/通知或未捕获的异常,其中一个错误处理程序将接管控制并启动必要的错误处理过程。

注意

错误处理程序的注册是在应用程序的构造函数中通过调用 PHP 函数set_exception_handlerset_error_handler来完成的。如果您不希望 Yii 处理这些类型的错误和异常,可以通过在主index.php入口脚本中将全局常量YII_ENABLE_ERROR_HANDLERYII_ENABLE_EXCEPTION_HANDLER定义为 false 来覆盖此默认行为。

默认情况下,应用程序将使用框架类CErrorHandler作为负责处理 PHP 错误和未捕获异常的应用程序组件。这个内置应用程序组件的任务之一是使用适当的视图文件显示这些错误,这取决于应用程序是在调试模式还是生产模式下运行。这允许您为这些不同的环境自定义错误消息。在开发环境中显示更详细的错误信息以帮助解决问题是有意义的。但允许生产应用程序的用户查看相同的信息可能会影响安全性。此外,如果您在多种语言中实现了您的站点,CErrorHandler还会选择用于显示错误的首选语言。

在 Yii 中,您引发异常的方式与通常引发 PHP 异常的方式相同。在需要时,可以使用以下一般语法引发异常:

throw new ExceptionClass('ExceptionMessage');

Yii 提供的两个异常类是:

  • CException

  • CHttpException

CException是一个通用的异常类。CHttpException表示一个 HTTP 错误,并且还携带一个statusCode属性来表示 HTTP 状态码。在浏览器中,错误的显示方式取决于抛出的异常类。

显示错误

正如之前提到的,当CErrorHandler应用组件处理错误时,它会决定在显示错误时使用哪个视图文件。如果错误是要显示给最终用户的,就像使用CHttpException时一样,其默认行为是使用一个名为errorXXX的视图,其中XXX代表 HTTP 状态码(例如,400、404 或 500)。如果错误是内部错误,只应显示给开发人员,它将使用一个名为Exception的视图。当应用程序处于调试模式时,将显示完整的调用堆栈以及源文件中的错误行。

然而,当应用程序运行在生产模式下时,所有错误都将使用errorXXX视图文件显示。这是因为错误的调用堆栈可能包含不应该显示给任何最终用户的敏感信息。

当应用程序处于生产模式时,开发人员应依靠错误日志提供有关错误的更多信息。当发生错误时,错误级别的消息将始终被记录。如果错误是由 PHP 警告或通知引起的,消息将被记录为php类别。如果错误是由未捕获的exception引起的,类别将是exception.ExceptionClassName,其中异常类名是CHttpExceptionCException的一个或子类。因此,可以利用前一节讨论的日志记录功能来监视生产应用程序中发生的错误。当然,如果发生致命的 PHP 错误,您仍然需要检查由 PHP 配置设置定义的错误日志,而不是 Yii 的错误日志。

默认情况下,CErrorHandler按以下顺序搜索相应视图文件的位置:

  • WebRoot/themes/ThemeName/views/system:当前活动主题下的系统视图目录

  • WebRoot/protected/views/system:应用程序的默认系统视图目录

  • YiiRoot/framework/views:Yii 框架提供的标准系统视图目录

您可以通过在应用程序或主题的系统视图目录下创建自定义错误视图文件来自定义错误显示。

Yii 还允许您定义一个特定的控制器动作方法来处理错误的显示。这实际上是我们的应用程序配置的方式。当我们通过一些示例时,我们会看到这一点。

我们使用 Gii Crud Generator 工具创建 CRUD 脚手架时为我们生成的一些代码已经利用了 Yii 的错误处理。其中一个例子是ProjectController::loadModel()方法。该方法定义如下:

public function loadModel($id)
  {
    $model=Project::model()->findByPk($id);
    if($model===null)
      throw new CHttpException(404,'The requested page does not exist.');
    return $model;
  }

我们看到它正在尝试基于输入的id查询字符串参数加载相应的项目模型 AR 实例。如果它无法定位请求的项目,它会抛出一个CHttpException,以通知用户他们请求的页面(在本例中是项目详细信息页面)不存在。我们可以通过明确请求我们知道不存在的项目来在浏览器中测试这一点。由于我们知道我们的应用程序没有与id99相关联的项目,因此请求http://localhost/trackstar/project/view/id/99将导致返回以下页面:

显示错误

这很好,因为页面看起来像我们应用程序中的任何其他页面,具有相同的主题、页眉、页脚等。

实际上,这不是呈现此类型错误页面的默认行为。 我们的初始应用程序配置为使用特定的控制器操作来处理此类错误。 我们提到这是处理应用程序中错误的另一种选项。 如果我们查看主配置文件/protected/config/main.php,我们会看到以下应用程序组件声明:

'errorHandler'=>array(
  // use 'site/error' action to display errors
    'errorAction'=>'site/error',
),

这配置了我们的错误处理程序应用组件使用SiteController::actionError()方法来处理所有打算显示给用户的异常。 如果我们查看该操作方法,我们会注意到它正在呈现protected/views/site/error.php视图文件。 这只是一个普通的控制器视图文件,因此它还将呈现任何相关的应用程序布局文件,并将应用适当的主题。 通过这种方式,我们能够在发生某些错误时为用户提供非常友好的体验。

要查看默认行为是什么,而不添加此配置,请暂时注释掉先前的配置代码行(在protected/config/main.php中),然后再次请求不存在的项目。 现在我们看到以下页面:

显示错误

由于我们没有明确定义任何遵循先前概述的自定义错误页面,这是 Yii 框架本身的framework/views/error404.php文件。

继续并恢复对配置文件的更改,以再次使用SiteController::actionError()方法进行错误处理。

现在让我们看看这与抛出CException类相比如何。 让我们注释掉当前抛出 HTTP 异常的代码行,并添加一个新行来抛出这个其他异常类。 对protected/controllers/ProjectController.php文件进行突出显示的更改:

public function loadModel($id)
  {
    $model=Project::model()->findByPk($id);
    if($model===null)
 **//throw new CHttpException(404,'The requested page does not exist.');**
 **throw new CException('This is an example of throwing a CException');**
    return $model;
  }

现在,如果我们请求一个不存在的项目,我们会看到一个非常不同的结果。 这次我们看到一个由系统生成的错误页面,其中包含完整的堆栈跟踪错误信息转储,以及发生错误的特定源文件:

显示错误

它显示了抛出CException类的事实,以及描述这是抛出 CException 的示例,源文件,发生错误的文件中的特定行,然后是完整的堆栈跟踪。

因此,抛出这个不同的异常类,以及应用程序处于调试模式的事实,会产生不同的结果。 这是我们希望显示以帮助排除问题的信息类型,但前提是我们的应用程序在私人开发环境中运行。 让我们暂时注释掉根index.php文件中的调试设置,以查看在“生产”模式下如何显示:

// remove the following line when in production mode
//defined('YII_DEBUG') or define('YII_DEBUG',true);

如果我们刷新对不存在的项目的请求,我们会看到异常显示为面向最终用户友好的 HTTP 500 错误,如下截图所示:

显示错误

因此,我们看到在“生产”模式下不会显示任何敏感代码或堆栈跟踪信息。

缓存

缓存数据是帮助提高生产 Web 应用程序性能的一种很好的方法。 如果有特定内容不希望在每个请求时都更改,那么使用缓存来存储和提供此内容可以减少检索和处理数据所需的时间。

Yii 在缓存方面提供了一些不错的功能。 要利用 Yii 的缓存功能,您首先需要配置一个缓存应用程序组件。 这样的组件是几个子类之一,它们扩展了CCache,这是具有不同缓存存储实现的缓存类的基类。

Yii 提供了许多特定的缓存组件类实现,利用不同的方法存储数据。以下是 Yii 在版本 1.1.12 中提供的当前缓存实现的列表:

  • CMemCache:使用 PHP memcache 扩展。

  • CApcCache:使用 PHP APC 扩展。

  • CXCache:使用 PHP XCache 扩展。

  • CEAcceleratorCache:使用 PHP EAccelerator 扩展。

  • CDbCache:使用数据库表存储缓存数据。默认情况下,它将在运行时目录下创建并使用 SQLite3 数据库。您可以通过设置其connectionID属性来显式指定要使用的数据库。

  • CZendDataCache:使用 Zend Data Cache 作为底层缓存介质。

  • CFileCache:使用文件存储缓存数据。这对于缓存大量数据(如页面)特别合适。

  • CDummyCache:提供一致的缓存接口,但实际上不执行任何缓存。这种实现的原因是,如果您面临开发环境不支持缓存的情况,您仍然可以执行和测试需要在可用时使用缓存的代码。这使您可以继续编写一致的接口代码,并且当实际实现真正的缓存组件时,您将不需要更改编写用于写入或检索缓存中的数据的代码。

  • CWinCacheCWinCache基于 WinCache 实现了一个缓存应用程序组件。有关更多信息,请访问www.iis.net/expand/wincacheforphp

所有这些组件都是从同一个基类CCache继承,并公开一致的 API。这意味着您可以更改应用程序组件的实现,以使用不同的缓存策略,而无需更改任何使用缓存的代码。

缓存配置

正如前面提到的,Yii 中使用缓存通常涉及选择其中一种实现,然后在/protected/config/main.php文件中配置应用程序组件以供使用。配置的具体内容当然取决于具体的缓存实现。例如,如果要使用 memcached 实现,即CMemCache,这是一个分布式内存对象缓存系统,允许您指定多个主机服务器作为缓存服务器,配置它使用两个服务器可能如下所示:

array(
    ......
    'components'=>array(
        ......
        'cache'=>array(
            'class'=>'system.caching.CMemCache',
            'servers'=>array(
                array('host'=>'server1', 'port'=>12345, 'weight'=>60),
                array('host'=>'server2', 'port'=>12345, 'weight'=>40),
            ),
        ),
    ),
);

为了让读者在跟踪 Star 开发过程中保持相对简单,我们将在一些示例中使用文件系统实现CFileCache。这应该在任何允许从文件系统读取和写入文件的开发环境中都是 readily available。

注意

如果由于某种原因这对您来说不是一个选项,但您仍然想要跟随代码示例,只需使用CDummyCache选项。正如前面提到的,它实际上不会在缓存中存储任何数据,但您仍然可以根据其 API 编写代码,并在以后更改实现。

CFileCache提供了基于文件的缓存机制。使用这种实现时,每个被缓存的数据值都存储在一个单独的文件中。默认情况下,这些文件存储在protected/runtime/cache/目录下,但可以通过在配置组件时设置cachePath属性来轻松更改这一点。对于我们的目的,这个默认值是可以的,所以我们只需要在/protected/config/main.php配置文件的components数组中添加以下内容,如下所示:

// application components
  'components'=>array(
    …
 **'cache'=>array(**
 **'class'=>'system.caching.CFileCache',**
 **),**
     …  
),

有了这个配置,我们可以在运行的应用程序中的任何地方通过Yii::app()->cache访问这个新的应用程序组件。

使用基于文件的缓存

让我们尝试一下这个新组件。还记得我们在上一章作为管理功能的一部分添加的系统消息吗?我们不必在每次请求时从数据库中检索它,而是将最初从数据库返回的值存储在我们的缓存中,以便有限的时间内不必从数据库中检索数据。

让我们向我们的SysMessage/protected/modules/admin/models/SysMessage.php)AR 模型类添加一个新的公共方法来处理最新系统消息的检索。让我们将这个新方法同时设置为publicstatic,以便应用程序的其他部分可以轻松使用这个方法来访问最新的系统消息,而不必显式地创建SysMessage的实例。

将我们的方法添加到SysMessage类中,如下所示:

/**
   * Retrieves the most recent system message.
   * @return SysMessage the AR instance representing the latest system message.
   */

public static function getLatest()
{

  //see if it is in the cache, if so, just return it
  if( ($cache=Yii::app()->cache)!==null)
  {
    $key='TrackStar.ProjectListing.SystemMessage';
    if(($sysMessage=$cache->get($key))!==false)
      return $sysMessage;
  }
  //The system message was either not found in the cache, or   
//there is no cache component defined for the application
//retrieve the system message from the database 
  $sysMessage = SysMessage::model()->find(array(
    'order'=>'t.update_time DESC',
  ));
  if($sysMessage != null)
  {
    //a valid message was found. Store it in cache for future retrievals
    if(isset($key))
      $cache->set($key,$sysMessage,300);    
      return $sysMessage;
  }
  else
      return null;
}

我们将在接下来的一分钟内详细介绍。首先,让我们更改我们的应用程序以使用这种新方法来验证缓存是否正常工作。我们仍然需要更改ProjectController::actionIndex()方法以使用这个新创建的方法。这很容易。只需用调用这个新方法替换从数据库生成系统消息的代码。也就是说,在ProjectController::actionIndex()中,只需更改以下代码:

$sysMessage = SysMessage::model()->find(array('order'=>'t.update_time DESC',));

到以下内容:

$sysMessage = SysMessage::getLatest();

现在在项目列表页面上显示的系统消息应该利用文件缓存。我们可以检查缓存目录以进行验证。

如果我们对文件缓存的默认位置protected/runtime/cache/进行目录列表,我们确实会看到创建了一些文件。两个文件的名称都相当奇怪(您的可能略有不同)18baacd814900e9b36b3b2e546513ce8.bin2d0efd21cf59ad6eb310a0d70b25a854.bin

一个保存我们的系统消息数据,另一个是我们在前几章中配置的CUrlManager的配置。默认情况下,CUrlManager将使用缓存组件来缓存解析的 URL 规则。您可以将CUrlManagercacheId参数设置为false,以禁用此组件的缓存。

如果我们以文本形式打开18baacd814900e9b36b3b2e546513ce8.bin文件,我们可以看到以下内容:

a:2:{i:0;O:10:"SysMessage":12:{s:18:" :" CActiveRecord _ _md";N;s:19:" :" CActiveRecord _ _new";b:0;s:26:" :" CActiveRecord _ _attributes";a:6:{s:2:"id";s:1:"2";s:7:"message";s:56:"This is a second message from your system administrator!";s:11:"create_time";s:19:"2012-07-31 21:25:33";s:14:"create_user_id";s:1:"1";s:11:"update_time";s:19:"2012-07-31 21:25:33";s:14:"update_user_id";s:1:"1";}s:23:" :"18CActiveRecord _18_related";a:0:{}s:17:" :" CActiveRecord _ _c";N;s:18:" 18:" CActiveRecord _ _:"  _pk";s:1:"2";s:21:" :" CActiveRecord _ _alias";s:1:"t";s:15:" :" CModel _ _errors";a:0:{}s:19:" :" CModel _ _validators";N;s:17:" :" CModel _ _scenario";s:6:"update";s:14:" :" CComponent _ _e";N;s:14:" :" CComponent _ _m";N;}i:1;N;}

这是我们最近更新的SysMessage AR 类实例的序列化缓存值,这正是我们希望看到的。因此,我们看到缓存实际上是在工作的。

现在让我们更详细地重新审视一下我们的新SysMessage::getLatest()方法的代码。代码的第一件事是检查所请求的数据是否已经在缓存中,如果是,则返回该值:

//see if it is in the cache, if so, just return it
if( ($cache=Yii::app()->cache)!==null)
{
  $key='TrackStar.ProjectListing.SystemMessage';
  if(($sysMessage=$cache->get($key))!==false)
    return $sysMessage;
}

正如我们所提到的,我们配置了缓存应用组件,可以通过Yii::app()->cache在应用程序的任何地方使用。因此,它首先检查是否已定义这样的组件。如果是,它尝试通过$cache->get($key)方法在缓存中查找数据。这做的更多或更少是您所期望的。它尝试根据指定的键从缓存中检索值。键是用于映射到缓存中存储的每个数据片段的唯一字符串标识符。在我们的系统消息示例中,我们只需要一次显示一条消息,因此可以使用一个相当简单的键来标识要显示的单个系统消息。只要对于我们想要缓存的每个数据片段保持唯一,键可以是任何字符串值。在这种情况下,我们选择了描述性字符串TrackStar.ProjectListing.SystemMessage作为存储和检索缓存系统消息时使用的键。

当此代码首次执行时,缓存中尚没有与此键值关联的任何数据。因此,对于此键的$cache->get()调用将返回false。因此,我们的方法将继续执行下一部分代码,简单地尝试从数据库中检索适当的系统消息,使用 AR 类:

$sysMessage = SysMessage::model()->find(array(
  'order'=>'t.update_time DESC',
));

然后我们继续以下代码,首先检查我们是否从数据库中得到了任何返回。如果是,它会在返回值之前将其存储在缓存中;否则,将返回null

if($sysMessage != null)
{
  if(isset($key))
    $cache->set($key,$sysMessage->message,300);    
    return $sysMessage->message;
}
else
    return null;

如果返回了有效的系统消息,我们使用$cache->set()方法将数据存储到缓存中。这个方法的一般形式如下:

set($key,$value,$duration=0,$dependency=null)

将数据放入缓存时,必须指定一个唯一的键以及要存储的数据。键是一个唯一的字符串值,如前所述,值是希望缓存的任何数据。只要可以序列化,它可以是任何格式。持续时间参数指定了一个可选的存活时间TTL)要求。这可以用来确保缓存的值在一段时间后被刷新。默认值为0,这意味着它永远不会过期。(实际上,Yii 在内部将持续时间的值<=0翻译为一年后过期。所以,不完全是永远,但肯定是很长时间。)

我们以以下方式调用set()方法:

$cache->set($key,$sysMessage->message,300);  

我们将键设置为之前定义的TrackStar.ProjectListing.SystemMessage;要存储的数据是我们返回的SystemMessage AR 类的消息属性,即我们的tbl_sys_message表的消息列;然后我们将持续时间设置为300秒。这样,缓存中的数据将在每 5 分钟后过期,届时将再次查询数据库以获取最新的系统消息。当我们设置数据时,我们没有指定依赖项。我们将在下面讨论这个可选参数。

缓存依赖项

依赖参数允许采用一种替代和更复杂的方法来决定缓存中存储的数据是否应该刷新。您的缓存策略可能要求根据特定用户发出请求、应用程序的一般模式、状态或文件系统上的文件是否最近已更新等因素使数据无效,而不是声明缓存数据的过期时间。此参数允许您指定此类缓存验证规则。

依赖项是CCacheDependency或其子类的实例。Yii 提供了以下特定的缓存依赖项:

  • CFileCacheDependency:如果指定文件的最后修改时间自上次缓存查找以来发生了变化,则缓存中的数据将无效。

  • CDirectoryCacheDependency:与文件缓存依赖项类似,但是它检查给定指定目录中的所有文件和子目录。

  • CDbCacheDependency:如果指定 SQL 语句的查询结果自上次缓存查找以来发生了变化,则缓存中的数据将无效。

  • CGlobalStateCacheDependency:如果指定的全局状态的值发生了变化,则缓存中的数据将无效。全局状态是一个跨多个请求和多个会话持久存在的变量。它通过CApplication::setGlobalState()来定义。

  • CChainedCacheDependency:这允许您将多个依赖项链接在一起。如果链中的任何依赖项发生变化,缓存中的数据将变得无效。

  • CExpressionDependency:如果指定的 PHP 表达式的结果发生了变化,则缓存中的数据将无效。

为了提供一个具体的例子,让我们使用一个依赖项,以便在tbl_sys_message数据库表发生更改时使缓存中的数据过期。我们将不再任意地在五分钟后使我们的缓存系统消息过期,而是在需要时精确地使其过期,也就是说,当表中的系统消息的update_time列发生更改时。我们将使用CDbCacheDependency实现这一点,因为它旨在根据 SQL 查询结果的更改来使缓存数据无效。

我们改变了对set()方法的调用,将持续时间设置为0,这样它就不会根据时间过期,而是传入一个新的依赖实例和我们指定的 SQL 语句,如下所示:

$cache->set($key, $sysMessage, 0, new CDbCacheDependency('SELECT MAX(update_time) FROM tbl_sys_message'));

注意

将 TTL 时间更改为0并不是使用依赖的先决条件。我们可以将持续时间留在300秒。这只是规定了另一个规则,使缓存中的数据无效。数据在缓存中只有效 5 分钟,但如果表中有更新时间更晚的消息,也就是更新时间,数据也会在此时间限制之前重新生成。

有了这个设置,缓存只有在查询语句的结果发生变化时才会过期。这个例子有点牵强,因为最初我们是为了避免完全调用数据库而缓存数据。现在我们已经配置它,每次尝试从缓存中检索数据时都会执行数据库查询。然而,如果缓存的数据集更复杂,涉及更多的开销来检索和处理,一个简单的 SQL 语句来验证缓存的有效性可能是有意义的。具体的缓存实现、存储的数据、过期时间,以及这些依赖形式的任何其他数据验证,都将取决于正在构建的应用程序的具体要求。知道 Yii 有许多选项可用于满足我们多样化的需求是很好的。

查询缓存

查询缓存的方法在数据库驱动应用程序中经常需要,Yii 提供了更简单的实现,称为查询缓存。顾名思义,查询缓存将数据库查询的结果存储在缓存中,并在后续请求中节省查询执行时间,因为这些请求直接从缓存中提供。为了启用查询,您需要确保CDbConnection属性的queryCacheID属性引用有效缓存组件的ID属性。它默认引用'cache',这就是我们从前面的缓存示例中已经配置的。

要使用查询缓存,我们只需调用CDbConnectioncache()方法。这个方法接受一个持续时间,用来指定查询在缓存中保留的秒数。如果持续时间设置为0,缓存就被禁用了。您还可以将CCacheDependency实例作为第二个参数传入,并指定多少个后续查询应该被缓存为第三个参数。这第三个参数默认为1,这意味着只有下一个 SQL 查询会被缓存。

因此,让我们将以前的缓存实现更改为使用这个很酷的查询缓存功能。使用查询缓存,我们的SysMessage::getLatest()方法的实现大大简化了。我们只需要做以下操作:

    //use the query caching approach
    $dependency = new CDbCacheDependency('SELECT MAX(update_time) FROM tbl_sys_message');
    $sysMessage = SysMessage::model()->cache(1800, $dependency)->find(array(
      'order'=>'t.update_time DESC',
    ));
    return $sysMessage;

在这里,我们与以前的基本方法相同,但我们不必处理缓存值的显式检查和设置。我们调用cache()方法来指示我们要将结果缓存 30 分钟,或者通过指定依赖项,在此时间之前刷新值,如果有更近期的消息可用。

片段缓存

前面的例子演示了数据缓存的使用。这是我们将单个数据存储在缓存中。Yii 还提供了其他方法来存储视图脚本的一部分生成的页面片段,甚至整个页面本身。

片段缓存是指缓存页面的一部分。我们可以在视图脚本中利用片段缓存。为此,我们使用CController::beginCache()CController::endCache()方法。这两种方法用于标记应该存储在缓存中的渲染页面内容的开始和结束。就像使用数据缓存方法时一样,我们需要一个唯一的键来标识被缓存的内容。一般来说,在视图脚本中使用片段缓存的语法如下:

...some HTML content...
<?php
if($this->beginCache($id))
{
// ...content you want to cache here
$this->endCache();
}
?>
...other HTML content...

当有缓存版本可用时,beginCache()方法返回false,并且缓存的内容将自动插入到该位置;否则,if 语句内的内容将被执行,并且在调用endCache()时将被缓存。

声明片段缓存选项

在调用beginCache()时,我们可以提供一个数组作为第二个参数,其中包含定制片段缓存的缓存选项。事实上,beginCache()endCache()方法是COutputCache过滤器/小部件的便捷包装。因此,缓存选项可以是COutputCache类的任何属性的初始值。

在缓存数据时,指定的最常见选项之一是持续时间,它指定内容在缓存中可以保持有效的时间。这类似于我们在缓存系统消息时使用的“持续时间”参数。在调用beginCache()时,可以指定duration参数如下:

$this->beginCache($key, array('duration'=>3600))

这种片段缓存方法的默认设置与数据缓存的默认设置不同。如果我们不设置持续时间,它将默认为 60 秒,这意味着缓存的内容将在 60 秒后失效。在使用片段缓存时,您可以设置许多其他选项。有关更多信息,请参考COutputCache的 API 文档以及 Yii 权威指南的片段缓存部分,该指南可在 Yii 框架网站上找到:www.yiiframework.com/doc/guide/1.1/en/caching.fragment

使用片段缓存

让我们在 TrackStar 应用程序中实现这一点。我们将再次专注于项目列表页面。您可能还记得,在项目列表页面的底部有一个列表,显示了用户在与每个项目相关的问题上留下的评论。这个列表只是指示谁在哪个问题上留下了评论。我们可以使用片段缓存来缓存这个列表,比如说两分钟。应用程序可以容忍这些数据略微过时,而两分钟对于等待更新的评论列表来说并不长。

为了做到这一点,我们需要对列表视图文件protected/views/project/index.php进行更改。我们将调用整个最近评论小部件的内容包裹在这个片段缓存方法中,如下所示:

<?php
$key = "TrackStar.ProjectListing.RecentComments";
if($this->beginCache($key, array('duration'=>120))) {
   $this->beginWidget('zii.widgets.CPortlet', array(
    'title'=>'Recent Comments',
  ));  
  $this->widget('RecentCommentsWidget');
  $this->endWidget();
  $this->endCache(); 
}
?>

有了这个设置,如果我们第一次访问项目列表页面,我们的评论列表将被存储在缓存中。然后,如果我们在两分钟内快速(在两分钟之前)向项目中的问题之一添加新评论,然后切换回项目列表页面,我们不会立即看到新添加的评论。但是,如果我们不断刷新页面,一旦缓存中的内容过期(在这种情况下最多两分钟),数据将被刷新,我们的新评论将显示在列表中。

注意

您还可以简单地在先前缓存的内容中添加echo time(); PHP 语句,以查看它是否按预期工作。如果内容正确缓存,时间显示将在缓存刷新之前不会更新。在使用文件缓存时,请记住确保您的/protected/runtime/目录对 Web 服务器进程是可写的,因为这是缓存内容默认存储的位置。

我们可以通过声明缓存依赖项而不是固定持续时间来避免这种情况。片段缓存也支持缓存依赖项。因此,我们可以将之前看到的beginCache()方法调用更改为以下内容:

if($this->beginCache($key, array('dependency'=>array(
      'class'=>'system.caching.dependencies.CDbCacheDependency',
      'sql'=>'SELECT MAX(update_time) FROM tbl_comment')))) {

在这里,我们使用了CDbCacheDependency方法来缓存内容,直到对我们的评论表进行更新。

页面缓存

除了片段缓存之外,Yii 还提供了选项来缓存整个页面请求的结果。页面缓存方法类似于片段缓存方法。然而,由于整个页面的内容通常是通过将额外的布局应用于视图来生成的,我们不能简单地在布局文件中调用beginCache()endCache()。原因是布局是在对CController::render()方法进行调用后应用的,内容视图被评估之后。因此,我们总是会错过从缓存中检索内容的机会。

因此,要缓存整个页面,我们应该完全跳过生成页面内容的操作执行。为了实现这一点,我们可以在控制器类中使用COutputCache类作为操作过滤器。

举个例子,让我们使用页面缓存方法来缓存每个项目详细页面的页面结果。TrackStar 中的项目详细页面是通过请求格式为http://localhost/trackstar/project/view/id/[id]的 URL 来呈现的,其中[id]是我们请求详细信息的特定项目 ID。我们要做的是设置一个页面缓存过滤器,将为每个请求的 ID 单独缓存此页面的整个内容。当我们缓存内容时,我们需要将项目 ID 合并到键值中。也就是说,我们不希望请求项目#1 的详细信息,然后应用程序返回项目#2 的缓存结果。COutputCache过滤器允许我们做到这一点。

打开protected/controllers/ProjectController.php并修改现有的filters()方法如下:

public function filters()
{
  return array(
    'accessControl', // perform access control for CRUD operations
 **array(**
 **'COutputCache + view',  //cache the entire output from the actionView() method for 2 minutes**
 **'duration'=>120,**
 **'varyByParam'=>array('id'),**
 **),**
  );
}

此过滤器配置利用COutputCache过滤器来缓存应用程序从调用ProjectController::actionView()生成的整个输出。如您可能还记得的那样,在COutputCache声明之后添加的+ view参数是我们包括特定操作方法的标准方式,以便过滤器应用。持续时间参数指定了 120 秒(两分钟)的 TTL,之后页面内容将被重新生成。

varyByParam配置是一个非常好的选项,我们之前提到过。这个功能允许自动处理变化,而不是将责任放在开发人员身上,为被缓存的内容想出一个独特的键策略。例如,在这种情况下,通过指定与输入请求中的GET参数对应的名称列表。由于我们正在缓存按project_id请求的项目的页面内容,因此使用此 ID 作为缓存内容的唯一键生成的一部分是非常合理的。通过指定'varyByParam'=>array('id')COutputCache会根据输入查询字符串参数id为我们执行此操作。在使用COutputCache缓存数据时,还有更多可用的选项来实现这种自动内容变化策略。截至 Yii 1.1.12,以下变化功能可用:

  • varyByRoute:通过将此选项设置为true,特定的请求路由将被合并到缓存数据的唯一标识符中。因此,您可以使用请求的控制器和操作的组合来区分缓存的内容。

  • varyBySession:通过将此选项设置为true,将使用唯一的会话 ID 来区分缓存中的内容。每个用户会话可能会看到不同的内容,但所有这些内容仍然可以从缓存中提供。

  • varyByParam:如前所述,这使用输入的GET查询字符串参数来区分缓存中的内容。

  • varyByExpression:通过将此选项设置为 PHP 表达式,我们可以使用此表达式的结果来区分缓存中的内容。

因此,在我们的ProjectController类中配置了上述过滤器,对于特定项目详细信息页面的每个请求,在重新生成并再次存储在缓存之前,都会在缓存中存储两分钟。您可以通过首先查看特定项目,然后以某种方式更新该项目来测试这一点。如果在两分钟的缓存持续时间内进行更新,您的更新将不会立即显示。

缓存整个页面结果是提高网站性能的好方法,但显然并不适用于每个应用程序中的每个页面。即使在我们的示例中,为项目详细信息页面缓存整个页面也不能正确使用分页实现我们的问题列表。我们使用这个作为一个快速示例来实现页面缓存,但并不总是适用于每种情况。数据、片段和页面缓存的结合允许您调整缓存策略以满足应用程序的要求。我们只是触及了 Yii 中所有可用缓存选项的表面。希望这激发了您进一步探索完整的缓存景观的兴趣。

一般性能调优提示

在准备应用程序投入生产时,还有一些其他事项需要考虑。以下部分简要概述了在调整基于 Yii 的 Web 应用程序性能时需要考虑的其他领域。

使用 APC

启用 PHP APC 扩展可能是改善应用程序整体性能的最简单方法。该扩展缓存和优化 PHP 中间代码,并避免在每个传入请求中解析 PHP 脚本所花费的时间。

它还为缓存内容提供了一个非常快速的存储机制。启用 APC 后,可以使用CApcCache实现来缓存内容、片段和页面。

禁用调试模式

我们在本章的前面讨论了调试模式,但再次提及也无妨。禁用调试模式是另一种提高性能和安全性的简单方法。如果在主index.php入口脚本中定义常量YII_DEBUGtrue,Yii 应用程序将在调试模式下运行。许多组件,包括框架本身的组件,在调试模式下运行时会产生额外的开销。

另外,正如在第二章中提到的,入门,当我们第一次创建 Yii 应用程序时,大多数 Yii 应用程序文件不需要,也不应该放在公共可访问的 Web 目录中。Yii 应用程序只有一个入口脚本,通常是唯一需要放在 Web 目录中的文件。其他 PHP 脚本,包括所有 Yii 框架文件,都应该受到保护。这就是主应用程序目录的默认名称为protected/的原因。为了避免安全问题,建议不要公开访问它。

使用 yiilite.php

当启用 PHP APC 扩展时,可以用名为yiilite.php的不同 Yii 引导文件替换yii.php。这有助于进一步提高 Yii 应用程序的性能。yiilite.php文件随每个 Yii 版本发布。它是合并了一些常用的 Yii 类文件的结果。合并文件中删除了注释和跟踪语句。因此,使用yiilite.php将减少被包含的文件数量,并避免执行跟踪语句。

注意

请注意,没有 APC 的情况下使用yiilite.php可能会降低性能。这是因为yiilite.php包含一些不一定在每个请求中使用的类,并且会花费额外的解析时间。还观察到,在某些服务器配置下,即使启用了 APC,使用yiilite.php也会更慢。判断是否使用yiilite.php的最佳方法是使用代码包中包含的“Hello World”演示运行基准测试。

使用缓存技术

正如我们在本章中描述和演示的,Yii 提供了许多缓存解决方案,可以显著提高 Web 应用程序的性能。如果生成某些数据需要很长时间,我们可以使用数据缓存方法来减少数据生成的频率;如果页面的某部分保持相对静态,我们可以使用片段缓存方法来减少其渲染频率;如果整个页面保持相对静态,我们可以使用页面缓存方法来节省整个页面请求的渲染成本。

启用模式缓存

如果应用程序使用Active RecordAR),你可以在生产环境中启用模式缓存以节省解析数据库模式的时间。这可以通过将CDbConnection::schemaCachingDuration属性配置为大于零的值来实现。

除了这些应用程序级别的缓存技术,我们还可以使用服务器端缓存解决方案来提升应用程序的性能。我们在这里描述的 APC 缓存的启用属于这个范畴。还有其他服务器端技术,比如 Zend Optimizer、eAccelerator 和 Squid 等。

这些大部分只是在你准备将 Yii 应用程序投入生产或者为现有应用程序排除瓶颈时提供一些良好的实践指南。一般的应用程序性能调优更多的是一门艺术而不是科学,而且 Yii 框架之外有许多因素影响整体性能。Yii 自问世以来就考虑了性能,并且继续远远超过许多其他基于 PHP 的应用程序开发框架(详见www.yiiframework.com/performance/)。当然,每个 Web 应用程序都需要进行调整以增强性能,但选择 Yii 作为开发框架肯定会让你的应用程序从一开始就具备良好的性能基础。

有关更多详细信息,请参阅 Yii 权威指南中的性能调优部分www.yiiframework.com/doc/guide/1.1/en/topics.performance

总结

在本章中,我们将注意力转向对应用程序进行更改,以帮助提高其在生产环境中的可维护性和性能。我们首先介绍了 Yii 中可用的应用程序日志记录策略,以及如何根据不同的严重级别和类别记录和路由消息。然后我们转向错误处理,以及 Yii 如何利用 PHP 5 中的基础异常实现来提供灵活和健壮的错误处理框架。然后我们了解了 Yii 中可用的一些不同的缓存策略。我们了解了在不同粒度级别上对应用程序数据和内容进行缓存的方法。对于特定变量或单个数据片段的数据缓存,对页面内的内容区域进行片段缓存,以及对整个渲染输出进行完整页面缓存。最后,我们提供了一系列在努力改善 Yii 驱动的 Web 应用程序性能时要遵循的良好实践。

恭喜!我们应该为自己鼓掌。我们已经从构思到生产准备阶段创建了一个完整的网络应用程序。当然,我们也应该为 Yii 鼓掌,因为它在每一个转折点都帮助我们简化和加快了这个过程。我们的 TrackStar 应用程序已经相当不错;但就像所有这类项目一样,总会有改进和提高的空间。我们已经奠定了一个良好的基础,现在你拥有 Yii 的力量,你可以很快将其转变为一个更加易用和功能丰富的应用程序。此外,许多涵盖的示例也可以很好地应用到你可能正在构建的其他类型的网络应用程序上。我希望你现在对使用 Yii 感到自信,并且会在未来的项目中享受到这样做的好处。开心开发!

posted @ 2024-05-05 00:13  绝不原创的飞龙  阅读(11)  评论(0编辑  收藏  举报