Calendário do advento Symfony dia quatro: refatorando
Anteriormente no symfony
Durante o dia três, todas as camadas da arquitetura MVC foram mostradas e modificadas para ter uma lista de questões mostradas corretamente na homepage. A aplicação está mais agradável mas falta ainda o índice.
Os objetivos para o quarto dia são mostrar a lista das respostas a uma pergunta, dar um URL agradável à página de detalhe da pergunta, adicionar uma classe feita sob encomenda e mover algum pedaço dos códigos para um lugar melhor. Isto o ajudará a entender o conceito de templates, model, política de roteamento e refatoração. Você pode pensar que está muito cedo para reescrever o código que é de apenas alguns dias, mas veremos o que achará sobre ele no fim deste tutorial.
Para ler este tutorial, você deve estar familiarizado com o conceito da implementação MVC no symfony. Ajudaria também se você tivesse uma idéia sobre o que desenvolvimento ágil.
Mostrar as respostas para uma questão
Primeiro, deixe-me continuar a adaptação dos templates gerados pela Questão durante o CRUD dia dois
A ação question/show foi feita para mostrar os detalhes de uma questão, para isso você passa uma id. Teste-o, abrindo :
http://askeet/frontend_dev.php/question/show/id/1
Você provavelmente já viu a pagina show se brincou antes com a aplicação. Aqui é onde nos vamos adicionar a resposta e a questão.
Uma olhadinha na ação
Primeiro, vamos dar uma olhada na ação show, localizada no arquivo askeet/apps/frontend/modules/question/actions/actions.class.php:
[php]
public function executeShow()
{
$this->question = QuestionPeer::retrieveByPk($this->getRequestParameter('id'));
$this->forward404Unless($this->question);
}
Se você está familiarizado com Propel, você reconhece aqui uma simples requisição para a tabela Question. Ela trará um registro único pelo valor da id que é sua chave primária. No exemplo mostrado na URL anteriormente, o parâmetro id traz o valor 1, então o método ->retrieveByPk() da classe QuestionPeer retornará um objeto da classe Question que tenha 1 como chave primária. Se você não está familiarizado com Propel, volte depois de ler a documentação em seu site.
O resultado dessa requisição será passada para o template showSuccess.php através da variável $question.
O método ->getRequestParameter('id') dos objetos sfAction pegam... os parametrod de requisição chamados id, Se ele for passado em um modo GET ou POST. Por exemplo, se você requerer :
http://askeet/frontend_dev.php/question/show/id/1/myparam/myvalue
...então a ação show pode requerer myvalue por $this->getRequestParameter('myparam').
Note: O método
forward404Unless()envia para o navegador uma página 404 se a questão não existir no banco de dados. É sempre uma boa prática trabalhar com uma margem de casos e erros que podem ocorrer durante a execução e o symfony dá alguns métodos simples para te ajudar a fazer a coisa certa facilmente.
Modifique o template showSuccess.php
O template gerado showSuccess.php não é exatamente o que precisamos, então vamos reescreve-lo completamente. Abra o arquivo frontend/modules/question/templates/showSuccess.php e substitua o seu conteúdo por isto:
[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>
Você reconhece aqui o div do interested_block que foi adicionado ao templete listSuccess.php ontem. Ele simplesmente mostra o numero de usuários interessados por uma questão.
adell - Não consegui fazer a tradução desta frase.
After that, the markup also looks very much like the one of the list, except that there is no link_to on the title.
É justo reescrever do código inicial para indicar somente a informação necessária sobre uma pergunta.
A nova parte é a div answers. Ela mostra todas as respostas para a questão (usando o simples método $question->getAnswers()), e para cada um deles, mostra a relevância total, o nome do autor e a data de criação adicionada ao corpo.
O format_date() é outra exemplo de template helpers para qual uma declaração inicial é requerida. Você pode procurar mais sobre a sintaxe deste helper e de outros em capitulo de internacionalização em do livro do symfony (estes helpers apressam as tarefas tediosas de indicar datas em um formato bem parecido).
Nota: O Propel cria nomes do método para tabelas ligadas adicionando um 's' automaticamente no fim do nome da tabela.
Perdoe por favor o feio método do ->getRelevancys() que elimina diversas linhas do código do SQL.
Adicione novos dados para teste
É hora de adicionar dados para as tabelas answer e relevancy, no fim do arquivo data/fixtures/test_data.yml(sinta-se a vontade para adicionar os seus próprios):
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.
Recarregue seus dados com:
$ php batch/load_data.php
Navegue para a action mostrando a primeira questão e cheque se todas as modificações tiveram sucesso:
http://askeet/frontend_dev.php/question/show/id/XX
Nota: Substitua o XX com o
idda sua primeira questão.
A questão é agora mostrada de maneira mais extravagante, seguida de resposta para ela. Bem melhor, não?
Modifique o model, parte I
Está quase certo que o nome completo do autor será necessitado em algum lugar a mais na aplicação. Você pode também considerar também que o nome completo é u, atributo do objeto User. Isto significa que deve ser um método no modelo User para recuperar o nome completo, em vez de o reconstruir em uma ação. Vamos reescreve-lo. Abra o askeet/lib/model/User.php e adicione o seguinte metodo:
[php]
public function __toString()
{
return $this->getFirstName().' '.$this->getLastName();
}
Por que este metodo é chamado __toString() em vez de getFullName() ou algo semelhante? Porque o metodo __toString() é o metodo padrão usado pelo PHP5 para objetos para a representação do objeto como a string. Isto significa que você pode substituir
[php]
posted by <?php echo $answer->getUser()->getFirstName().' '.$answer->getUser()->getLastName() ?>
linha do template askeet/apps/frontend/modules/question/templates/showSuccess.php por uma mais simples
[php]
posted by <?php echo $answer->getUser() ?>
para conseguir o mesmo resultado. Bom, não é?
Não se repita
Um dos melhores princípios do desenvolvimento ágil é evitar duplicidade de código Isto se chama "Don't Repeat Yourself" (D.R.Y.). Isto é porque código duplicado é duas vezes mais difícil de revisar, modificar, testar e validar que um único pedaço de código encapsulado. Isto também torna a manutenção de aplicações muito mais complexas. E se você prestou atenção na ultima parte do tutorial de hoje, você observou algum código duplicado entre o template listSuccess.php escrito ontem e o template showSuccess.php:
[php]
<div class="interested_block">
<div class="interested_mark">
<?php echo count($question->getInterests()) ?>
</div>
</div>
Então nossa primeira sessão de refatoração removerá este pedaço de código dos dois templates e coloca-o em um fragmento, ou pedaço de código reusável. Crie um arquivo _interested_user.php no diretório askeet/apps/frontend/modules/question/template/ com seguinte código:
[php]
<div class="interested_mark">
<?php echo count($question->getInterests()) ?>
</div>
Então substitua o código original em ambos os templates (listSuccess.php e showSuccess.php) com:
[php]
<div class="interested_block">
<?php include_partial('interested_user', array('question' => $question)) ?>
</div>
Um fragmento não tem acesso nativo a qualquer objeto corrente. O fragmento usa uma variável $question, assim deve ser definida na chamada include_partial. O adicional _ em frente do nome do arquivo de fragmento ajuda a distingui-lo facilmente do atual template no diretório tempate/. Se você quer aprender mais sobre fragmentos, leia o capitulo de view do livro symfony.
Modifique o modelo, parte II
A chamada $question->getInterests() do novo fragmento fará um pedido para a base de dados e retorna uma array de objetos da classe Interest. Este é um pedido pesado somente para o número de pessoas interessadas, e pôde carregar demais a base de dados. Lembre-se que esta chamada é feita também no template listSuccess.php, mas em um loop, para cada questão da lista. Seria uma boa ideia otimiza-la.
Uma boa solução é adicionar uma coluna na tabela Question chamada interested_users, e atualizar esta coluna cada vez
que um interesse é criado.
Cuidado: Nós estamos a ponto de modificar o modelo sem nenhuma maneira aparente testá-la, já que não há atualmente nenhuma maneira de adicionar registros de
Interestatravés do askeet. Você nunca deve modificar algo sem alguma maneira de testá-la.Felizmente, nos temos uma maneira de testar esta modificação, e você descobrirá mais tarde nesta parte.
Adicione um campo ao modelo de objeto User.
Vá sem medo e modifique o data model askeet/config/schema.xml adicionando à tabela ask_question:
[xml]
<column name="interested_users" type="integer" default="0" />
Reconstrua o modelo: $ symfony propel-build-model
Está certo, nós estamos reconstruindo o modelo sem nos preocuparmos com extensões existentes a ele! Isto é porque a extensão da classe do User foi feita no askeet/lib/model/User.php, qual herda o propel gerado da classe askeet/lib/model/om/BaseUser.php. Isto é porque você nunca deve editar o código do diretório askeet/lib/model/om/: ele é rescrito a cada vez que o build-model é chamado. O symfony ajuda facilitar o ciclo de vida normal das mudanças do modelo nos estágios adiantados de todo o projeto web.
Você também precisa atualizar a base de dados atual. Para evitar de escrever algum SQL, você deve reconstruir seu SQL schema e recarregar seus dados:
$ symfony propel-build-sql
$ mysql -u youruser -p askeet < data/sql/schema.sql
$ php batch/load_data.php
Nota: TIMTOWTDI: Há mais de uma maneira de fazer isto. Em vez de reconstruir a base de dados, você pode adicionar uma nova coluna ao Mysql na mão:
$ mysql -u youruser -p askeet -e "alter table ask_question add interested_users int default '0'"
Modifique o metodo save() do objeto Interest
Deve ser atualizado o valor deste campo sempre que um usuário declara seu interesse para uma pergunta, i.e. cada vez que um novo campo é adicionado para a tabela Interest. Você poderia executar isto com um trigger no MySQL, mas isto seria uma solução dependente da base de dados e você não poderia mudar para outra base de dados tão facilmente.
O melhor solução é modificar o modelo cancelando o metodo save() da classe Interest. Este metodo é chamado cada vez que um objeto da classe Intrest é criado. Então abra o arquivo askeet/lib/model/Interest.php e escreva o seguinte metodo:
[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;
}
O novo metodo save() obtém a questão relacionada ao interesse corrente e incrementa e seu campo do interested_users. Então faça o usual save(), mas porque um $this->save(); terminaria em um loop infinito, usa o metodo parent::save() preferencialmente.
Fixar o pedido atualizando com uma transação
Que aconteceria se a base de dados falhasse entre o update do objeto do Questione do objeto do Interest? Você teria dados corrompidos. Este é o mesmo problema encontrado com em um banco quando a transferência do dinheiro significa um primeiro pedido para diminuir a quantidade de um cliente, e em um segundo pedido aumentar um outro cliente.
Se dois pedidos são altamente dependentes, você deve fixar sua execução com uma transação. Uma transação é a segurança de que ambos os pedidos serão executados, ou nenhum deles. Se algum erro acontecer para um dos pedidos da transação, todos os anteriores serão cancelados e a base de dados retorna ao estado onde estava antes da transação.
Nosso metodo save() é uma boa oportunidade para ilustrar a implementação da transação no symfony. Troque o código por:
[php]
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;
}
}
Primeiro, o metodo abre uma conexão direta para a base de dados através do Creole. Entre o declaração ->begin() e o ->commit() a transação assegura que todos serão terminados ou nenhum. Se algum falha, uma exceção será levantada e a base de dados executara um rollback para o estado anterior.
Mude o template
Agora que o metodo ->getInterestedUsers() do objeto Question trabalha corretamente é hora de simplificar o fragmento _interested_user.php por:
[php]
<?php echo count($question->getInterests()) ?>
by
[php]
<?php echo $question->getInterestedUsers() ?>
Nota: Graças a nossa brilhante ideia de usar um fragmento em vez de sair duplicando código no template, esta modificação somente é necessária em um. Se não, nos teríamos que modificar os templates
listSuccess.phpeshowSuccess.phpe para preguiçosos como nos, isto é imperdoável.
Nos termos do número dos pedidos e do tempo de execução, isto deve ser melhor. Você pode verificar o número dos pedidos da base de dados indicados na web debug tolbar, apos o ícone de base de dados. Observe que você pode também obter os detalhes das perguntas do SQL para a página atual clicando no ícone da base de dados.
Teste a validação da modificação
Nós nos certificaremos de que nada esteja quebrado executando a ação show outra vez, mas antes, rodamos outra vez o batch de importação dos dados que nós escrevemos ontem:
$ cd /home/sfprojects/askeet/batch
$ php load_data.php
Quando criamos os registros da tabela Interest, o objeto sfPropelData sobrescrevera o metodo save() e deve corretamente atualizar os registros relacionado ao User. Assim esta é uma boa maneira de testar a modificação do modelo,
mesmo se ainda não houver nenhuma relação do CRUD com o objeto Interest construído.
Verifique-o pedindo a Home Page e o detalhe da primeira pergunta:
http://askeet/frontend_dev.php/
http://askeet/frontend_dev.php/question/show/id/XX
O numero de usuários interessados não muda. Isto é um sucesso!
Mesmos para as respostas
O que era bom feito para
O que foi feito para count($question->getInterests()) poderia também ser feito para count($answer->getRelevancys()). A única diferença será que uma resposta pode ter votos positivos e negativos por usuários, quando uma pergunta puder somente ser votada como 'intesting'. Agora que você sabe como modificar o modelo, nós podemos ir rapidamente. Estão aqui as mudanças, apenas como um lembrete. Você não precisa copia-los à mão para o tutorial de amanha se você usar o repositório SVN do askeet.
Adicione a seguinte coluna a tabela
answeremschema.xml[xml] <column name="relevancy_up" type="integer" default="0" /> <column name="relevancy_down" type="integer" default="0" />Reconstrua o model a atualize a base de dados
$ symfony propel-build-model $ symfony propel-build-sql $ mysql -u youruser -p askeet < data/sql/schema.sqlSobrescreva o metodo
->save()da classeRelevancyemlib/model/Relevancy.php[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; } }Adicione os dois métodos na classe
Answerno modelo:[php] 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; }Mude a parte a respeito das respostas dentro
question/templates/showSuccess.phppor:[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>Adicione algum dado de teste no fixtures
Relevancy: rel1: answer_id: a1_q1 user_id: fabien score: 1 rel2: answer_id: a1_q1 user_id: francois score: -1Execute o batch de população
Cheque a pagina
question/show
Roteamento
Desde o inicio deste tutorial, chamamos a URL
http://askeet/frontend_dev.php/question/show/id/XX
As regras padrão roteamento do symfony entende este pedido como se você realmente houvesse pedido
http://askeet/frontend_dev.php?module=question&action=show&id=XX
Mas tendo um sistema de roteamento, abre muitas outras possibilidades. Nós poderíamos usar o título das perguntas como o URL, para requerem a mesma página com:
http://askeet/frontend_dev.php/question/what-shall-i-do-tonight-with-my-girlfriend
Isto otimiza a maneira que os motores de busca posicionam as páginas do Web site, e para fazer os URLs de mais fácil leitura
Criar uma versão alternativa do título
Primeiro, nos precisamos um versão convertida do título - um título modificado - para ser usado como uma URL. Há mais de uma maneira para fazer isto, e nós escolheremos armazenar este título alternativo como uma coluna nova da tabela Question. No schema.xml, adicione a seguinte linha para a tabela Question:
[xml]
<column name="stripped_title" type="varchar" size="255" />
<unique name="unique_stripped_title">
<unique-column name="stripped_title" />
</unique>
...e reconstrua o modelo e atualize a base de dados:
$ symfony propel-build-model
$ symfony propel-build-sql
$ mysql -u youruser -p askeet < data/sql/schema.sql
Nos rescreveremos o metodo setTitle() do objeto Question para que inicialize o título modificado ao mesmo tempo.
Classes customizadas
Mas antes disto, iremos criar uma classe customizada para transformar realmente um título em um título modificado,
desde que esta função não concerne realmente especificamente ao objeto Question (nós provavelmente o usaremos também para o objeto Answer).
Crie um novo arquivo myTools.class.php no diretório askeet/lib/:
[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;
}
}
Agora abra o arquivo da classe askeet/lib/model/Question.php e adicione:
[php]
public function setTitle($v)
{
parent::setTitle($v);
$this->setStrippedTitle(myTools::stripText($v));
}
Observe que a classe customizada myTools não necessita ser declarada: o symfony a carrega automaticamente quando necessitado, contanto que esteja situada no diretório do lib/.
Nos podemos agora recarregar nossos dados:
$ symfony cc
$ php batch/load_data.php
Se você quiser aprender mais sobre classes customizadas e helpers customizados, leia o capitulo de extensões do livro do symfony.
Mude os links da ação show
No template listSuccess.php, mude a linha
[php]
<h2><?php echo link_to($question->getTitle(), 'question/show?id='.$question->getId()) ?></h2>
por
[php]
<h2><?php echo link_to($question->getTitle(), 'question/show?stripped_title='.$question->getStrippedTitle()) ?></h2>
Agora abra o actions.class.php do modulo question, e mude o action show para:
[php]
public function executeShow()
{
$c = new Criteria();
$c->add(QuestionPeer::STRIPPED_TITLE, $this->getRequestParameter('stripped_title'));
$this->question = QuestionPeer::doSelectOne($c);
$this->forward404Unless($this->question);
}
Tentar mostrar outra vez a lista das perguntas e acessar cada uma delas clicando em seu título:
http://askeet/frontend_dev.php/
O URLs mostram corretamente o título descascado das perguntas:
http://askeet/frontend_dev.php/question/show/stripped-title/what-shall-i-do-tonight-with-my-girlfriend
Mudando as réguas do roteamento
Mas isto não é exatamente como nós queremos que seja mostrado. É hora de modificar as regras de roteamento. Abra o arquivo de configuração routing.yml (localizado no diretório askeet/apps/frontend/config/) e adicione a seguinte regra no inicio do arquivo:
question:
url: /question/:stripped_title
param: { module: question, action: show }
Na linha url, a palavra question é um texto customizado que aparecerá no URL final, o stripped_title é um parâmetro (ele é precedido por :). Dão forma a um *paterm que o sistema de roteamento do symfony aplica aos links das chamadas da action question/show - porque todas os links em nossos templates usam o helper link_to().
É hora do teste final: Mostre novamente a homepage, clique no título da primeira questão. Não somente a primeira pergunta é mostrada (provando que nada esta quebrado), mas a barra de endereço do seu navegador agora mostra:
http://askeet/frontend_dev.php/question/what-shall-i-do-tonight-with-my-girlfriend
Se você quer aprender mais sobre a feature de roteamento, leia o capitulo politica de roteamento do livro do simfony.
Vejo você amanha
Hoje, o Web site não recebeu muitas características novas. Entretanto, você viu mais sobre codificação de templates, você sabe modificar o modelo, e o código foi totalmente refatorado em muitos dos lugares.
Isto acontece toda hora na vida de um projeto symfony: o código que pode ser reusado é refatorado em um fragmento ou a uma classe customizada, o código que aparece em uma ação ou em um molde e que pertence realmente ao modelo é movido para o modelo. Mesmo se isto espalhar o código em pequenos arquivos em vários diretórios, a manutenção a evolução fica fácil
A estrutura de arquivos de um projeto symfony torna fácil encontrar onde uma parte de código se encontra realmente de acordo com sua natureza (helper, model, template, action, custom class, etc.).
O trabalho de refatoração feito hoje agiliza o desenvolvimento dos próximos dias. E nós faremos periodicamente alguma refatoração na vida deste projeto, da maneira que nós desenvolvemos - fazer uma característica trabalhar sem se preocupar com funcionalidades futuras - requer uma estrutura boa de código se nós não quisermos terminar com uma bagunça total.
O que para amanhã? Começaremos escrever um formulário e veremos como recuperar a informação dele. Nós separaremos também a lista das perguntas do Home Page em páginas. Neste ínterim, senta-se livre para fazer download do código de hoje do repositório de SVN (marcado como release_day_4) em:
http://svn.askeet.com/tags/release_day_4/
e para enviar-nos qualquer questão usando a askeet mailing-list ou o forum dedicado.