symfony24天教程之第4天 重构

 symfony回顾

在第3天,我们看到了一个MVC架构的所有层,并修改它们用来在页面上正确地表示question的列表。应用变得好看了些但是还是有点缺乏内容。第四天的目标是表示一个question的answer,给question的详细页面一个更友好的URL,加入一个客户类(custom class),路由政策,以及重构。你可能觉得现在就重写前几天的代码有点太早了,但是相信完成了今天的教程之后你的想法会有所改变。

要阅读这个教程,你应该熟悉以下的章节 MVC在symfony中的实现 .还有如果你懂一点 敏捷开发 也会有所帮助。

表示一个quesiton的answer

首先,让我们继续调整第2天Question CRUD生成的模板。quesiton/show 动作专注于表示一个quesiton的详细信息,并假设收到的参数是你传给它的id。我们可以试试看,调用下面的URL(你可能要把最后的 2 改成你的数据表里的某个id)

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

你可能已经开到过这个页面了。这就是我们将要修改的页面,我们要加上一个question的answer.

动作速览

首先,让我们看看show动作,它在下面这个文件。

askeet/apps/frontend/modules/question/actions/actions.class.php

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

如果你熟悉Rropel,你知道这是一个简单的对Question数据表的请求。它的目的是用id参数作为唯一主键取得一条唯一的记录。在上面这个例子里,id参数的值是2,所以QuestionPeer类的->retrieveByPk()方法会返回一个Question类的对象,它的主键为2。如果你不熟悉Propel,那么请先看看 某些文档,然后再回来继续。

这个请求的结果被通过$question变量交给 showSuccess.php模板,

sfAction对象的->getRequestParameter('id‘)方法取得请求中的叫做'id'的参数,而不论它是通过GET还是POST模式传来的。例如,如果你请求

http://askeet/frontend_dev.php/question/show/id/1/myparam/myvalue
那么show动作将用 $this->getRequestParameter('myparam').来取得myvalue.

注意:forward404Unless()方法如果在数据库找不到要找到question它就会发送给浏览器一个404页面。总是处理执行中的边界值和错误是一个好习惯,symfony给出了一个简单的方法来让这件事情更容易。

修改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模板了。它表示对一个question感兴趣的用户数。还有,现在它非常象一个list,除了在title没有link_to.它只是对初始代码的一次改写,以表示一个question的需要表示的信息。

新的部分是answers  DIV,它表示对这个question的所有answer(用 $question->getAnswers() Propel方法),除了body还表示每个answer的Relevancy(关联),作者的名字,创建日期。

format_date()是另一个需要初始声明的模板助手的例子,你可以在 symfony宝典的 助手的初始化 章节找到更多关于助手的语法。(这些助手帮助我们能更快地完成单调的日期格式化表示的任务)

注意:Propel为关联表创建的方法的名字是在表名后加一个's',虽然->getRelevancys() 这样的方法名有点不好看,但是可以帮你节约号几行SQL代码。

加入一些新的测试数据

现在是时候在 data/fixtures/test_data.yml 给answer和relevancy数据表加些数据了。(你完全可以加些自己的数据)

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

如果以前修改成功,下面的URL将将表示你的第一个question

http://askeet/frontend_dev.php/question/show/id/XX
注意:把XX改成你的第一个question的id

现在 question表示得更好看了,后面还跟着它的answer.不错吧。

修改模块 第1部分

几乎可以肯定表示一个作者的全名在这个应用的其他地方也会用到。你也可以把全名考虑成一个User对象的属性,这意味着User模块应该有一个方法可以用来取得全名,而不是在动作中每次重写。打开 askeet/lib/model/User.php 加入以下的方法

public   function  __toString()
{
  
return   $this -> getFirstName() . '   ' . $this -> getLastName();
}

为什么这个方法叫 __toString() 而不叫 getFullName()或其它类似的名字呢?因为__toString() 方法在PHP5中是一个对象描述为字符串时的缺省方法。这意味着你可以把askeet/apps/frontend/modules/question/templates/showSuccess.php 文件里的下面的代码改写成 更简单的形式

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

代码简洁吧.

不要重复你自己

敏捷开发的一个好的原则就是避免重复代码,被称为 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 >
 

所以我们重构的第一个任务就是去掉这两个模板里的重复代码,把他们放到一个片段(fragment),或成为可重用代码块里。在askeet/apps/frontend/modules/question/template/ 里创建一个_interested_user.php文件,代码如下。

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

然后,把两个模板(listSuccess.phpshowSuccess.php)里的代码置换成

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

一个片段没有任何对现在对象的本地访问,这个片段用了一个 $question变量,所以它必须在对 include_partial 的调用里定义。片段文件的前缀_可以帮助我们把它和template/目录下的真正的模板文件区分开来。如果你想学到更多关于片段的知识,可以读一读 symfony宝典的 显示 章节

修改模块 第2部分

新的片段里的$question->getInterests()调用请求数据库并返回一个Interest类的对象的数组。对于只需要感兴趣的用户的数字来说这是一个消耗太大的请求,它可能给数据库带来不必要的负载。记住,这个调用还在listSuccess.php里调用,是在一个循环里。看来最好优化一下它。

一个好的方法是给Question数据表加入一个叫做interested_users的列,然后在每次有关于这个question的interest创建的时候去更新这个列。

重要:我们将要更改一个模块,但是没有明显的方法去测试它,因为现在没有办法可以去通过askeet给Interest数据表加记录。如果你没法测试,你就不应该做任何修改。幸运的是,我们有一个办法可以测试这个修改,我们会在本章的后面发现它。

给User对象模块加入一个字段

大胆地修改the askeet/config/schema.xml 数据模块,给ask_question数据表加一个字段。

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

然后重建模块

$ symfony propel-build-model

对,我们重建模块而不用担心对现有模块的扩展,因为对User类的扩展是在 askeet/lib/model/User.php里的,它继承自Propel 生成的类askeet/lib/model/om/BaseUser.php。这就是你为什么不应该编辑 askeet/lib/model/om/ 目录下的代码:它们在每次调用build-model的时候都被重建。Symfony帮助我们更容易地在Web项目的早期修改模块。

你好需要更新真实的数据库。为了避免写SQL语句,你要重建你的SQL schema并重新载入测试数据

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

注意:TIMTOWTDI(另一种方法,There is more then one way to do it):除了重建数据库,你可以手工给MySQL数据表加入新的列。

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

修改Interest对象的保存方法

更新这个新字段的动作必须在每次一个user对一个question感兴趣的时候都做一次,也就是说,每次一条新的纪录加入到Interest数据表的时候都要做一次。你可以通过在MySQL里做一个触发器来实现它,但是这是一个数据库依存的解决方案,你就不能很容易地迁移到另一个数据库了。

最好的解决方案是重载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()方法找到和现在的interest关联度question,然后给它的interested_users 字段加上1.然后再调用通常的save()方法,但是因为调用$this->save()的话,会引起循环参照,所以用类的方法 parent::save()来代替。

用事务来让更新请求安全

 如果数据库在Question对象的更新河Interest对象的更新之间出了问题会怎么样呢额?你会得到一个坏掉的数据,这就象银行转帐的时候,在从一个账户扣款和在另一个账户增加钱款两布之间出了问题一样。

如果两个请求是高度相关的,那么你应该用一个事物使它们安全。一个事物是对这两个请求都成功的保证,要么就是两个都不成功。如果某个事务中的某一个请求发生了问题,所有以前成功处理的请求都会被取消,数据库会回到该事务之前的状态。

我们的save()方法是一个举例说明symfony中事务的实现的好机会。用下面的代码替换现在的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 ;
  }
}

首先,方法打开一个Prople的链接,事务保证在->begin()和->commit()之间的语句要么都被执行,要么都不被执行(没有效果)。如果出了问题,那么会唤起一个异常,数据库会执行回滚,回到先前的状态。

改变模板

现在Question对象的->getInterestedUsers() 方法能正常工作了,可以简化_interested_user.php 片段了。

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

替换成

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

注意:感谢我们刚才的使用一个片段来代替模板中的两段重复代码的主意,这次的修改只需要做一次就可以了。否则我们必须修改listSuccess.php和showSuccess.php模板,对于象我们这样的懒人,这实在是太难以忍受了。

在请求和执行时间的问题上,现在有所改善了。你可以在Web调试工具条上的数据库图标的后面看到数据库请求的次数。注意点击这个图标你还可以看到现在这个页面的数据库请求的细节:


测试修改的正确性

我们要检查一下做show动作时没有问题,但是在此之前,再次执行下昨天写的载入数据的批处理。$ cd /home/sfprojects/askeet/batch
$ php load_data.php

当创建Interest数据表的记录的时候,sfPropelData对象将使用重载过的save()方法,它将正确地更新相关的User数据。所以,虽然现在还没有建立对Interest对象的CRUD界面,但这个批处理是一个检验对模块的修改的好办法。

检查下首页和第一个question的详细页面,

http://askeet/frontend_dev.php/
http://askeet/frontend_dev.php/question/show/id/XX
对question感兴趣的user数目没有改变,说明修改成功了!

对answer做同样的事

我们刚才对count($question->getInterests()) 做的事情也可以对count($answer->getRelevancys())做一遍,唯一的不同是一个answer可以被user投票赞成和反对,而一个question只能被投票为'感兴趣'。现在我们理解了怎样修改模块,我们可以做得快一些。下面是修改的内容,你不一定要手动修改它,可以从 askeet SVN容器 下载它。

在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/lib.model.schema.sql

重载lib/model/Relevancy.php中的Relevancy类的->save()方法

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 的关于answer的部分

< 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 >

在schema.xml的answer表加入下面的字段

加入一些数据

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

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

执行数据发布程序

检查下question/show页面

路由

从这个教程的开始,我们就用下面这样的URL

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

缺省的symfony路由规则这样理解这个请求

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

但是有一个路由系统为我们提供了很多其他的可能性,我们可以用question的title作为URL,象如下这样地请求页面。

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

这可以更优化搜索引擎对这个站点的页面的索引,让URL可读性更好。

创建一个title的代替版本

首先我们需要一个title的代替版本--一个精简后的title--作为一个URL. 记住,做一件事情有不只一个方法。我们选择吧这个替代版本的title保存为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/lib.model.schema.sql

我们很快会重载Question对象的setTitle()方法,这样它就可以同时保存这个精简后的title了。

用户类

但是在此之前,我们将创建一个用户类来执行把title转换为精简title的工作,因为这个功能并不一定是越Question对象有关的(我们也可以为Answer对象用它)

在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 ));
}
 

这里注意myTools用户类不需要声明:把它放在 lib/目录下,symfony就会在需要它时自动载入它,

我们现在可以重新载入我们的数据

$ symfony cc
$ php batch/load_data.php

如果你一定要学写更多的关于用户类和用户助手的知识,清康symfony宝典的 扩展 章节。

改变到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 >

现在打开question模块的actions.class.php,然后修改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);
}

试着再次显示question的list并点击它们的title

http://askeet/frontend_dev.php/

URL显示的是question的精简后的title.

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

改变路由

但是现在还没有到我们想要的效果,现在是时候编辑路由规则了。打开routing.yml定义文件(在 askeet/apps/frontend/config/ 目录)然后再文件的头部加上如下的规则

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

在url行,question是一个将表示在URL中的客户化的文本,而 stripped_title是一个参数(以:开头).它们构成了一个式样(pattern),symfony路由系统将把它用于 对questin/show的调用--因为在我们的模板中所有的链接都用了link_to()助手。

现在是最后的测试了!再次表示首页,点击第一个question的title,不仅第一个question被正确表示(证明修改没错)而且地址栏现在表示成了

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

如果你想学到更多的关于路由特性的知识,请读一读 symfony宝典的 路由方针 章节

明天见

今天网站本身并没有增加很多新特性。不管怎么说,你看了更多的模板代码,你知道了怎样修改模块,很多地方的代码被重构了。

这将在一个symfony项目的整个生命周期中:可重用的代码被重购到一个片段或者一个客户类,在动作或者模板里的本该属于模块的代码被移动到模块。虽然这把代码分散到很多目录的很多小文件里,但是维护和评估却变得简单了。另外,symfony项目的文件结构让我们可以很容易地根据一段代码的特性(助手,模块,模板,动作,客户类,等等)找到它的准确位置。

今天做的重构工作将提高接下来的日子里的开发速度,在这个项目的生命周期中,我们将周期性地做更多重构,因为我们的开发方式-让一个特性工作而不必担心接下来的相关关系-要求一个好的代码结构,否则我们将面对一团乱麻。

那么,明天我们干嘛?我们将开始写一个表单,并将看到怎样从表单得到信息。我们还将把首页的列表分割到多个页面。同时,你可以从以下链接访问今天的教程源代码(标记 release_day3)

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

问题和反馈

如果你发现一个拼写错误或其他错误,请 打开 这里

如果你需要支持或者有一个技术问题,请在 用户邮件列表 或 讨论 发帖

猜你喜欢

转载自blog.csdn.net/lujianjian/article/details/1913882