Askeet. День четвёртый. Рефакторинг
Приветствуем!
Хотите что-то написать?Нужно назвать себя.
Если вы пришли в первый раз,
то нужно зарегистрироваться.
Переводы → Askeet. День четвёртый. Рефакторинг
В предыдущих сериях
Во время третьего дня мы изучили все слои архитектуры MVC для отображения для правильного отображения списка вопросов на главной странице. Приложение становится симпатичнее, но до сих пор не хватает контента
Целями чётвёртого дня являются - показать список ответов, задать симпатичный УРЛ для отдельного вопроса, добавить собственный класс и переместить некоторые цепочки кода в лучшее место. Это поможет вам понять основы шаблона, модели, разграничения путей и рефакторинга. Вам может показаться что код переписывать ещё рано - ему несколько дней отроду. Но вы начнёте думать иначе после этого урока.
Отображение ответов к вопросам
Для начала продолжим адаптацию шаблонов, созданных при помощи CRUD во время второго дня
Действие action/show направлено на отображение деталей вопроса, учитывая что вы передали ему id. Для проверки просто введите ссылку, заменив id на существующий:
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 из запроса, который является главным ключом. В ссылке, данной выше, указан параметр со значением 2, поэтому метод retrieveByPk() класса QuestionPeer возвратит объект класса Question, имеющий главный ключ равный 1. Если вы ещё не связывались с Propel, то пора бы уже почитать документацию.
Результат данного запроса передаётся в шаблон showSuccess.php через переменную $question.
Метод getRequestParameter('id') объекта sfAction получает... параметр с именем id, независимо от того передан он через метод GET или POST. Например, если вы запросите:
http://askeet/frontend_dev.php/question/show/id/1/myparam/myvalue
Тогда действие show сможет получить значение myvalue через $this->getRequestParameter('myparam').
Изменяем шаблон showSuccess
Сгенерированный шаблон showSuccess.php это не совсем то, что нам нужно. Поэтому мы его полностью перепишем... Откройте файл frontend/modules/question/templates/showSuccess.php и замените в нём всё на следующее:
<?php use_helper('Date') ?> <div class="interested_block"> <div class="interested_mark" id="mark_<?php echo $question->getId() ?>"> <?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>
Вы можете заметить блок interest_block, который уже вчера фигурировал в шаблоне listSuccess.php. Он лишь отображает количество заинтересованных пользователей в данном вопросе. К тому же разметка выглядит очень похоже на ту, которая использовалась в list, кроме link_to в заголовке. Этот кусок кода лишь отображает необходимую информацию о вопросе.
Новым является блок answers. Он отображает все ответы на вопросы (используя обычный метод $question->getAnswers()) и для каждого из них отображает релевантность, имя автора и дату создания дополнительно к тексту.
format_date() это ещё один пример хелпера для шаблонов, который нужно подключить. Вы можете узнать больше о синтаксисе данного и других хелперов из главы про хелперы перевода
Propel создаёт названия методов для присоединённых таблиц добавляет постфикс 's' к имени таблицы. Простите ему ужасные методы вроде getRelevancys(), которые заменяют вам несколько строк 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
Hint от переводичка: как уже и говорилось ранее - в symfony 1.1 это реализуется через команду:
symfony propel:data-load frontend
Откройте адрес, отображающий первый вопрос и посмотрите на изменения. (замените XX на id вопроса)
http://askeet/frontend_dev.php/question/show/id/XX

Теперь вопросы отображаются вместе с ответами. Гораздо лучше, не так ли?
Изменяем модель. Часть 1
Думаю понятно, что полное имя автора где-нибудь понадобится в приложении. Можете считать что полное имся это атрибут объекта User. Это значит, что должен быть метод в модели User, позволяющий получить полное имя, вместо того чтобы постоянно определять его в действиях. Давайте напишем его. Откройте askeet/lib/model/User.php и добавьте следующую функцию:
public function __toString(){ return $this->getFirstName().' '.$this->getLastName();}
А чёй то мы его названли __toString() а не getFullName какой-нибудь? Потому что __toString() это стандартный метод для представление объектов php5 в виде строки. Это значит что код вроде:
posted by <?php echo $answer->getUser()->getFirstName().' '.$answer->getUser()->getLastName() ?>
Легко заменяется на
posted by <?php echo $answer->getUser() ?>
Результат не отличается. Чудесно, не правда ли?
Не повторяйся!
Одним из очень хороших принципов гибкого программирования - избегать повторения кода. Данный принцип говорит - "Не повторяйся!". Всё потому что двойной код тяжело проверять и изменять, в отличии от уникально инкапсулированного куска кода. Оно так же усложняет поведение кода. И если вы были внимательны, то наверняка заметили повторение кода в listSuccess.php и showSuccess.php:
<div class="interested_block"> <div class="interested_mark" id="mark_<?php echo $question->getId() ?>"> <?php echo count($question->getInterests()) ?> </div></div>
В первой очереди нашего рефакторинга мы избавимся от дублирования этого куска кода и поместим его в отдельный фрагмент, который возможно использовать повторно. Создайте файл _interest_user.php в askeet/apps/frontend/modules/question/templates/ со следующим кодом:
<div class="interested_mark" id="mark_<?php echo $question->getId() ?>"> <?php echo count($question->getInterests()) ?></div>
После чего замените оригинальный код в обоих шаблонах(listSuccess.php и showSuccess.php) на следующее:
<div class="interested_mark" id="mark_<?php echo $question->getId() ?>"> <?php echo count($question->getInterests()) ?></div>
У данного фрагмента нет родного доступа к любому из текущих объектов. Фрагмент использует переменную $question, поэтому её нужно указать при вызове include_partial. Дополнительный символ _ перед файлом фрагмента помогает с лёгкостью отличить фрагменты от обычных шаблонов в каталоге templates/. Для того чтобы узнать больше о фрагментах прочтите главу по видам
Изменяем модель. Часть 2
Вызов метода $question->getInterests() создаёт новый запрос в базу данны и возвращает массив объектов класса Interest. Это тяжёлый запрос всего-лишь для числа заинтересованных пользователей и он может нагрузить неплохо базу. Помните, что данный запрос также выполняется в шаблоне listSuccess.php, но уже во время итерации, для каждого из вопроса в списке. Было бы неплохо оптимизировать данную штуку.
Отличных решением будет добавление колонки к таблице Question именумой interested_users и обновлять значение данной колонки каждый раз когда создаётся интерес к вопросу.
Мы собираемся изменить модель без тестирование, так как нет возможности добавить интересы через askeet. Не нужно ничего изменить без предварительного тестирования.
К счастью есть способ проверить изменения и вы узнаете о нём чуть позже.
Добавляем поле к модели объекта 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, которые заимствует класс askeet/lib/model/om/BaseUser.php, созданный Propel. Вот почему не стоит изменять код, расположенный в askeet/lib/model/om/. Он перестраивается каждый раз когда вызывается build-model. Symfony помогает в упрощении нормального жизенного цикла в изменениях моделей на ранних стадиях развития любого веб проекта.
Вам так же понадобится обновить текущю базу данных. Воизбежании написания SQL запросов перестроите вашу SQL-схему и перезагрузите тестовые данные.
$ symfony propel-build-sql$ mysql -u youruser -p askeet < data/sql/lib.model.schema.sql$ php batch/load_data.php
hint от переводчика: напоминаю для забывчивых про версию symfony 1.1 и команду insert-sql ;)
Есть и другой способ перестроить базу - вы можете добавить новую колонку вручную:
$ mysql -u youruser -p askeet -e "alter table ask_question add interested_users int default '0'"
Изменяем метод save() объекта Interest
Обновление значения данного поля должно происходить каждый раз, когда пользователь показывает свой интерес к вопросу, например каждый раз когда запись добавляется в таблицу Interest. Вы можете ввести это при помощи триггера в MySQL, но это будет решение, зависимое от баз данных и переключиться на другую базу данных уже будет непросто.
Лучшим решением будет изменение метода save() для модели класса Interest. Данный метод вызывается каждый раз при создании объекта класса 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()
Безопасность обновления при помощи транзацкий
Что случится если база "крякнула" между обновлением объекта вопроса и одним из объектов 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; }}
Сначала метод открывает подключение к базе данных через Creole. Между begin() и commit() транзакция делает так что будет сделано всё или ничего. Если что-то не удалось произвести, то будет выкинуто исключение и база данных откатится на предыдущее состояние.
Меняем шаблоны
Раз уж у нас метод getInterestedUsers() заработал как надо самое время упростить фрагмент _interested_user.php и заменить:
<?php echo count($question->getInterests()) ?>
на:
<?php echo $question->getInterestedUsers() ?>
Благодаря нашей замечательной идее с использованием фрагмента вместо дублирующегося кода в шаблонах, данное изменение нужно сделать лишь однажды. Если бы не это, то пришлось бы переделывать шаблоны listSuccess.php и showSuccess.php, а для таких ленивцев как бы это было бы переусредием.
В отношении к количеству запросов и времени исполнения стало попроще. Вы можете проверить это при помощи количества запросов, отображённых на панели отладки, после иконки баз данных. Так же вы можете получить детальную информацию по запросам для текущей страницы кликнув на иконку базы данных.


Проверка верности изменений
Ну чтож, проверьте что ничего не сломалось запросив действией show вновь. Но перед этим запустите импорт данных
$ cd /home/sfprojects/askeet/batch$ php load_data.php
При создании записей таблицы Interest, объект sfPropelData будет использовать переписанный метод save() и должен правильно обновить соотвествующие записи User. Это хороший путь для проверки изменений в модели, даже если нет интерфейса CRUD с объектом Interest.
Проверьте всё запросив следующие ссылки:
http://askeet/frontend_dev.php/
http://askeet/frontend_dev.php/question/show/id/XX
Количество заинтересованных пользователей не изменилось. Отличный ход!
Теперь тоже для ответов
То, что мы проделали для count($question->getInterests()) можно легко проделать для ($answer->getRelevancys()). Единственным отличием будет то, что ответ может иметь как отрицательные так и положительные значения голосов пользователей, в то время как вопрос может быть только "интересным". Теперь, когда вы понимаете как изменять модель, мы пойдём куда быстрее. Вот наши изменения, просто напоминание. Вам не обязательно копировать их от руки, если вы воспользуетесь репозитарием askeet.
Добавьте следующие колонки в таблицу answer в schema.xml
<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
НЛО прилетело и опубликовало эту подсказку здесь: insert-sql
Переписываем метод save() класса Relevancy в 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; }}
Добавьте 2 следующих метода в класс 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;}
Измените кусок кода, отвечающий за ответы в answers in 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>
Добавьте тестовые данные в фикстуры:
Relevancy: rel1: answer_id: a1_q1 user_id: fabien score: 1 rel2: answer_id: a1_q1 user_id: francois score: -1
Запустите скрипт заполнения базы
Проверьте страницу question/show

Пути
С самого начала данного урока мы вызывали ссылку
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
Это будет отличной оптимизацией для поисковых движков и сделает ссылки более читаемыми
Создаём альтернативную версию title
Сначала нам нужна изменённая версия заголовка (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
Вскоре мы перепишем метод setTitle() объекта Question таким образом, что он будет в то же время сохранять изменённый заголовок.
Изменённый класс
Но перед этим мы создадим собственный класс который преобразует заголовок, не знающий природу объекта Questopn (класс может пригодиться нам и для объекта Answer).
Создайте класс myTools.class.php в каталоге askeet/lib/
<?phpclass 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 не обязательно определять: symfony загружает его автоматически когда нужно, если тот расположен в каталоге lib.
А теперь можно и данные перезагрузить
$ symfony cc$ php batch/load_data.php
Если вы хотите знать больше о дополнительных классах и дополнительных хелперах, прочтите главу о расширении
Изменяем ссылки для действия 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?id='.$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/
Изменённый заголовки корректно отображаются в ссылках:
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 это наш текст, который будет отображаться в ссылке, в то время как stripped_title это параметр. Вместе они образуют шаблон, который система маршрутизации symfony применить ко всем ссылкам вида question/show - потому что все эти ссылки используют хелпер link_to()
Время последней проверки: отобразим вновь главную страницу, кликнем по первому заголовку. Теперь не только отображается вопрос (доказывая ничего не сломано), но и в адресной строке вашего браузера можно видеть:
http://askeet/frontend_dev.php/question/what-shall-i-do-tonight-with-my-girlfriend
Для познания системы маршрутизации по
Коментарии:
Опечатка. Блок:
---
После чего замените оригинальный код в обоих шаблонах(listSuccess.php и showSuccess.php) на следующее:
getInterests()) ?>
----
Наверное нужно поменять на это:
---
После чего замените оригинальный код в обоих шаблонах(listSuccess.php и showSuccess.php) на следующее:
$question)) ?>
---- ответить
Кавычки съелись.
Блок:
-----------
После чего замените оригинальный код в обоих шаблонах(listSuccess.php и showSuccess.php) на следующее:
‹div class="interested_mark" id="mark_‹?php echo $question->getId() ?›"›
‹?php echo count($question->getInterests()) ?›
‹/div›
-----------
Надо заменить на:
-----------
После чего замените оригинальный код в обоих шаблонах(listSuccess.php и showSuccess.php) на следующее:
‹div class="interested_block"›
‹?php include_partial('interested_user', array('question' => $question)) ?›
‹/div›
-----------
И еще:
'question/show?id='.$question->getStrippedTitle()
надо поменять на
'question/show?stripped_title='.$question->getStrippedTitle()




