内容


使用 Agavi 进行 MVC 编程简介,第 3 部分

使用 Agavi 添加验证和管理功能

学习使用 Agavi 框架构建可伸缩的 Web 应用程序

Comments

系列内容:

此内容是该系列 # 部分中的第 # 部分: 使用 Agavi 进行 MVC 编程简介,第 3 部分

敬请期待该系列的后续内容。

此内容是该系列的一部分:使用 Agavi 进行 MVC 编程简介,第 3 部分

敬请期待该系列的后续内容。

简介

本系列的第 2 部分带您深入 Agavi 的世界,介绍如何处理用户通过 Web 表单提交的用户输入,如何通过来自 MySQL 和 Doctrine 的帮助在您的应用程序中支持数据库访问。通过将 Model 添加到这个混合程序并使用这些 Model 读取来自应用程序数据库的车辆清单,该部分还拓展了您对 Agavi 的 MVC 实现的知识。

但是,了解如何从数据库读取记录只解决了问题的一半。另一半涉及写入新记录或修改现有记录,本文将解决这个问题。在接下来的几节中,我将帮助您打造一个更智能的 Web Automobile Sales Platform (WASP) 示例应用程序,以便用户能够通过一个 Web 界面创建、编辑和删除记录。我们还将探讨 Agavi 的安全框架的基础理论,展示如何将某些功能限制到只允许经过验证的用户使用。现在,就让我们开始吧!

添加数据库记录

首先,图 1 将帮助您迅速回忆起这个 WASP 数据库的结构:

图 1. WASP 数据库
列出 WASP 数据库的清单、制造商、国家等数据库字段的属性
列出 WASP 数据库的清单、制造商、国家等数据库字段的属性

本系列的第 2 部分结束时创建了一个 DisplayAction,它从数据库读取并显示单独的车辆清单。这些清单本身是在 MySQL 命令提示中使用原始 SQL 命令手动创建的。但是,这个 WASP 应用程序的目标是支持销售商自己向数据库添加清单,管理员可以在数据库中审查并确认这些清单。这个业务目标自然会导致以下功能要求:

  • 销售商上传车辆清单的界面;
  • WASP 管理员审查、批准或删除上传清单的界面;
  • 区分上述两类用户的安全和访问控制模型。

要实现上述要求,首先构建一个允许销售商通过一个 Web 表单添加新清单的 CreateAction。启动您的 Agavi 构建脚本并初始化 Listing 模块中的 Action 和 3 个视图,如下所示:

shell> agavi action-wizard
Module name: Listing
Action name: Create
Space-separated list of views to create for Create [Success]: Input Error Success

您还需要将这个 CreateAction 的一个新路由添加到应用程序的路由表(参见 清单 1):

清单 1. Listing/CreateAction 路由定义
<?xml version="1.0" encoding="UTF-8"?>
<ae:configurations xmlns:ae="http://agavi.org/agavi/config/global/envelope/1.0" 
 xmlns="http://agavi.org/agavi/config/parts/routing/1.0">
  <ae:configuration>
    <routes>
      ...
            
      <!-- action for listing pages "/listing" -->
      <route name="listing" pattern="^/listing" module="Listing">
        <route name=".create" pattern="^/create$" action="Create" />
        <route name=".display" pattern="^/display/(id:\d+)$" action="Display" />
      </route>
            
    </routes>
  </ae:configuration>
</ae:configurations>

这个 CreateAction 的默认行为是显示一个 Web 表单,该表单的字段与 图 1 中显示的数据字段一致。为此,通过定义 getDefaultViewName() 方法(参见 清单 2)在 CreateAction 中指定 CreateInputView 默认显示所有 GET 请求。

清单 2. Listing/CreateAction 定义
<?php
class Listing_CreateAction extends WASPListingBaseAction
{
  public function getDefaultViewName()
  {
    return 'Input';
  }
}
?>

使用必要的 Web 表单更新对应的 CreateInput 模板文件(参见 清单 3)。本文附带的代码压缩文档(参见 下载)包含这个表单的 CSS 规则和 JavaScript 代码。

清单 3. Listing/CreateInput 模板
<script src="/js/form.js"></script>
<h3>Add Listing</h3>
<form action="<?php echo $ro->gen(null); ?>" method="post">
  <fieldset>
    <legend>Owner Information</legend>
  	<label for="OwnerName" class="required">Name:</label>
  	<input id="OwnerName" type="text" name="OwnerName" />
  	<p/>
  	<label for="OwnerTel">Telephone number:</label>
  	<input id="OwnerTel" type="text" name="OwnerTel" />
  	<p/>
  	<label for="OwnerEmail" class="required">Email address:</label>
  	<input id="OwnerEmail" type="text" name="OwnerEmail" />
  	<p/>
  	<label for="OwnerCity" class="required">City:</label>
  	<input id="OwnerCity" type="text" name="OwnerCity" />
  	<p/>
  	<label for="OwnerCountryID" class="required">Country:</label>
  	<select id="OwnerCountryID" name="OwnerCountryID">
  	<?php foreach ($t['countries'] as $c): ?>
  	<?php echo "<option value=\"$c[CountryID]\">" . $c['CountryName'] . 
  	 "</option>"; ?>
  	<?php endforeach; ?>
  	</select>
  	<p/>
	</fieldset>
	
  <fieldset>	
    <legend>Vehicle Information</legend>
  	<label for="VehicleManufacturerID" class="required">Manufacturer:</label>
  	<select id="VehicleManufacturerID" name="VehicleManufacturerID">
  	<?php foreach ($t['manufacturers'] as $m): ?>
  	<?php echo "<option value=\"$m[ManufacturerID]\">" . $m['ManufacturerName'] . 
  	 "</option>"; ?>
  	<?php endforeach; ?>
  	</select>
  	<p/>
  	
	  <label for="VehicleModel" class="required">Model:</label>
	  <input id="VehicleModel" type="text" name="VehicleModel" />
  	<p/>
  	
  	<label for="VehicleYear" class="required">Year of manufacture:</label>
  	<input id="VehicleYear" type="text" name="VehicleYear" size="4" 
  	 style="width:80px" />
  	<p/>
  	
  	<label for="VehicleColor" class="required">Color:</label>
  	<input id="VehicleColor" type="text" name="VehicleColor" />
  	<p/>
  	
  	<label for="VehicleMileage" class="required">Mileage:</label>
  	<input id="VehicleMileage" type="text" name="VehicleMileage" size="6" 
  	 style="width:100px" />
  	<p/>
  	
  	<label for="VehicleAccessoryBit" style="height:130px">Accessories:</label>
  	<input id="VehicleAccessoryBit_1" type="checkbox" name="VehicleAccessoryBit[]" 
  	 value="1" style="width:2px" />Power steering
  	<br/>
  	<input id="VehicleAccessoryBit_2" type="checkbox" name="VehicleAccessoryBit[]" 
  	 value="2" style="width:2px" />Power windows
  	<br/>
  	<input id="VehicleAccessoryBit_4" type="checkbox" name="VehicleAccessoryBit[]" 
  	 value="4" style="width:2px" />Audio system
  	<br/>
  	<input id="VehicleAccessoryBit_8" type="checkbox" name="VehicleAccessoryBit[]" 
  	 value="8" style="width:2px" />Video system
  	<br/>
  	<input id="VehicleAccessoryBit_16" type="checkbox" name="VehicleAccessoryBit[]" 
  	 value="16" style="width:2px" />Keyless entry system
  	<br/>
  	<input id="VehicleAccessoryBit_32" type="checkbox" name="VehicleAccessoryBit[]" 
  	 value="32" style="width:2px" />GPS
  	<br/>
  	<input id="VehicleAccessoryBit_64" type="checkbox" name="VehicleAccessoryBit[]" 
  	 value="64" style="width:2px" />Alloy wheels
  	<p/>
  	
  	<label for="data[VehicleIsFirstOwned]">Ownership:</label>
  	<input id="data[VehicleIsFirstOwned]" type="checkbox" 
  	 name="data[VehicleIsFirstOwned]" value="1" style="width:2px" />First owner
  	<p/>
  	
  	<label for="VehicleIsCertified">Certification:</label>
  	<input id="VehicleIsCertified" type="checkbox" name="VehicleIsCertified" 
  	 value="1" style="width:2px" 
  	  onClick="javascript:handleInputDisplayOnCheck('VehicleIsCertified', 
  	  'divVehicleCertificationDate')"/>Fully certified
  	<p/>
  	
  	<div id="divVehicleCertificationDate" style="display:none">
    	<label for="VehicleCertificationDate" class="required">
    	 Certificate issued in:</label>
    	<select id="VehicleCertificationDate_mm" name="VehicleCertificationDate_mm">
    	<?php for ($x=1; $x<=12; $x++): ?>
    	<?php echo "<option value=\"$x\">" . 
    	 date("F", mktime(null, null, null, $x, 1)) . "</option>"; ?>
    	<?php endfor; ?>
    	</select>
    	<select id="VehicleCertificationDate_yyyy" 
    	 name="VehicleCertificationDate_yyyy">
    	<?php for ($x=1990; $x<=date('Y'); $x++): ?>
    	<?php echo "<option value=\"$x\">$x</option>"; ?>
    	<?php endfor; ?>
    	</select>
    	<p/>
  	</div>
  	
  	<label for="VehicleSalePriceMin" class="required">Sale price (min):
  	 </label>
  	<input id="VehicleSalePriceMin" type="text" name="VehicleSalePriceMin" 
  	 size="6" style="width:100px" />
  	<p/>

  	<label for="VehicleSalePriceMax" class="required">Sale price (max):
  	 </label>
  	<input id="VehicleSalePriceMax" type="text" name="VehicleSalePriceMax" 
  	 size="6" style="width:100px" />
  	<p/>
  	
  	<label for="VehicleSalePriceIsNegotiable"> </label>
  	<input id="VehicleSalePriceIsNegotiable" type="checkbox" 
  	 name="VehicleSalePriceIsNegotiable" value="1" style="width:2px" />Negotiable
  	<p/>
  	
  	<label for="Note">Description:</label>
  	<textarea id="Note" name="Note" style="width:300px; height:200px"
  	 ></textarea>
  	<p/>
	</fieldset>
	
	<input type="submit" name="submit" class="submit" value="Submit Listing" />
</form>

<script>
handleInputDisplayOnCheck('VehicleIsCertified', 'divVehicleCertificationDate');
</script>

注意,这个表单中的两个字段 CountryManufacturer 是选择列表,它们的值分别来自 country 和 manufacturer 表。要检索这些值并使其对模板变量可用,在 $WASP_ROOT/app/modules/Listing/lib/view/WASPListingBaseView.class.php 中编辑 Listing 模块的基视图,并向它添加下面的 setInputViewAttributes() 方法(参见 清单 4):

清单 4. Listing/WASPListingBaseView 定义
<?php
/**
 * The base view from which all Listing module views inherit.
 */
class WASPListingBaseView extends WASPBaseView
{
  // set values from selection lists
  function setInputViewAttributes() {
    $q = Doctrine_Query::create()
          ->from('Country c');    
    $this->setAttribute('countries', $q->fetchArray());
    
    $q = Doctrine_Query::create()
          ->from('Manufacturer m');
    $this->setAttribute('manufacturers', $q->fetchArray());
  }  
}
?>

setInputViewAttributes() 方法添加到 WASPListingBaseView 后,在所有扩展它的视图中都可以使用该方法,从而不必将这段代码复制到每个单独的视图中。

下面,编辑 CreateInputView 并在它的 executeHtml() 方法中调用该方法(参见 清单 5):

清单 5. Listing/CreateInputView 定义
<?php
class Listing_CreateInputView extends WASPListingBaseView
{
  public function executeHtml(AgaviRequestDataHolder $rd)
  {
    $this->setupHtml($rd);
    $this->setInputViewAttributes(); 
  }
}
?>

如果您在 Web 浏览器中访问 URL http://wasp.localhost/listing/create,您应该能够看见一个类似于 图 2 的表单。

图 2. 添加新清单的 Web 表单
用于添加新清单的一个 Web 表单的屏幕截图,表单中的字段可以输入车主和车辆信息
用于添加新清单的一个 Web 表单的屏幕截图,表单中的字段可以输入车主和车辆信息

用户提交表单时,客户端浏览器将输入数据放入一个 POST 事务并发送到服务器。如果输入通过验证,Agavi 将调用 CreateAction 的 executeWrite() 方法,该方法必须处理输入并将其作为一条新记录存储在数据库中。清单 6 展示了执行这个任务的代码:

清单 6. Listing/CreateAction 定义
<?php
class Listing_CreateAction extends WASPListingBaseAction
{
  ...
  public function executeWrite(AgaviRequestDataHolder $rd)
  {    
    try {
      // initialize object
      $listing = new Listing();
      
      // populate with validated input
      $listing->fromArray($rd->getParameters());      
      $listing->VehicleAccessoryBit = 
       array_sum($rd->getParameter('VehicleAccessoryBit'));

      // set some values manually
      $listing->RecordDate = date('Y-m-d', mktime());
      $listing->DisplayStatus = 0;
      
      // save record and get record ID
      $listing->save();
      $id = $listing->RecordID;      
      return 'Success';
    } catch (Exception $e) {    
      $this->setAttribute('error', $e->getMessage());  
      return 'Error';
    }
  }
}
?>

清单 6 中的方法初始化 Listing 模块的一个新实例,使用通过 Web 表单提交的数据填充这个新实例。这时,还可以对输入数据执行任何必要的调整,比如添加组成 listing.VehicleAccessoryBit 位掩码的位,或者将清单的显示状态设置为隐藏。然后,通过调用模块的 save() 方法(生成并执行必要的 INSERT 查询)将记录存储到数据库。

假定记录成功存储,CreateAction 将显示 CreateSuccessView。清单 7 展示 CreateSuccess 模板的代码:

清单 7. Listing/CreateSuccess 模板
<h3>Add Listing</h3>
Your submission has been accepted.
<p/>
A moderator will review it shortly and, if approved, it will be added 
to the public database within the next 48 hours.

如果验证失败,或者因为某种原因记录不能存储到数据库,CreateAction 将显示 CreateErrorView。根据错误的原因,CreateErrorView 要么重新显示 CreateInput 模板并突出显示错误的字段,要么显示 CreateError 模板并将错误信息显示在一个模板变量中。

清单 8 展示 CreateErrorView 的代码:

清单 8. Listing/CreateErrorView 定义
<?php
class Listing_CreateErrorView extends WASPListingBaseView
{
  public function executeHtml(AgaviRequestDataHolder $rd)
  {
    $this->setupHtml($rd);

    // if validation errors, render input template
    if ($this->container->getValidationManager()->getReport()->count()) {
      $this->setInputViewAttributes(); 
      $this->getLayer('content')->setTemplate('CreateInput');   
    }   
  }
}
?>

清单 9 展示 CreateError 模板的代码:

Listing 9. Listing/CreateError 模板
<h3>Add Listing</h3>
An error occurred while processing your submission. Please try again later.
<br/><br/>
<code style="color: red">
<?php echo $t['error']; ?>
</code>

如果您一直跟随我们这个系列的文章,您可能已经注意到上面的处理输入验证错误的方法和您以前见过的方法不同(参见本系列第 2 部分中的 “第 2 部分:使用 Agavi 和 Doctrine 添加表单和数据库支持” 部分中的 ContactAction 定义)。在此前的方法中,Action 的 handleError() 方法被覆盖以直接显示对应的 InputView;这里,Action 的 handleError() 方法保持完好,由 ErrorView 确定是显示 Input 模板还是显示 Error 模板。

这一区别可能看起来微不足道,但实际上,这是非常重要的区别。尽管第一种方法比较方便,但它不能处理不同的输出类型。例如,对于 HTML 输出,您可能想重新显示输入表单;但是,对于 SOAP 输出,您可能想要显示一条错误信息。第二种方法是 Agavi 核心开发人员的推荐方法,它支持上述区别,因为如何处理一个错误的决定由 ErrorView 而不是 Action 确定。ErrorViews 可以处理多种输出类型,但 Action 办不到。

关于这个问题,有一点值得注意:尽管本系列中的示例使用 try-catch() 代码块来捕捉和处理异常,但这并不是必须的。Agavi 将自动处理任何未捕获的异常,因为它捕获异常并将一个 “500 Internal Server Error” 页面返回到客户端。对于每个 Action,捕捉和处理异常需要做很多工作,但是,在另一方面,它允许对客户端返回的错误页面进行更多控制。到底应该使用哪种方法呢?这实际上取决于您的应用程序的要求,但是一旦选择一种方法,就要坚持到底,因为中途改变方法将导致前后不一致。

您可能还注意到,在前面的解释中,我跳过了一个重要组件:输入验证器。原因是:清单 3 中的 Web 表单比您以前见过的表单更长、更复杂,因此伴随它的输入验证器也值得深入讨论。这正是下面将介绍的内容。

使用复杂的输入验证器

查看一下 清单 3 中的 Web 表单,您将很快意识到该表单需要几个不同的输入验证器。要验证字符串字段,比如字段 ModelDescription,使用一个 AgaviStringValidator,如 清单 10 所示:

清单 10. 一个字符串验证示例
<validator class="string">
  <arguments>
    <argument>VehicleModel</argument>
  </arguments>
  <errors>
    <error>ERROR: Vehicle model is missing or invalid</error>
  </errors>
  <ae:parameters>
    <ae:parameter name="required">true</ae:parameter>
  </ae:parameters>
</validator>

<validator class="string">
  <arguments>
    <argument>Note</argument>
  </arguments>
  <ae:parameters>
    <ae:parameter name="required">false</ae:parameter>
  </ae:parameters>
</validator>

数值字段可以使用 AgaviNumberValidator 验证,如 清单 11 所示:

清单 11. 一个数值验证示例
<validator class="number">
  <arguments>
    <argument>VehicleMileage</argument>
  </arguments>
  <errors>
    <error>ERROR: Vehicle mileage is missing or invalid</error>
  </errors>
  <ae:parameters>
    <ae:parameter name="type">int</ae:parameter>
    <ae:parameter name="min">1</ae:parameter>
    <ae:parameter name="max">99999</ae:parameter>
    <ae:parameter name="required">true</ae:parameter>
  </ae:parameters>        
</validator>      

<validator class="number">
  <arguments>
    <argument>VehicleSalePriceMin</argument>
  </arguments>
  <errors>
    <error>ERROR: Vehicle sale price (min) is missing or invalid</error>
  </errors>
  <ae:parameters>
    <ae:parameter name="type">int</ae:parameter>
    <ae:parameter name="min">0</ae:parameter>
    <ae:parameter name="required">true</ae:parameter>
  </ae:parameters>
</validator>

对于需要限制到一个特定范围的输入,AgaviNumberValidator 也适用。例如,MySQL 的 YEAR 数据类型目前只允许范围在 1901-2155 之间的值,因此针对这个字段的输入需要遵守这个限制(参见 清单 12)。

清单 12. 一个年份输入验证示例
<validator class="number">
  <arguments>
    <argument>VehicleYear</argument>
  </arguments>
  <errors>
    <error for="required">ERROR: Vehicle year of manufacture is missing 
    </error>
    <error for="type">ERROR: Vehicle year of manufacture is invalid</error>
    <error for="min">ERROR: Vehicle year of manufacture is before 1900
    </error>
    <error for="max">ERROR: Vehicle year of manufacture is after 2020
    </error>
  </errors>
  <ae:parameters>
    <ae:parameter name="type">int</ae:parameter>
    <ae:parameter name="min">1901</ae:parameter>
    <ae:parameter name="max">2020</ae:parameter>
    <ae:parameter name="required">true</ae:parameter>
  </ae:parameters>
</validator>

要验证电子邮件地址,使用 AgaviEmailValidator,如 清单 13 所示:

清单 13. 一个电子邮件地址验证示例
<validator class="email">
  <arguments>
    <argument>OwnerEmail</argument>
  </arguments>
  <errors>
    <error>ERROR: Email address is missing or invalid</error>
  </errors>
  <ae:parameters>
    <ae:parameter name="required">true</ae:parameter>
  </ae:parameters>
</validator>

要验证更复杂的字符串,比如电话号码,使用 AgaviRegexValidator,如 清单 14 所示:

清单 14. 一个正则表达式验证示例
<validator class="regex">
  <arguments>
    <argument>OwnerTel</argument>
  </arguments>
  <errors>
    <error>ERROR: Number is missing or invalid</error>
  </errors>
  <ae:parameters>
    <ae:parameter name="required">false</ae:parameter>
    <ae:parameter name="pattern">#^[0-9]{6,25}$#</ae:parameter>
    <ae:parameter name="match">true</ae:parameter>
  </ae:parameters>
</validator>

Agavi 还支持使用 AND 或 OR 条件嵌套验证器。这个功能的一个有用的应用是:如果某些字段没有出现在 POST 提交中,可以使用验证器自动设置这些字段的默认值。复选框字段是这种应用的典型例子:这些字段通常不包含在 POST 提交中,除非用户明确选中它们。在这些情况中,可以联合使用 AgaviOrValidator 和 AgaviSetValidator 来为这些字段指定默认值并将它们导出到 Action。

仔细查看 清单 15,它展示了这样一种情况:如果 $_POST['VehicleIsFirstOwned'] 在 POST 提交中不存在,清单中的代码将把该值设置为 0。

清单 15. 一个设置默认值的示例
<validator class="or">
  <validator class="number">
    <arguments>
      <argument>VehicleIsFirstOwned</argument>
    </arguments>
    <errors>
      <error>ERROR: Vehicle ownership status is invalid</error>
    </errors>
    <ae:parameters>
      <ae:parameter name="required">false</ae:parameter>
      <ae:parameter name="min">0</ae:parameter>
      <ae:parameter name="max">1</ae:parameter>
    </ae:parameters>
  </validator>      

  <validator class="set">
    <ae:parameters>
      <ae:parameter name="export">VehicleIsFirstOwned</ae:parameter>
      <ae:parameter name="value">0</ae:parameter>
    </ae:parameters>
  </validator>      
</validator>

Agavi 还包含一个非常复杂的日期和时间验证器。AgaviDateTimeValidator 不仅检查输入的日期是否有效,它还将输入值重新格式化为另一种日期或时间格式。当您处理那些需要特定格式的日期和时间值的 DATETIME 数据库字段时,这个验证器特别有用。

清单 16 中,AgaviDateTimeValidator 从两个单独的表单字段中读取车辆证书的月份和年份,确认它们组成一个有效的日期,然后将它们重新格式化为 YYYY-MM-DD 格式,以便插入一个 MySQL DATE 字段。

清单 16. 一个日期/时间验证示例
<validator class="datetime">
  <arguments>
    <argument name="AgaviDateDefinitions::MONTH">VehicleCertificationDate_mm
    </argument>
    <argument name="AgaviDateDefinitions::YEAR">VehicleCertificationDate_yyyy
    </argument>
  </arguments>
  <errors>
    <error>ERROR: Vehicle certification date is missing or invalid</error>
  </errors>
  <ae:parameters>
    <ae:parameter name="required">true</ae:parameter>
    <ae:parameter name="check">true</ae:parameter>
    <ae:parameter name="export">VehicleCertificationDate</ae:parameter>
    <ae:parameter name="cast_to">
      <ae:parameters>
          <ae:parameter name="type">date</ae:parameter>
          <ae:parameter name="format">yyyy-MM-dd</ae:parameter>
      </ae:parameters>
    </ae:parameter>
  </ae:parameters>
</validator>

注意,AgaviDateTime 验证器要求将 Agavi 配置变量 use_translation 设置为 true。您可以在 $WASP_ROOT/app/config/settings.xml 文件中设置这个变量。

要查看完整的 CreateAction 验证器,请参阅本文附带的代码归档文件(参见 下载)。

列出数据库记录

下面让我们构建 WASP 管理界面。为简便起见,我们假定管理员只需查看、编辑和删除清单。这些功能对应 AdminIndexAction、AdminEditAction 和 AdminDeleteAction,我们将在下面几节构建它们。启动 Agavi 构建脚本并按如下操作添加它们:

shell> agavi action-wizard
Module name: Listing
Action name: AdminIndex
Space-separated list of views to create for AdminIndex [Success]: Error Success
...
Module name: Listing
Action name: AdminDelete
Space-separated list of views to create for AdminDelete [Success]: Error Success
...
Module name: Listing
Action name: AdminEdit
Space-separated list of views to create for AdminEdit [Success]: Error Success Input 
Redirect404

这里需要简单说明一下:从上面的代码可以清楚地看出,我将 AdminIndexAction 放在了 Listing 模块中,对于其他两个管理 Action,我也将遵循这种方法。原因何在?因为所有这些 Action 都是关于清单管理的,所以 Listing 模块似乎是它们的一个好归宿。根据这种方法,最终的文件系统布局是:

app/modules/Listing/actions/DisplayAction.class.php
app/modules/Listing/actions/CreateAction.class.php
app/modules/Listing/actions/AdminIndexAction.class.php
app/modules/Listing/actions/AdminEditAction.class.php
app/modules/Listing/actions/AdminDeleteAction.class.php

但是,还有另一种方法 — 这些操作是专门针对管理员使用而构建的,因此,它们是否应该组成一个单独的模块,比如 Admin 呢?根据这种方法,最终的文件系统布局是:

app/modules/Listing/actions/DisplayAction.class.php
app/modules/Listing/actions/CreateAction.class.php
...
app/modules/Admin/actions/ListingIndexAction.class.php
app/modules/Admin/actions/ListingEditAction.class.php
app/modules/Admin/actions/ListingDeleteAction.class.php

对于从其他框架迁移到 Agavi 的用户来说,他们的一个主要困惑是如何确定上述两种方法哪一种正确或者更好。但是,重要的是要知道,上述两种方法(以及其他我没有提到、但您可以自由想象的方法)都是有效的。当您将多个操作组织进各个模块时,Agavi 没有特殊的标准以供使用。因此,您可以自由选择适合您和您的应用程序的方法。或者,如果您不想组织它们,您甚至可以将所有操作都放到 Default 模块中,您完全不必担心这会出现问题。

现在言归正传。将这三个新的 Action 的路由添加到 Agavi 的路由表(见 清单 17):

清单 17. Listing/Admin* 路由定义
<?xml version="1.0" encoding="UTF-8"?>
<ae:configurations xmlns:ae="http://agavi.org/agavi/config/global/envelope/1.0" 
 xmlns="http://agavi.org/agavi/config/parts/routing/1.0">
  <ae:configuration>
    <routes>
       ...

      <!-- action for admin listing pages "/admin/listing/*" -->
      <route name="admin.listing" pattern="^/admin/listing" module="Listing">
        <route name=".index" pattern="^/index$" action="AdminIndex" />
        <route name=".edit" pattern="^/edit/(id:\d+)$" action="AdminEdit" />
        <route name=".delete" pattern="^/delete$" action="AdminDelete" />
      </route>
                        
    </routes>
  </ae:configuration>
</ae:configurations>

AdminIndexAction 是三个操作中最简单的一个,因为它只是在清单表中显示所有记录的列表,并提供编辑和删除每条记录的选择。让我们就从这个操作开始,方法是编辑 Action 的 executeRead() 方法并插入一个 Doctrine 查询来检索数据库中的所有清单(参见 清单 18)。

清单 18. Listing/AdminIndexAction 定义
<?php
class Listing_AdminIndexAction extends WASPListingBaseAction
{
  public function getDefaultViewName()
  {
    return 'Success';
  }
  
  public function executeRead(AgaviRequestDataHolder $rd) 
  { 
    try {
      // get input variables
      $id = $rd->getParameter('id');

      // create query
      $q = Doctrine_Query::create()
            ->from('Listing l')
            ->leftJoin('l.Manufacturer m')
            ->leftJoin('l.Country c');
      $result = $q->fetchArray();
      
      // set view variables
      $this->setAttribute('records', $result);
      return 'Success';
    } catch (Exception $e) {
      $this->setAttribute('error', $e->getMessage());  
      return 'Error';
    }
  }
  
}
?>

假定没有发生异常,生成的记录集通过模板变量 $t['records'] 传输到 AdminIndexSuccess 模板并在一个干净的 HTML 表中显示(参见 清单 19)。

清单 19. Listing/AdminIndexSuccess 模板
<h3>View All Listings</h3>
<?php if (count($t['records']) == 0): ?>
No records available
<?php else: ?>

<div id="list">
  <form action="<?php echo $ro->gen('admin.listing.delete'); ?>" method="post" >
  <table cellspacing="5">
    <tr>
      <td class="key"></td>
      <td class="key"></td>
      <td class="key">Submission Date</td>
      <td class="key">Manufacturer</td>
      <td class="key">Model</td>
      <td class="key">Year</td>
      <td class="key">Mileage</td>
      <td class="key">Color</td>
      <td class="key"></td>
    </tr>  
    <?php foreach ($t['records'] as $record): ?>
    <tr>
      <td><input type="checkbox" name="id[]" 
       value="<?php echo $record['RecordID']; ?>" style="width:2px" />
      </td>
      <td><?php echo $record['RecordID']; ?></td>
      <td><?php echo date('d M Y', strtotime($record['RecordDate'])); ?>
      </td>
      <td><?php echo $record['Manufacturer']['ManufacturerName']; ?>
      </td>
      <td><?php echo $record['VehicleModel']; ?></td>
      <td><?php echo $record['VehicleYear']; ?></td>
      <td><?php echo $record['VehicleMileage']; ?></td>
      <td><?php echo $record['VehicleColor']; ?></td>
      <td><a href="<?php echo $ro->gen('admin.listing.edit', 
       array('id' => $record['RecordID'])); ?>">Edit</a></td>
    </tr>  
    <?php endforeach; ?>
    <tr>
      <td colspan="9"><input type="submit" name="submit" style="width:150px" 
       value="Delete Selected" /></td>
    </tr>    
  </table>  
  </form>
</div>
<?php endif; ?>

注意以上代码还使用了 $ro->gen() 方法,该方法使用以前创建的路由名称,为编辑和删除记录动态生成路由。

要查看操作效果,在您的 Web 浏览器中访问 http://wasp.localhost/admin/listing/index,以显示数据库中的当前车辆清单的摘要。图 3 显示了这个视图的一个外观示例:

图 3. 数据库记录的一个摘要视图
数据库记录的一个摘要视图的屏幕截图
数据库记录的一个摘要视图的屏幕截图

但是没有分页,也没有排序……别着急,随着这个系列的深入,这些功能将一一实现!

使用一个新的主模板

图 3 可以看出,AdminIndexAction 生成的摘要视图与这个 WASP 应用程序的其他公共页面拥有相同的布局和外观。这毫不奇怪,因为所有的视图都使用相同的主模板,这个主模板位于 $WASP_ROOT/app/templates/Master.php。然而,出于美学需要或者是为了在用户移动到应用程序的另一个区域时向用户提供视觉警示,客户通常要求应用程序的管理视图具有不同的外观和风格。

使用 Agavi,这一点不难办到 — 只需创建一个不同的主模板,在 Agavi 中注册,然后在视图的 setupHtml() 方法中引用这个模板。

步骤 1:创建一个新的主模板

首先,在 $WASP_ROOT/app/templates/AdminMaster.php 创建一个新的主模板,然后使用 清单 20 中的代码填充该模板。

清单 20. AdminMaster 模板
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" 
 "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en" lang="en">
  <head>
    <meta http-equiv="Content-Type" content="text/html; charset=utf-8"/>
    <base href="<?php echo $ro->getBaseHref(); ?>" />
    <link rel="stylesheet" type="text/css" href="/css/default.css" />
    <link rel="stylesheet" type="text/css" href="/css/admin.css" />
    <title><?php if(isset($t['_title'])) echo htmlspecialchars($t['_title']) . 
     ' - '; echo AgaviConfig::get('core.app_name'); ?></title>
  </head>
  <body>
    <!-- begin header -->
    <div id="header">
      <div id="logo">
        <img src="/images/logo-admin.jpg" />
      </div>
      <div id="menu">
      </div>
    </div>
    <!-- end header -->
    
    <!-- begin body -->
    <div id="body"> 
      <?php echo $inner; ?>
    </div>
    <!-- end body -->
    
    <!-- begin footer -->
    <div id="footer">
      <p>Powered by <a href="http://www.agavi.org/">Agavi</a>. 
      Licensed under <a href="http://www.creativecommons.org/">Creative Commons
      </a>.</p>
    </div>
    <!-- end footer -->
  </body>
</html>

在上述相同位置使用这些附加规则(见 清单 21)创建一个新的 CSS 文件并将它存储为 $WASP_ROOT/pub/css/admin.css

清单 21. Listing/AdminMaster 模板样式表
#header {
  background: white;
  border-bottom: dashed 2px black;
}

#logo {
  padding-left: 10px;
}

#menu {
  background: white;
}

#menu a {
  color: black;
}

#footer {
  background: black;
}


#footer a {
  color: white;
}

#body form fieldset legend {
  color: black;
}

步骤 2:创建并注册一个新布局

接下来,告知 Agavi 关于您的新模板的信息。在 $WASP_ROOT/app/config/output_types.xml 中注册一个新布局,用于 HTML 输出类型。这里是需要编写的代码:

<?xml version="1.0" encoding="UTF-8"?>
<ae:configurations xmlns:ae="http://agavi.org/agavi/config/global/envelope/1.0" 
 xmlns="http://agavi.org/agavi/config/parts/output_types/1.0">
  
  <ae:configuration>    
    <output_types default="html">      
      <output_type name="html">
        
        ...   
        <layouts default="standard">
          <!-- standard layout with a content and a decorator layer -->
          <layout name="standard">
            <!-- content layer without further params. 
            this means the standard template is used, i.e. the one with the 
            same name as the current view -->
            <layer name="content" />
            <!-- decorator layer with the HTML skeleton, navigation etc; 
            set to a specific template here -->
            <layer name="decorator">
              <ae:parameter name="directory">%core.template_dir%</ae:parameter>
              <ae:parameter name="template">Master</ae:parameter>
            </layer>
          </layout>
          
          <layout name="admin">
            <layer name="content" />
            <layer name="decorator">
              <ae:parameter name="directory">%core.template_dir%</ae:parameter>
              <ae:parameter name="template">AdminMaster</ae:parameter>
            </layer>
          </layout>         
          ...

        </layouts>
        
        <ae:parameter name="http_headers">
          <ae:parameter name="Content-Type">text/html; charset=UTF-8
          </ae:parameter>
        </ae:parameter>
        
      </output_type>      
    </output_types>    
  </ae:configuration>
  ...
 
</ae:configurations>

步骤 3:根据需要使用新模板

要在一个视图中使用这个新布局,将布局名称作为一个附加参数传递到该视图的 setupHtml() 方法。为了展示效果,更新 AdminIndexSuccessView 以便它如 清单 22 所示。

清单 22. Listing/AdminIndexSuccessView 定义
<?php
class Listing_AdminIndexSuccessView extends WASPListingBaseView
{
  public function executeHtml(AgaviRequestDataHolder $rd)
  {
    $this->setupHtml($rd, 'admin');
  }
}
?>

现在,当您重新访问清单摘要页面时,您将看到新的布局(如 图 4 所示)。

图 4. 修改后的带有新布局的摘要视图
修改后的带有新布局的摘要视图的屏幕截图
修改后的带有新布局的摘要视图的屏幕截图

删除数据库记录

您已经创建了占位符类和路由,剩下的事情就是如何让 AdminDeleteAction 工作。首先,添加一个验证器,以便记录 ID 的数组传递到 AdminDeleteAction(参见 清单 23)。

清单 23. Listing/AdminDeleteAction 验证器
<?xml version="1.0" encoding="UTF-8"?>
<ae:configurations
  xmlns="http://agavi.org/agavi/config/parts/validators/1.0"
  xmlns:ae="http://agavi.org/agavi/config/global/envelope/1.0"
  parent="%core.module_dir%/Listing/config/validators.xml"
>
  <ae:configuration>
    
    <validators method="write">
      <validator class="number">
        <arguments base="id[]">
          <argument />
        </arguments>
        <ae:parameters>
          <ae:parameter name="required">true</ae:parameter>
          <ae:parameter name="min">1</ae:parameter>
        </ae:parameters>
      </validator>    
    </validators>
    
  </ae:configuration>
</ae:configurations>

然后,更新 AdminDeleteAction 的 executeWrite() 方法,以便读取这些记录 ID 并从数据库删除相应的记录(参见 清单 24)。

清单 24. Listing/AdminDeleteAction 定义
<?php
class Listing_AdminDeleteAction extends WASPListingBaseAction
{
  public function getDefaultViewName()
  {
    return 'Success';
  }
  
  public function executeWrite(AgaviRequestDataHolder $rd)
  {
    try {
      // get record ids
      $ids = $rd->getParameter('id');
      
      foreach ($ids as $id) {
        // delete record from database
        $q = Doctrine_Query::create()
              ->delete('Listing')
              ->addWhere('RecordID = ?', $id);
        $result = $q->execute();        
      }
      
      return 'Success';     
    } catch (Exception $e) {
      $this->setAttribute('error', $e->getMessage());  
      return 'Error';
    }
  } 
}
?>

您还需要建立 AdminDeleteSuccess 和 AdminDeleteError 模板,如清单 2526 所示:

清单 25. Listing/AdminDeleteSuccess 模板
<h3>Delete Listing</h3>
The selected record(s) were successfully deleted!
清单 26. Listing/AdminDeleteError 模板
<h3>Delete Listing</h3>
The selected record(s) could not be deleted. Please try again.
<br/><br/>
<code style="color: red">
<?php echo $t['error']; ?>
</code>

完成了!在 http://wasp.localhost/admin/listing/index 查看摘要页面,看看它是怎样工作的。选择一些记录并单击 Delete Selected

编辑数据库记录

编辑一条现有的记录与添加一条新记录稍微有些不同。编辑现有记录时,需要向用户显示一个输入表单,表单中的字段预先填充了现有记录的内容。幸运的是,Agavi 的 FormPopulationFilter 再次显示其威力,使得这个任务成为小菜一碟。

为了说明如何操作,打开您的 AdminEditAction 并更新它的 executeRead() 方法,以便检索选中的记录(使用请求 URL 中传递的经过验证的记录),然后将该记录传递到 AdminEditView,如 清单 27 所示:

清单 27. Listing/AdminEditAction 定义
<?php
class Listing_AdminEditAction extends WASPListingBaseAction
{
  public function getDefaultViewName()
  {
    return 'Input';
  }
  
  public function executeRead(AgaviRequestDataHolder $rd)
  {
    try {
      // get record ID
      $id = $rd->getParameter('id');
      
      // get record
      $q = Doctrine_Query::create()
            ->from('Listing l')
            ->leftJoin('l.Manufacturer m')
            ->leftJoin('l.Country c')
            ->where('l.RecordID = ?', $id);
      $result = $q->fetchArray();
      
      // if record exists, show input form
      // else generate 404 error page
      if (count($result) == 1) {
        $this->setAttribute('listing', $result[0]);
        return 'Input';
      } else {        
        return 'Redirect404';
      }
    } catch (Exception $e) {
      $this->setAttribute('error', $e->getMessage());  
      return 'Error';
    }
  } 
}
?>

注意,如果没有记录匹配请求的 ID,客户端将重定向到 AdminEditRedirect404View;这个视图只是转发到 Agavi 的默认 Error404 视图,该视图将向客户端显示一条 “Page not found” 错误。

假定发现一条匹配请求的 ID 的记录,AdminEditInputView 将处理该记录并将它转换为一个关联数组,该数组的键匹配输入表单的字段。然后只需使用该数组作为参数调用 FormPopulationFilter,就可以使用对应值预填充各个字段(参见 清单 28)。

清单 28. Listing/AdminEditInputView 定义
<?php
class Listing_AdminEditInputView extends WASPListingBaseView
{
  public function executeHtml(AgaviRequestDataHolder $rd)
  {
    $this->setupHtml($rd, 'admin');

    $this->setInputViewAttributes();    
    
    // pre-populate form
    if ($this->getAttribute('listing')) {      
      $pre = new AgaviParameterHolder();
      
      $record = $this->getAttribute('listing');        
      foreach ($record as $k => $v) {
        $pre->setParameter("$k", $v);          
      }
      
      // special modification: VehicleAccessoryBit
      $allBits = array(1,2,4,8,16,32,64);    
      $selectedBits = array();
      foreach ($allBits as $bit) {
        ($record['VehicleAccessoryBit'] & $bit) ? $selectedBits[] = $bit : null; 
      }
      $pre->setParameter("VehicleAccessoryBit", $selectedBits);
         
      // special modification: VehicleCertificationDate
      $pre->setParameter("VehicleCertificationDate_mm", 
       date('n', strtotime($record['VehicleCertificationDate'])));
      $pre->setParameter("VehicleCertificationDate_yyyy", 
       date('Y', strtotime($record['VehicleCertificationDate'])));
      
      // special modification: DisplayUntilDate
      $pre->setParameter("DisplayUntilDate_dd", date('j', 
       strtotime($record['DisplayUntilDate'])));
      $pre->setParameter("DisplayUntilDate_mm", date('n', 
       strtotime($record['DisplayUntilDate'])));
      $pre->setParameter("DisplayUntilDate_yyyy", date('Y', 
       strtotime($record['DisplayUntilDate'])));
      
      // populate form
      $this->getContext()->getRequest()->setAttribute('populate', $pre, 
       'org.agavi.filter.FormPopulationFilter');
    }    
  }
}
?>

也许您想知道,包含 清单 3 中的相同表单的 AdminEditInput 模板如何再添加两个特殊的管理字段来显示状态和失效日期?清单 29 展示了添加这两个字段后的 AdminEditInput 模板:

清单 29. Listing/AdminEditInput 模板
<h3>Edit Listing</h3>
<form action="<?php echo $ro->gen(null); ?>" method="post">
  ...
<fieldset>  
    <legend>Listing Status</legend>
    <label for="DisplayStatus" class="required">Display status:</label>
    <select id="DisplayStatus" name="DisplayStatus" 
     onChange="javascript:handleInputDisplayOnSelect('DisplayStatus', 
     'divDisplayUntilDate', new Array('1'))">
      <option value="0">Hidden</option>
      <option value="1">Visible</option>
    </select>
    <p/>
    <div id="divDisplayUntilDate" style="display:none">
      <label for="DisplayUntilDate" class="required">Display until:</label>
      <select id="DisplayUntilDate_dd" name="DisplayUntilDate_dd">
        <?php for ($x=1; $x<=31; $x++): ?>
        <?php echo "<option value=\"$x\">$x</option>"; ?>
        <?php endfor; ?>
      </select>
      <select id="DisplayUntilDate_mm" name="DisplayUntilDate_mm">
        <?php for ($x=1; $x<=12; $x++): ?>
        <?php echo "<option value=\"$x\">" . 
         date("F", mktime(null, null, null, $x, 1)) . "</option>"; ?>
        <?php endfor; ?>
      </select>
      <select id="DisplayUntilDate_yyyy" name="DisplayUntilDate_yyyy">
        <?php for ($x=date('Y'); $x<=(date('Y')+5); $x++): ?>
        <?php echo "<option value=\"$x\">$x</option>"; ?>
        <?php endfor; ?>
      </select>
    </div>
  </fieldset
  ...
</form>

清单 30 显示了对应的验证规则:

清单 30. Listing/AdminEditAction 验证器
<?xml version="1.0" encoding="UTF-8"?>
<ae:configurations
  xmlns="http://agavi.org/agavi/config/parts/validators/1.0"
  xmlns:ae="http://agavi.org/agavi/config/global/envelope/1.0"
  parent="%core.module_dir%/Listing/config/validators.xml"
>
  <ae:configuration>

    <validators>
    ...       
      <validator class="number">
        <arguments>
          <argument>DisplayStatus</argument>
        </arguments>
        <errors>
          <error>ERROR: Display status is missing or invalid</error>
        </errors>
        <ae:parameters>
          <ae:parameter name="required">true</ae:parameter>
          <ae:parameter name="min">0</ae:parameter>
          <ae:parameter name="max">1</ae:parameter>
        </ae:parameters>
      </validator>                      
      
      <validator class="datetime">
        <arguments>
          <argument name="AgaviDateDefinitions::DATE">DisplayUntilDate_dd
          </argument>
          <argument name="AgaviDateDefinitions::MONTH">DisplayUntilDate_mm
          </argument>
          <argument name="AgaviDateDefinitions::YEAR">DisplayUntilDate_yyyy
          </argument>
        </arguments>
        <errors>
          <error>ERROR: Display expiry date is missing or invalid</error>
        </errors>
        <ae:parameters>
          <ae:parameter name="required">true</ae:parameter>
          <ae:parameter name="check">true</ae:parameter>
          <ae:parameter name="export">DisplayUntilDate</ae:parameter>
          <ae:parameter name="cast_to">
            <ae:parameters>
                <ae:parameter name="type">date</ae:parameter>
                <ae:parameter name="format">yyyy-MM-dd</ae:parameter>
            </ae:parameters>
          </ae:parameter>
        </ae:parameters>
      </validator>                
            
    </validators>
    
  </ae:configuration>
</ae:configurations>

图 5 显示了预填充的输入表单的外观:

图 5. 带有预填充字段的一个 Web 表单
带有预填充字段的一个 Web 表单的屏幕截图
带有预填充字段的一个 Web 表单的屏幕截图

这个表单提交回 AdminEditAction 之后,executeWrite() 方法使用提交的值创建并填充一个 Listing 对象,然后调用 save() 方法来制定并执行相应的 UPDATE 查询(参见 清单 31)。

清单 31. Listing/AdminEditAction 定义
<?php
class Listing_AdminEditAction extends WASPListingBaseAction
{
  ...
  public function executeWrite(AgaviRequestDataHolder $rd)
  {
    try {
      // initialize object
      $listing = new Listing();
      $listing->assignIdentifier($rd->getParameter('id'));
      
      // populate with validated input
      $listing->fromArray($rd->getParameters());      
      $listing->VehicleAccessoryBit = 
       array_sum($rd->getParameter('VehicleAccessoryBit'));
      
      // save updated record
      $listing->save();
      return 'Success';
    } catch (Exception $e) {
      $this->setAttribute('error', $e->getMessage());  
      return 'Error';     
    }
  }     
}
?>

请注意 清单 31 中对 assignIdentifier() 的调用。这用于设置记录的主键,该主键用作针对 Doctrine 的一个标记,表明正在处理的记录已经存在于数据库中。因此,Doctrine 应该使用 UPDATE 查询而不是 INSERT 查询。

通过这个界面,WASP 管理员能够查看、编辑和删除记录。换句话说,您的管理模块现在功能齐全了!但是,它没有任何安全保障,结果,任何 WASP 用户都可以访问它。下面,让我们来修复这个问题。

添加用户验证

Agavi 包含一个功能齐全的安全框架,它不仅支持简单的基于密码的验证和更复杂的基于角色的访问控制(RBAC),还能够轻松地扩展以满足自定义要求。但是,针对本文的管理模块,基于密码的验证(管理员访问管理模块之前需要提供一个有效密码)就足以满足要求了。

访问控制通过 Action 的 isSecure() 方法,基于每个 Action 进行指定。如果这个方法返回 true,Agavi 将检查当前用户是否通过验证,如果没有,它将切换到应用程序的默认登录操作(通常是 Default/LoginAction)。然后,LoginAction 获取和确认用户证书,并针对后续请求验证用户。

下列步骤讨论如何保证 WASP 管理模块的安全。

步骤 1:创建用户数据库和模型

首先创建一个新的 MySQL 表来持有管理员用户的姓名和密码,如下所示:

mysql> CREATE TABLE IF NOT EXISTS `user` (
    -> RecordID int(11) NOT NULL AUTO_INCREMENT,
    -> Username varchar(25) CHARACTER SET utf8 NOT NULL,
    -> `Password` text CHARACTER SET utf8 NOT NULL,
    -> PRIMARY KEY (RecordID),
    -> UNIQUE KEY Username (Username)
    -> ) ENGINE=InnoDB  DEFAULT CHARSET=utf8 COLLATE=utf8_bin;
Query OK, 0 rows affected (0.13 sec)

为该表提供几个帐户,以便开始工作:

mysql> INSERT INTO user (Username, Password) VALUES('simon', PASSWORD('says'));
Query OK, 1 row affected (0.08 sec)

mysql> INSERT INTO user (Username, Password) VALUES('marco', PASSWORD('polo'));
Query OK, 1 row affected (0.08 sec)

然后,使用 Doctrine 为这个表格生成一个 User 模型,方法是使用本系列第 2 部分中的 “第 2 部分:使用 Agavi 和 Doctrine 添加表单和数据库支持” 介绍的流程。将生成的类添加到 $WASP_ROOT/app/lib/doctrine/ 目录:

shell> php doctrine-gen.php
shell> cd /usr/local/apache/htdocs/wasp/app/lib
shell> cp /tmp/models/User.php doctrine/
shell> cp /tmp/models/generated/BaseUser.php doctrine/

步骤 2:创建占位符类

尽管每个 Agavi 应用程序都默认包含一个 LoginAction,但它没有包含一个 LogoutAction。因此,启动您的 Agavi 构建脚本并添加它:

shell> agavi action-wizard
Module name: Default
Action name: Logout
Space-separated list of views to create for Save [Success]: Error Success

您也许还需要为 LoginInputView、LoginSuccessView 和 LoginErrorView 生成模板,如下所示:

shell> agavi template-create
Module name: Default
Template name: LoginSuccess

shell> agavi template-create
Module name: Default
Template name: LoginInput

shell> agavi template-create
Module name: Default
Template name: LoginError

步骤 3:定义路由

将这些 Action 的路由添加到应用程序的路由表,如 清单 32 所示:

清单 32. Default/LoginAction 路由定义
<?xml version="1.0" encoding="UTF-8"?>
<ae:configurations xmlns:ae="http://agavi.org/agavi/config/global/envelope/1.0" 
 xmlns="http://agavi.org/agavi/config/parts/routing/1.0">
  <ae:configuration>
    <routes>
      ...  
      <!-- action for admin login page "/admin/login" -->
      <route name="admin.login" pattern="^/admin/login$" 
       module="Default" action="Login" />
      
      <!-- action for admin logout pages "/admin/logout" -->
      <route name="admin.logout" pattern="^/admin/logout$" 
       module="Default" action="Logout" />
                                  
    </routes>
  </ae:configuration>
</ae:configurations>

步骤 4:编写 Action 代码

向 LoginAction 添加一些代码,以便读取由用户提交的凭证并针对数据库检查凭证(参见 清单 33)。如果凭证有效,Action 使用 setAuthenticated() 方法设置一个验证标记并显示 LoginSuccess 视图;如果凭证无效,则返回 LoginError 视图。

清单 33. Default/LoginAction 定义
<?php
class Default_LoginAction extends WASPDefaultBaseAction
{

  public function getDefaultViewName()
  {
    return 'Input';
  }
  
  public function executeWrite(AgaviRequestDataHolder $rd)
  {
    try {
      // get input parameters
      $u = $rd->getParameter('username');
      $p = $rd->getParameter('password');
      
      // check user credentials
      $q = Doctrine_Query::create()
            ->from('User u')
            ->where('u.Username = ? AND u.Password = PASSWORD(?)', array($u,$p));
      $result = $q->fetchArray();
        
      // set authentication flag if valid
      if (count($result) == 1) {
        $this->getContext()->getUser()->setAuthenticated(true);
        return 'Success';
      } else {
        return 'Error';
      }
    } catch (Exception $e) {
        return 'Error';        
    }
  }
}
?>

LogoutAction 的操作正好相反:它取消用户的验证标记并关闭用户会话(参见 清单 34)。

清单 34. Default/LogoutAction 定义
<?php
class Default_LogoutAction extends WASPDefaultBaseAction
{
  public function executeRead(AgaviRequestDataHolder $rd)
  {
    try {
      $this->getContext()->getUser()->setAuthenticated(false);
      return 'Success';
    } catch (Exception $e) {
      return 'Error';     
    }
  } 
}
?>

步骤 5:编写视图代码

在属于 LoginAction 和 LogoutAction 的各种视图中,有两个视图需要特别注意:LoginInputView 和 LoginSuccessView。

LoginInputView 生成一个登录表单,当用户试图访问一个受限 Action 时会看到该表单。它还存储原始请求 URL 并在用户成功登录后将它们重定向到那个 URL。根据 Agavi cookbook(参见 参考资料 中的链接),实现上述目标最简单的方法是将原始请求 URL 存储在执行上下文中,如 清单 35 所示:

清单 35. Default/LoginInputView 定义
<?php
class Default_LoginInputView extends WASPDefaultBaseView
{
  public function executeHtml(AgaviRequestDataHolder $rd)
  {
    // get referrer URL and save it
    if($this->getContainer()->hasAttributeNamespace(
     'org.agavi.controller.forwards.login')) {
      $this->getContext()->getUser()->setAttribute('redirect', 
       $this->getContext()->getRequest()->getUrl(), 'org.agavi.WASP.login');
    } else {
      $this->getContext()->getUser()->removeAttribute('redirect', 
       'org.agavi.WASP.login');
    }   
    $this->setupHtml($rd, 'admin');
  }
}
?>

这个视图生成的 LoginInput 模板非常简单 — 一个包含两个字段的 Web 表单。清单 36 包含模板代码,图 6 展示了这个表单。

清单 36. Default/LoginInput 模板
<h3>Log In</h3>
<form action="<?php echo $ro->gen('admin.login'); ?>" method="post">
  <label for="username" class="required">Username:</label>
  <input id="username" type="text" name="username" style="width:150px" />
  <p/>
  <label for="password" class="required">Password:</label>
  <input id="password" type="password" name="password" style="width:150px" />
  <p/>
  <input type="submit" name="submit" class="submit" value="Log In" />
</form>
图 6. 一个登录表单
一个登录表单的屏幕截图
一个登录表单的屏幕截图

清单 37 包含上述表单的输入验证规则:

清单 37. Default/LoginAction 验证器
<?xml version="1.0" encoding="UTF-8"?>
<ae:configurations
  xmlns="http://agavi.org/agavi/config/parts/validators/1.0"
  xmlns:ae="http://agavi.org/agavi/config/global/envelope/1.0"
  parent="%core.module_dir%/Default/config/validators.xml"
>
  <ae:configuration>
    
    <validators method="write">
      <validator class="string">
        <arguments>
          <argument>username</argument>
        </arguments>
        <errors>
          <error for="required">ERROR: Username is missing</error>
        </errors>
        <ae:parameters>
          <ae:parameter name="required">true</ae:parameter>
        </ae:parameters>
      </validator>
      
      <validator class="string">
        <arguments>
          <argument>password</argument>
        </arguments>
        <errors>
          <error for="required">ERROR: Password is missing</error>
        </errors>
        <ae:parameters>
          <ae:parameter name="required">true</ae:parameter>
        </ae:parameters>
      </validator>
    </validators>
    
  </ae:configuration>
</ae:configurations>

假定登录成功,LoginSuccessView 检索到原始 URL 请求并将客户端重定向到该 URL(参见 清单 38)。

清单 38. Default/LoginSuccessView 定义
<?php
class Default_LoginSuccessView extends WASPDefaultBaseView
{
  public function executeHtml(AgaviRequestDataHolder $rd)
  {
    // get original request URL
    // redirect 
    if($this->getContext()->getUser()->hasAttribute('redirect', 
     'org.agavi.WASP.login')) {
      $this->getResponse()->setRedirect($this->getContext()->
       getUser()->removeAttribute('redirect', 'org.agavi.WASP.login'));
      return true;
    }
    $this->setupHtml($rd, 'admin');
  }
}
?>

步骤 6:定义安全 Action

所有工作几乎都完成了 — 剩下的事情是定义哪些 Action 需要验证。打开 AdminIndexAction、AdminDeleteAction 和 AdminEditAction 类,向每个返回 true 的类添加一个 isSecure() 方法。清单 39 有一个示例:

清单 39. 安全的 Action
<?php
class Listing_AdminEditAction extends WASPListingBaseAction
{
  ...  
  final public function isSecure()
  {
    return true;
  }     
}
?>

现在,当您试图访问任何管理路由 — 比如,位于 http://wasp.localhost/admin/listing/index 的摘要页面 — Agavi 都将首先将您重定向到一个登录表单,并在您输入有效凭证后显示请求的 URL。您自己尝试一下,看看效果如何。

结束语

这就是第 3 篇文章的全部内容。本文通过添加一个成熟的管理模块并向用户提供一个在数据库中添加、编辑和删除车辆清单的基于 Web 的界面,增加了这个 WASP 示例应用程序的功能。这还不是全部内容 — 本文还展示了如何为管理模块定义单独的布局,介绍了 Agavi 的用户验证和安全模型的基础知识。

下载 小节下载本文实现的所有代码。我建议您下载并开始试用它,尝试向它添加新东西。我敢保证您能从中获得更多的知识。祝您实验愉快,下次见!


下载资源


相关主题


评论

添加或订阅评论,请先登录注册

static.content.url=http://www.ibm.com/developerworks/js/artrating/
SITE_ID=10
Zone=XML, Open source, Web development
ArticleID=428072
ArticleTitle=使用 Agavi 进行 MVC 编程简介,第 3 部分: 使用 Agavi 添加验证和管理功能
publish-date=09142009