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
{
$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 用以下的内容替换掉。
< 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数据表加些数据了。(你完全可以加些自己的数据)
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 加入以下的方法
{
return $this -> getFirstName() . ' ' . $this -> getLastName();
}
为什么这个方法叫 __toString() 而不叫 getFullName()或其它类似的名字呢?因为__toString() 方法在PHP5中是一个对象描述为字符串时的缺省方法。这意味着你可以把askeet/apps/frontend/modules/question/templates/showSuccess.php 文件里的下面的代码改写成 更简单的形式
代码简洁吧.
不要重复你自己
敏捷开发的一个好的原则就是避免重复代码,被称为 Don't Repeat Yourself (D.R.Y)。这是因为重复的代码在检查,修改,测试和更新的时候都要比一段封装后的代码多花更多的时间。这还让应用的维护变得复杂。如果你去看看昨天的教程,你会注意到昨天写的listSuccess.php模板和ShowSuccess.php有一些重复的代码。
< div class = " interested_mark " >
<? php echo count ( $question -> getInterests()) ?>
</ div >
</ div >
所以我们重构的第一个任务就是去掉这两个模板里的重复代码,把他们放到一个片段(fragment),或成为可重用代码块里。在askeet/apps/frontend/modules/question/template/ 里创建一个_interested_user.php文件,代码如下。
<? php echo count ( $question -> getInterests()) ?>
</ div >
然后,把两个模板(listSuccess.php 和 showSuccess.php)里的代码置换成
<? 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数据表加一个字段。
然后重建模块
$ 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 ,写如下的代码
{
$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()。
{
$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 片段了。
把
替换成
注意:感谢我们刚才的使用一个片段来代替模板中的两段重复代码的主意,这次的修改只需要做一次就可以了。否则我们必须修改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_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()方法
{
$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类加入以下的方法
{
$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的部分
<? 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表加入下面的字段
加入一些数据
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表。
< 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文件,
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 类文件加入
{
parent :: setTitle( $v );
$this -> setStrippedTitle(myTools :: stripText( $v ));
}
这里注意myTools用户类不需要声明:把它放在 lib/目录下,symfony就会在需要它时自动载入它,
我们现在可以重新载入我们的数据
$ symfony cc
$ php batch/load_data.php
如果你一定要学写更多的关于用户类和用户助手的知识,清康symfony宝典的 扩展 章节。
改变到show动作的链接
在listSuccess.php模板,修改下面这行,
改为
现在打开question模块的actions.class.php,然后修改show动作。
{
$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/ 目录)然后再文件的头部加上如下的规则
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/
问题和反馈
如果你发现一个拼写错误或其他错误,请 打开 这里