第四天--重构

Symfony回顾

在第三天的学习中,显示并修改了一个MVC结构的所有层次来在主页正确的显示问题列表。程序正在变得漂亮,但是仍然缺少内容。

第四天的目标是为一个问题显示答案,为问题的详细页面指定一个漂亮的URL,添加一个自定义的类,将大块的代码移动到一个更好的地方。这会帮助我们更好的理解模板,模块,路由规则以及重构的内容。也许我们会觉得在仅几天之后重新编写代码还为时过早,但是我们希望看到是在教程结束时我们的感受。

要阅读这节内容,我们必须熟悉Symfony一书中MVC实现的相关内容。如果我们了解什么是敏捷开发也是相当有帮助的。

为一个问题显示答案

首先,让我们继续修改我们在第二天生成的问题CRUD。

question/show动作用来显示一个问题的详细内容,假定我们向他传递了一个id参数。要测试他,可以调用下面的URL:
http://askeet/frontend_dev.php/question/show/id/2

如果我们在此之前测试了这个程序,我们也许已经看了显示页面。这就是我们将要为一个问题添加答案的地方。

动作查看

首先,我们来看一下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参数作为一个主键的唯一记录。让我们给定的URL的例子中,id的参数值为1,所以QuestionPeer的->retrieveByPk()方法将会返回1作为主键的Question类对象。如果我们并不熟悉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()方法就会向浏览器发送一个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>

我们也可以认出我们昨天已添加到listSuccess.php模板中的interested_block div层。他只是显示对一个指定问题感兴趣的用户数量。总之,这些标记与list中的标记十分相似,所不同的只是没有指向标题的link_to。这里我们只是重写了初始化代码来显示一个问题的必要信息。

新的部分是answer层。他显示了一个问题的所有答案(使用了简单的$question->getAnswers() Propel方法),对于每一个答案,显示了总体的评价,作者的名字以及创建时间。

format_date()是模板帮助器的另外一个例子,这需要一个初始声明。我们可以在Symfony一书的国际化帮助器一章中了解到更多的关于帮助器语法以及其他帮助器的内容。

Propel通过在自动在数据表的名字后面添加一个's'来为所链接的数据表生成方法。

添加一些新的测试数据

现在我们需要在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

浏览显示第一个问题的动作来查看修改是否成功:

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

现在的问题就是以更理想的方式来显示,下面我们就来回答这个问题。

修改模块 第一部分

现在我们可以很确定作者的全名一定会在程序的其他地方需要。我们也可以认为全名是User对象的一个属性。这就意味着他们应是User模块的一个方法,允许取得全名,而不是在一个动作中进行重组。让我们来编写这个方法。打开askeet/lib/model/User.php并且添加下面的方法:

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

为什么这个方法命名为__toString(),而不是getFullName()或是其他的相似方法呢?因为这是PHP5将对象表示为字符串默认所使用的方法。这就意味着我们可以将askeet/apps/frontend/modules/question/templates/showSuccess.php模板中的

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

替换为下面的简单代码行

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

来得到同样的结果。很简洁,不是吗?

不要重复自己

敏捷开发的一个原则就是避免复制代码。也就是所谓的"不要重复自己"(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/目录下的实际模板相区别。如果我们希望了解更多的关于片段的内容,我们可以查看Symfony一书的视图一章。

修改模块,第二部分

新片段的$question->getInterests()调用向数据库发送一个请求,并且返回一个Interest类对象数组。这对于有大量感兴趣的用户来说是一个沉重的请求,并且也会花费更长的时间来装入数据库。我们要记得在listSuccess.php模板中也有这个调用,但是这次却是在列表中每一个问题的循环中。所以进行优化将是一个好主意。

一个解决方案就是在Question数据表中添加一列interested_users,并且及时更新。

我们将会在没有透明方法进行测试的情况下修改这个模块,因为当前并没有办法通过askeet来添加Interest记录。我们绝不要没有任何办法测试的情况下修改任何东西。

幸运的是,我们确实有一个办法来测试这个修改,而我们将会这部分的后面来讨论。

在User对象模块中添加一个域

修改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帮助我们在任何webb工程的早期阶段简化模块变化的通常生命周期。

我们需要更新实际的数据库。为了避免编写一些SQL语句,我们应重新构建我们的SQL schema,并且重新装入测试数据:

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

还有其他的方法来完成。为了避免重新构建数据,我们可以手动向MySQL数据库中添加一个新列:

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

修改Interest对象的save()方法

必须在每次一个用户声明他对某个问题感兴趣时更新这个新域的值,例如,每次向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()方法得到与当前兴趣相关的问题,并且增加他的interested_users域。然后,他执行通常的save()操作,但是因为一个$this->save();会进行一个无限循环,所以使用parent::save()方法来替代。

使用事务安全化更新请求

如果数据库在更新Question对象与Interest对象之间出现失效时会怎么样呢?我们就会得到脏数据。在银行中,不一笔金钱交易遇到第一个请求需要减少一个帐户的存款数量,而第二个请求需要增加另一个帐户的存款数量的情况时,也会现在同样的问题。

如果两个请求是高度相关的,我们需要使用事务来安全化他们的执行。一个事务可以保证两个请求都成功,或是都不成功。如果事务中一个请求出现了问题,前面所有成功的请求都会取消,而数据库也会回退到事务开始前的状态。

我们的save()方法是在Symfony中演示实现事务的好机会。用下面的代码来进行相应的替换:

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

首先,这个方法打开一个到数据库的直接连接。在->begin()与->commit()之间的声明可以保证事务全部完成或是全未完成。如果出现了问题,就会抛出一个异常,而数据库就会执行一个回退到以前状态的操作。

改变模板

现在Question对象的->getInterestedUsers()方法可以正常的工作了,现在我们要来简化_interested_user.php片段,将下面的代码:

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

替换为:

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

幸亏我们使用了一个代码片段而不是将复制的代码放在模板中,这样这个修改只需要我们修改一次。如果不是这样,我们就不得不修改listSuccess.php与showSuccess.php模板,而对于懒惰的我们来说,这实是在繁重的工作。

从请求数量与执行的时间来看,这是一个较好的办法。我们可以在web调试工具栏中指定大量的数据请求来进行验证。注意,我们也可以通过点击数据库图标来得到当前页面SQL查询的详细信息。

测试修改的有效性

我们将会通过再次请求show动作来检测并没有破坏任何事情,但是在这之前,再一次运行我们昨天所编写的数据导入脚本:

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

当创建Interest数据表的记录时,sfpropelData对象将会使用覆写后的save()方法,从而可以正确的更新相关的User记录。所以这是测试模块修改的一个好方法,尽管还没有使用Interest对象构建任何的CRUD接口。

通过请求主页以及第一个问题的详细内容来进行检测:
http://askeet/frontend_dev.php/
http://askeet/frontend_dev.php/question/show/id/XX

感兴趣的用户并没有改变。这是一个成功的改动。

对答案执行同样的操作

我们刚才为count($question->getInterests())所做的操作也可以应用在count($answer->getRelevancys())。唯一的区别就在于一个答案可以以被用户认为是积极的或是消极的,而一个问题只可以被认为是'interesting'的。现在我们理解了如何修改模块,所以我们可以很快的完成操作。这里作为提示列出了改变。如果我们使用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中的与问题相关的部分:
<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中添加一些测试数据:
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的默认路由规则可以理解这样的请求,就如同我们实际请求是下面的URL:
http://askeet/frontend_dev.php?module=question&action=show&id=XX

但是具有这样的路由系统也有许多的可能性。我们可以将问题的标题作为URL,那么就可以用下面的URL来请求同样的页面:
http://askeet/frontend_dev.php/question/what-shall-i-do-tonight-with-my-girlfriend

这可以优化搜索引擎索引网站页面的方式,并且使得URL更具有可读性。

创建标题的替换版本

首先,我们需要改变用作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/lib.model.schema.sql

我们很快就会改写Question对象的setTitle()方法,从而他可以同时设置标题。

自定义类

但是在那之前,我们将会创建一个自定义的类来实际的进行标题转换,因为这个方法并不会真正的关注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);
}

再一次显示问题列表,并通过点击标题来进行访问:
http://askeet/frontend_dev.php/

现在问题的URL显示为:
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是一个参数。他们组成了一个Symfony路由系统将会在链接到question/show动作调用上的模式--因为在我们模板中的所有链接都使用link_to()帮助器。

现在可以进行最终的测试了:再一次显示主页,点击第一个问题标题。现在不仅会显示第一个问题,而我们的地址栏显示为:
http://askeet/frontend_dev.php/question/what-shall-i-do-tonight-with-my-girlfriend

如果我们希望了解更多的关于路由特性的内容,我们可以阅读Symfony一书的路由策略一节。

明天见

今天,网站本身并没有添加许多新的特性。然而,我们了解了更多的模板编码,我们知道了如何修改模块,以及在许多地方重构的整个代码。

这在整个Symfony工程中是经常发生的:可以重用的代码重构到一个片段或是一个自定义类中,在一个动作中显示的代码或是一个模板以及真正属于模块的都会移动到模块中。尽管这会将代码分解为许多目录中的小文件,但是这却会使得维护与修改变得十分容易。另外,一个Symfony工程的文件结构使得代码片段依据其特性很容易进行查找。

今天所完成的重构工作将会加速以后的开发。而我们在整个工程还会做大量的重构工作,因为我们的开发方式--使得一个特性工作而不必担心以后的功能--需要一个良好的代码结构。

明天我们做什么呢?我们将会开始编写一个表单,并且了解如何用他来收集信息。我们也会将主页的问题列表分解为几个页面。

你可能感兴趣的:(数据结构,sql,PHP,mysql,敏捷开发)