第四天 重构

原文:http://www.symfony-project.com/askeet/4

第三天展示了MVC构架的各个层次而且对它们做了修改 - 问题列表已可正确的显示在主页。我们的程序得到改善但仍然缺乏内容。

今天的任务是,显示问题的答案,给问题显示页加一个漂亮的URL,添加自定义类,把一些源码移到更适宜的地方。这些应帮助你了解模板,模型,转向,重构的概念。你也许认为重构只有几天的源码未免太早了,完成教程你会找到对这个问题的答案。

开始之前,你应熟悉Symfony里MVC的概念。有agile development的经验更佳。

显示与问题相关的答案

首先,让我们使用第二天中Quetion CRUD产生的模版。

Question/show这一动作显示问题的详情 - 前提是你必须提供一个id。测试它,输入:

http://askeet/frontend_dev.php/question/show/id/1

你将看到:

动作的简介

首先,让我们检阅show的动作(位于askeet/apps/frontend/modules/question/actions/actions.class.php):

public function executeShow()
 {
   $this->question = QuestionPeer::retrieveByPk($this->getRequestParameter('id'));
   $this->forward404Unless($this->question);
 }

如果你熟悉Propel,你知道这几行建立一个对数据库里Question表格的查询。目的是根据id参数以主键调出档案。在以上的实例,id参数的值是1,因此,QuestionPeer类中的函数→retrieveByPk()将返回Question类的对象,且其主键为1。如果你对Propel不熟悉,请参照Propel文献

请求的结果经$question变量被传递到showSuccess.php模版。

sfAction的函数getRequestParameter(’id’),能截取HTML中GET或POST的id参数,例如:

http://askeet/frontend_dev.php/question/show/id/1/myparam/myvalue

myvalue的值就能通过$this→getRequestParameter(’myparam’)获取。

注意:forward404Unless()函数给浏览器发一个404的页假如档案不存在于数据库中。

修改showSuccess.php模版

系统产生的showSuccess.php模版和我们想要的有差距,因此需要重写。打开frontend/modules/question/templates/showSuccess.php,把其内容覆盖为以下:

<?php use_helper('Date') ?>
 
<div class="interested_block">
  <div class="interested_mark">
    <?php echo count($question->getInterests()) ?>
  </div>
</div>
 
<h2><?php echo $question->getTitle() ?></h2>
 
<div class="question_body">
  <?php echo $question->getBody() ?>
</div>
 
<div id="answers">
<?php foreach ($question->getAnswers() as $answer): ?>
  <div class="answer">
    posted by <?php echo $answer->getUser()->getFirstName().' '.$answer->getUser()->getLastName() ?> 
    on <?php echo format_date($answer->getCreatedAt(), 'p') ?>
    <div>
      <?php echo $answer->getBody() ?>
    </div>
  </div>
<?php endforeach; ?>
</div>

你会发现interested_block div已存在于昨天的listSuccess.php模版中。它的作用是显示某一问题被关注的数目。它的的置标语言和list中的十分相似,不同的是它的的标题没加link_to.

answers div是新的部分。它显示一个问题的所有答案(用Propel的$question→getAnswers()函数),且对每个问题,显示关联度,作者的名字,问题的内容和日期。

format_date()又是一个模版助手(template helpers)的实例。使用此助手前须提供声明。你可以在Symfony书里的internationalization helpers chapter这章找到更多关于助手的语法(这些助手加快枯燥的工作例如显示日期)。

注意:Propel在为相连表格建立函数时,会再其末端自动填补一个“s”。请原谅不美观的getRelevancys()(译:英文里relevancys是语法上不正确的,应为relevancies)函数,它可节省了你几行的SQL代码。

导入新的测试数据

打开data/fixtures/test_data.yml,加入以下数据(你可以自己添加更多的数据):

Answer:
  a1_q1:
    question_id: q1
    user_id:     francois
    body:        |
      You can try to read her poetry. Chicks love that kind of things.

  a2_q1:
    question_id: q1
    user_id:     fabien
    body:        |
      Don't bring her to a donuts shop. Ever. Girls don't like to be
      seen eating with their fingers - although it's nice. 

  a3_q2:
    question_id: q2
    user_id:     fabien
    body:        |
      The answer is in the question: buy her a step, so she can 
      get some exercise and be grateful for the weight she will
      lose.

  a4_q3:
    question_id: q3
    user_id:     fabien
    body:        |
      Build it with symfony - and people will love it.

重导数据,输入:

$ php batch/load_data.php

导航到以下检查导入是否成功:

http://askeet/frontend_dev.php/question/show/id/XX

注意:把xx替换为问题的id

问题的设计比以前更加美观和精细了,不是吗?

修改模型 I

我们几乎可以确定作者的名字将会用于某部分的运用程序。你可以把全名当作User对象的一个属性。换句话说,我们的模型应提供一个函数让我们取得全名,而不是从动作里重建。就让我们重写模型。打开askeet/lib/model/User.php,加入以下函数:

public function __toString()
{
  return $this->getFirstName().' '.$this->getLastName();
}Why is this method named __toString() instead of getFullName() or something similar? Because the __toString()

为什么用__toString()而不是getFullName()或相似的函数名?原因是在PHP5里,当某对象需要以字符来表示时,PHP会自动调用__toString()获取所需的字符。因此,你可以把:

posted by <?php echo $answer->getUser()->getFirstName().' '.$answer->getUser()->getLastName() ?>

替换为:

posted by <?php echo $answer->getUser() ?>

达到同样的效果。

避免重复性的工作

Agile development的其一原则中讲到避免重复性代码。“Don’t Repeat Yourself”(D.R.Y.)。原因是重复代码加倍回顾,修改,测试和验证其代码的时间。另外它让维持的工作复杂化。如果你留意listSuccess.php模版和昨天写的showSuccess.php模版,你会发现重复代码:

<div class="interested_block">
  <div class="interested_mark">
    <?php echo count($question->getInterests()) ?>
  </div>
</div>

我们今天的重构任务就是把重复性的代码分别从这两个模版移去,把移去的代码放到一个片断里。在目录askeet/apps/frontend/modules/question/template/下,建立文件_interested_user.php:

<div class="interested_mark">
  <?php echo count($question->getInterests()) ?>
</div>

然后把原来的代码(位于listSuccess.php和showSuccess.php)更替为:

<div class="interested_block">
  <?php include_partial('interested_user', array('question' => $question)) ?>
</div>

片断没有访问当前对象的权力。以上片断用道$question变量,所以要通过include_partial()的参数来传递。片断前面的 _ 主要是为了区分模版和片断,因为两者都在template/的目录下。更多关于的说明,请参照视图

修改模型 II

新建片断里的$question→getInterests()函数在调用时返回一个Interest对象的数组。对于只需要取得某问题被关注的数目,这是一个繁重的请求 - 数据库可能会超负荷。要记住,在我们在listSuccess.php模版里也用到了这个函数,而且是在循环里。较理智的做法是把它优化。

一个比较好的办法是给Question表添加一个名为interested_users的列, 然后在每个关于这个问题的interest被建立的时候更新这个列。

注意:由于当前没有办法添加interest纪录,所以我们在一个没有明显的测试方法去测试的情况下修改模型。 但是在实践当中我们不应该去修改我们无法测试的东西。

在User对象模型里添加新的数据列

不要怕,打开askeet/config/schema.xml文档,在ask_question表格里加入:

<column name="interested_users" type="integer" default="0" />

重建模型:

$ symfony propel-build-model

在重建模型时,我们不必担心原先加入的代码被重建覆盖。为什么呢?原因是我们编辑的文档askeet/lib/model/User.php,只是原模型askeet/lib/model/om/BaseUser.php的派生类。重建只会覆盖基类的模型,而不是派生类的模型。因此,绝对不要把你要写的源码加在askeet/lib/model/om/目录下的模型里,因为他们会被覆盖当执行propel-build-model的指令。

你也需要更新数据库。Symfony可帮你重建SQL代码。输入:

$ symfony propel-build-sql
$ mysql -u youruser -p askeet < data/sql/schema.sql
$ php batch/load_data.php

注意:你也可以手动修改数据库的结构:

$ mysql -u youruser -p askeet -e "alter table ask_question add interested_users int default '0'"

修改Interest的save()函数

当用户对某一问题感兴趣时,我们必须更新刚加的数据列, 比如说当每次一个新的纪录被添加到insterest表中的时候。你可以用触发器来实现这个功能,但是这将是一个依赖于数据库平台的解决方案。因而你不能轻松的切换到其它数据平台上。

较理想的方案是重载Interest模型里的Save()函数。这个方法会在每次一个interest对象被建立时调用。打开askeet/lib/model/Interest.php,加入以下代码:

public function save($con = null)
{  
    $ret = parent::save($con);
 
    // update interested_users in question table
    $question = $this->getQuestion();
    $interested_users = $question->getInterestedUsers();
    $question->setInterestedUsers($interested_users + 1);
    $question->save($con);
 
    return $ret;
}

新的Save()函数首先获取相关的问题,然后使其interested_users的值加一。 然后,他在进行实际的save()操作。但是一个$this-save();将引起一个无限循环, 所以它使用了类方法parent::save()作为替代。

用transaction来确保更新操作的安全

如果更新Question和Interest的过程被打断会发生什么状况?后果是数据库里的资料有可能会被破坏。这和在银行转账时遇到的问题是一样的。转走一笔钱就意味着第一步先从账户里面减掉相应的数额,然后第二步再去增加目标账户的数额。 因此,对两个依赖性较高的请求,最好是用交易(transaction)来增加其安全性。交易可保障所有请求成功完成,或无一请求能够完成。换句话说,如果在交易时,其中的一个请求失败,原先的所有请求将会被取消,且数据库会被恢复到交易前的状态。

把原来的Save()函数覆盖为:

public function save($con = null)
{
  $con = Propel::getConnection();
  try
  {
    $con->begin();
 
    $ret = parent::save($con);
 
    // update interested_users in question table
    $question = $this->getQuestion();
    $interested_users = $question->getInterestedUsers();
    $question->setInterestedUsers($interested_users + 1);
    $question->save($con);
 
    $con->commit();
 
    return $ret;
  }
  catch (Exception $e)
  {
    $con->rollback();
    throw $e;
  }
}

首先,以上函数由Creole建立一个与数据库的连接。交易保证在begin()和commint()之间的代码将完全执行或再出错的情况下不执行 。当发生错误时系统会抛出一个例外(Exception),数据库将恢复到原来的状态。

修改模版

现在,Question的getInterestedUsers()函数以准备就绪,是时间简化_interested_user.php片断了。把

<?php echo count($question->getInterests()) ?>

改为:

<?php echo $question->getInterestedUsers() ?>

注意:由于使用片断消除了多重代码,我们只需要改变片断里的代码。否则,我们需要修改listSuccess.php和showSuccess.php中重复性的代码。

以请求的数量和执行时间来说,程序应该有所改进。你可以验证数据库请求的数目由调试工具栏的数据库图标。你甚至可以点击数据库图标察看SQL请求的细节。

测试修改的正确性

我们将再次用发送show申请检查程序并没有遭到破坏。但是在这之前,请执行我们昨天写的批处文件,导入测试数据:

$ cd /home/sfprojects/askeet/batch
$ php load_data.php

当建立一条Intereste表中的记录时, sfPropelData对象将使用重载过的save()函数并且正确的更新相应的User记录。

打开以下地址,用浏览主页和第一个问题的方式去检查我们的修改 :

http://askeet/frontend_dev.php/
http://askeet/frontend_dev.php/question/show/id/XX

感兴趣的用户数量并没有改变。这说明了这是一次成功的重构。

修改答案

对count($question→getInterests())的修改可以运用于count($answer→getRelevancys())。唯一的区别是答案可以有正的或负的投票值,而问题只有正的投票值。你以清楚怎样修改模型,我们就加速这一部分的教程。你无需进行这一步如果你用askeet SVN repository的代码进行明天的教程。

把以下能容加入到schema.xml的answer表格内:

<column name="relevancy_up" type="integer" default="0" />
<column name="relevancy_down" type="integer" default="0" />

重建模型,更新数据库:

$ symfony propel-build-model
$ symfony propel-build-sql
$ mysql -u youruser -p askeet < data/sql/schema.sql

重载Relevancy类的save()函数,位于lib/model/Relevancy.php:

public function save($con = null)
{
  $con = Propel::getConnection();
  try
  {
    $con->begin();
 
 
    $ret = parent::save();
 
 
    // update relevancy in answer table
    $answer = $this->getAnswer();
    if ($this->getScore() == 1)
    {
      $answer->setRelevancyUp($answer->getRelevancyUp() + 1);
    }
    else
    {
      $answer->setRelevancyDown($answer->getRelevancyDown() + 1);
    }
    $answer->save($con);
 
 
    $con->commit();
 
 
    return $ret;
  }
  catch (Exception $e)
  {
    $con->rollback();
    throw $e;
  }
}

在同一目录找到Answer类,加入以下函数:

public function getRelevancyUpPercent()
{
  $total = $this->getRelevancyUp() + $this->getRelevancyDown();
 
 
  return $total ? sprintf('%.0f', $this->getRelevancyUp() * 100 / $total) : 0;
}
 
public function getRelevancyDownPercent()
{
  $total = $this->getRelevancyUp() + $this->getRelevancyDown();
 
 
  return $total ? sprintf('%.0f', $this->getRelevancyDown() * 100 / $total) : 0;
}

打开question/templates/showSuccess.php,找到有关于answers的部分,更替为:

<div id="answers">
<?php foreach ($question->getAnswers() as $answer): ?>
  <div class="answer">
    <?php echo $answer->getRelevancyUpPercent() ?>% UP <?php echo $answer->getRelevancyDownPercent() ?> % DOWN
    posted by <?php echo $answer->getUser()->getFirstName().' '.$answer->getUser()->getLastName() ?> 
    on <?php echo format_date($answer->getCreatedAt(), 'p') ?>
    <div>
      <?php echo $answer->getBody() ?>
    </div>
  </div>
<?php endforeach; ?>
</div>

在fixtures里(位于data/fixtures/test_data.yml)加入测试数据:

Relevancy:
  rel1:
    answer_id: a1_q1
    user_id:   fabien
    score:     1


  rel2:
    answer_id: a1_q1
    user_id:   francois
    score:     -1

执行导入批处文件录入数据

打开页面,检查修改的结果

路由转向

自从第一天的教程开始,我们用:

http://askeet/frontend_dev.php/question/show/id/XX

在默认的规则下,Symfony把以上的请求理解为

http://askeet/frontend_dev.php?module=question&action=show&id=XX

路由系统开拓了许多可能性。借助路由,我们可以用问题的标题作为请求:

http://askeet/frontend_dev.php/question/what-shall-i-do-tonight-with-my-girlfriend

这样做可优化网页在搜索引擎的等级,也可增加URL的易读性。

制作备用标题

首先,我们需要把标题转换为带条的字符。有许多种做法,我们选择把带条的标题储存在数据库中的Question数据表里。打开schema.xml,找到Question表格,加入:

<column name="stripped_title" type="varchar" size="255" />
<unique name="unique_stripped_title">
  <unique-column name="stripped_title" />
</unique>

重建模型,更新数据库:

$ symfony propel-build-model
$ symfony propel-build-sql
$ mysql -u youruser -p askeet < data/sql/schema.sql

我们将重载Question模型的setTitle(),目的是在给标题赋值的同时指定带条的标题。

自定义类

我们首先须要创建一个自定义类用来把转换一般的标题为带条的标题。

在askeet/lib/目录下,建立一个新的文档myTools.class.php,加入:

<?php
 
class myTools
{
  public static function stripText($text)
  {
    $text = strtolower($text);
 
    // strip all non word chars
    $text = preg_replace('/\W/', ' ', $text);
 
    // replace all white space sections with a dash
    $text = preg_replace('/\ +/', '-', $text);
 
    // trim dashes
    $text = preg_replace('/\-$/', '', $text);
    $text = preg_replace('/^\-/', '', $text);
 
    return $text;
  }
}

打开askeet/lib/model/Question.php,加入函数:

public function setTitle($v)
{
  parent::setTitle($v);
 
  $this->setStrippedTitle(myTools::stripText($v));
}

使用自定义类myTool无需申明:Symfony会自动加载lib/目录下的类。

执行导入批处文件:

$ symfony cc
$ php batch/load_data.php

更多关于自定义类和助手的说明,请参照extension

修改show动作的链接

打开listSuccess.php模版,把

<h2><?php echo link_to($question->getTitle(), 'question/show?id='.$question->getId()) ?></h2>

代替为:

<h2><?php echo link_to($question->getTitle(), 'question/show?stripped_title='.$question->getStrippedTitle()) ?></h2>

打开actions.class.php,位于question模块,把show动作修改为:

public function executeShow()
{
  $c = new Criteria();
  $c->add(QuestionPeer::STRIPPED_TITLE, $this->getRequestParameter('stripped_title'));
  $this->question = QuestionPeer::doSelectOne($c);
 
  $this->forward404Unless($this->question);
}

导航到以下地址,随意点击一个问题的标题:

http://askeet/frontend_dev.php/

URL显示带条的标题:

http://askeet/frontend_dev.php/question/show/stripped-title/what-shall-i-do-tonight-with-my-girlfriend

修改路由规则

以上的URL并不完全是我们想要的。是时间修改路由规则了。到开routing.yml配置文件,位于askeet/apps/frontend/config/目录下。在文件的开头添加以下规则:

question:
  url:   /question/:stripped_title
  param: { module: question, action: show }

在url这行,question是一个自定义的文本,stripeed_title是参数值(紧接着:)。这一参数组将被运用到Symfony的路由系统,从而转换链接响应question/show动作的请求。链接的转换是由模版中的link_to()函数来实现的。

是时间去做最后的测试了:再次显示主页,点击第一个问题标题。 不但第一个问题被展现出来(这个证明了原有功能并没有遭到破坏),而且在地址栏中你会看到下面的地址:

http://askeet/frontend_dev.php/question/what-shall-i-do-tonight-with-my-girlfriend

更多关于路由的说明

明天見

今天,网站本身并没有得到很多的功能。但是你看到了更多的模版编程,你知道了怎样去修改模型,而且整个代码已经在很多地方做了重构。

重构发生在整个symfony的生命周期里: 代码被重构成片段或是自定义的类用来重用,那些存在于一个动作或是模版中,实际上属于模型的代码被移到了模型中。尽管经过这样的方法使代码分散在各个文件夹中的小文件里, 但是他却使代码的维护和进化变得容易了。 此外, 一个 symfony项目的文件结构使得根据一小块代码的实质(比如helper, model,template,action,custom class 等等)去寻找这块代码的位置变得容易。

今天所做的重构将加速下来几日的开发。 并且我们将定期的做更多的重构工作在整个项目的生命周期中。这是由于我们开发所遵循的方法就是添加一个功能不需要去担心未来会加入的功能,这也要求必须要有一个良好的代码结构,才能使我们不至于陷入代码混乱的泥潭之中。

明天将会是什么呢?我们将会实现一个表格,观察怎样从中得到信息。 我们将把主页的问题清单分页显示。此外,今天的SVN已提交,你可以用以下地址浏览:

http://svn.askeet.com/tags/release_day_4/

并且请使用 或是 发送给我们任何问题

 
askeet_4.txt · 上一次变更: 2007/01/08 03:38 通过 martin
 
Recent changes RSS feed Creative Commons License Donate Powered by PHP Valid XHTML 1.0 Valid CSS Driven by DokuWiki