Caution: You are browsing the legacy symfony 1.x part of this website.

symfony advent calendar day four: refactoring

지난 줄거리

지난 시간 에는 홈페이지에 질문들 목록을 보여주기 위해, MVC 구조의 모든 구성요소들을 살펴보고, 조작하여 보았습니다. 어플리케이션이 이제 좀 볼만 해지긴 했지만, 아직도 컨텐츠가 부족합니다.

오늘의 목표는 질문에 대한 대답들 목록을 보여주고, 세부 질문에 대한 URL 을 좀 더 깔끔하게 만드는 것입니다. 또한 사용자 클래스를 하나 추가할 것이고, 몇몇 코드뭉치들을 분산시킬 것입니다. 이를 통해 여러분들은 템플릿, 모델, 라우팅 정책, 그리고 리펙토링의 컨셉을 이해하실 수 있으실겁니다. 작성한 코드를 수정하기에는 아직 이르지 않느냐고 의아해하실 수도 있습니다만, 오늘 튜토리얼을 끝까지 읽어보신다면 아마 생각이 바뀌실 것입니다.

오늘은 심포니의 MVC 구현 이나 애자일 개발 같은 개념이 쓰입니다. 이에 대해 아직 익숙하지 않으신 분들은 링크된 문서를 읽어보시는게 좋을 것입니다.

질문에 대한 대답들을 표시하기

먼저 어제 했던 Question CRUD 템플릿을 변형하는 것을 계속하도록 하겠습니다.

question/show 액션은 전달 받은 id 값을 바탕으로 해당 질문에 대한 자세한 내용을 보여주도록 만들어져있습니다. 다음 주소를 호출해 보시기 바랍니다.

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

자세한 질문 내역

어플리케이션을 가지고 놀아보셨다면 이미 show 페이지를 보셨을 수도 있을 것입니다. 우리는 이곳에 질문에 대한 대답들을 표시하고자 합니다.

액션 둘러보기

시작하기 전에 askeet/apps/frontend/modules/question/actions/actions.class.php 파일의 show 액션을 살펴보겠습니다.

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

만약 Propel 을 다루실 줄 아신다면, 위 코드가 Question 테이블에 대한 요청을 하고 있다는 것을 알아차리실 수 있을 것입니다. 위 코드는 HTTP 리퀘스트로 들어온 id 값을 프라이머리 키로 사용하여 해당 값을 id 로 갖는 질문 하나를 받아오도록 하고 있습니다. 위의 예제에서 id 값은 1 입니다. 따라서 QuestionPeer 클래스의 ->retrieveByPk() 메쏘드는 1 을 프라이머리 키로 갖는 Question 클래스의 객체를 반환 할 것입니다. 만약 Propel 을 처음 보신다면, Propel 웹사이트의 몇몇 문서 들을 읽어보신 후 계속 읽어주시기 바랍니다.

요청에 대한 결과는 $question 변수를 통해서 showSuccess.php 템플릿으로 전달됩니다.

sfAction 객체의 ->getRequestParameter('id') 메쏘드는 GET 이나 POST 방식과 관계없이 id 라는 요청값을 반환합니다. 예를 들어 다음과 같은 요청을 했다고 한다면,

http://askeet/frontend_dev.php/question/show/id/1/myparam/myvalue

...show 액션은 myvalue 라는 값을 $this->getRequestParameter('myparam') 를 통해서 얻을 수 있을 것입니다.

참고: forward404Unless() 메소드는, 만약 해당 질문이 없을 경우 자동으로 404 에러 페이지를 호출합니다. 에러나 예외 상황에 대해 치밀하게 계획해두는 것이 항상 안전하고, 심포니는 이를 간단히 할 수 있도록 하는 여러 메쏘드들을 제공합니다.

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) 이 여기에도 쓰이고 있습니다. 해당 문단 (div) 은 해당 질문에 대한 흥미도를 보여줄 것입니다. 그 다음부터는, link_to 가 제목에 쓰이지 않았다는 것을 제외하고는, 어제 작성했던 HTML 과 상당히 유사합니다. 위 코드는 질문에 관한 정보를 보여주기 위해서 기존 코드를 다시 사용한 것 뿐입니다.

위 코드에서 새로운 부분은 answers 문단 (div) 입니다. 해당 문단은, Propel 메쏘드은 $question->getAnswers() 를 사용해서, 해당 질문에 대한 모든 대답들을 보여주고 각각에 대한 관계도 (relevancy) 와 작성자, 그리고 작성된 날자를 본문과 함께 표시합니다..

format_date() 는 템플릿 헬퍼중의 하나로, 사용전에 사용하겠다는 명시가 필요합니다. 이 헬퍼 및 다른 헬퍼들의 사용법은 온라인 문서들중 국제화 헬퍼 부분 에서 살펴보실 수 있습니다. (헬퍼들은 지루한 작업을 빠르게 처리하도록 도울 뿐 아니라 멋진 외관까지 제공합니다.)

참고: Propel 은 해당 테이블과 연결된 다른 테이블의 자료를 호출하는 메소드에 대해서는 's' 를 자동으로 붙이게 되어있습니다. ->getRelevancys() 메소드의 이름이 좀 이상하더라도 이해해 주시기 바랍니다.

테스트 데이터 추가하기

이제 answerrelevancy 테이블에 테스트 데이터를 추가할 차례입니다. 아래 내용을 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

첫번째 질문에 들어가셔서 자료가 제대로 입력되었는지 확인해봅시다.

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

참고: XX 를 여러분의 첫번째 질문의 id 로 바꾸십시오.

질문에 대한 대답들

대답들이 질문들 아래 표시되고 있습니다. 멋지지 않나요?

모델 수정하기, 첫번째

어플리케이션중 어디에선가는 사용자의 전체이름 (주 - 성과 이름을 합쳐 fullname 이라고 합니다.) 을 필요로 할때가 있을 것입니다. 사용자의 전체이름을 User 객체의 속성으로 만들 수도 있지만, 그것보다는 객체의 전체이름을 반환하는 메쏘드를 만드는것이 좀 더 효과적일 것입니다. askeet/lib/model/User.php 를 열고 다음 메쏘드를 추가하십시오.

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

왜 메쏘드 이름으로 getFullName() 같은 의미있는 이름이 아닌 __toString() 이 사용되었을까요? __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() ?> 

깔끔하지 않습니까?

반복하지 마세요

애자일 개발방법론의 원칙중 하나는 코드 중복을 피하라는 것입니다. "반복하지 마십시오" (D.R.Y., Don't Repeat Yourself). 중복된 코드는 코드를 길게 만들어서, 잘 캡슐화된 코드보다, 리뷰, 수정, 테스트, 그리고 검증과정을 복잡하게 만듭니다. 또한 유지보수에 있어서도 매우 복잡합니다. 오늘의 튜토리얼을 잘 살펴보셨다면 코드 중복을 발견하셨을 겁니다. 어제 작성된 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>

조각 (fragment) 파일들은 외부의 객체들에 대한 접근권한이 없으므로, $question 변수를 include_partial 호출시 넘겨주어야 합니다. 파일 이름 맨앞에 붙는 _template/ 디렉토리안의 조각파일들과 템플릿을 쉽게 구분하도록 도와주는 구분자입니다. 조각파일에 대해서 좀 더 알고 싶으시다면, 온라인 문서중 뷰 부분 을 참고하시기 바랍니다.

모델 수정하기, 두번째

$question->getInterests() 호출은 데이터베이스를 조회하여, Interest 객체의 배열을 반환합니다. 이 조회는 상당히 부하가 걸리는 작업입니다. 상세질문 페이지야 호출되는 경우도 많지 않을 것이지만, listSuccess.php 페이지에서는 각각의 질문 목록에 대해서 흥미도를 조회하도록 하고 있습니다. 때문에 이를 최적화시키는 것이 좋을 것입니다.

이를 최적화하기 위한 한가지 방안은, Question 테이블에 interested_users 컬럼을 추가하고, 질문에 대한 흥미도가 생성될때마다 이를 업데이트 하는 것입니다.

주의: 현재로써는 흥미도 자료를 추가할 수 있는 방법이 없기때문에, 모델을 수정해도 우리가 맞게 했는지 확인할 수가 없습니다. 테스트할 방안이 없을때에는 모델 수정을 하지 않는것이 가장 좋습니다.

다행히도 이번 작업에 대해서는 테스트를 할 수 있는 방안이 있으며, 이번 장에서 차차 살펴보도록 하겠습니다.

User 객체 모델에 필드 추가하기

askeet/config/schema.xml 파일을 열고 ask_question 테이블에 다음 필드를 용감하게 추가하십시오.

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

그리고 모델을 다시 생성합니다.

$ symfony propel-build-model

모델이 새로 만들어졌습니다. 우리가 모델들을 수정하긴 했지만, 걱정하실 필요는 없습니다. 우리가 수정한 askeet/lib/model/User.php 디렉토리의 User 클래스는, Propel 이 생성한 askeet/lib/model/om/ 디렉토리의 BaseUser.php 클래스를 상속받았기 때문입니다. 이는 우리가 askeet/lib/model/om/ 디렉토리의 모델들을 수정하지 않아야 하는 이유가 됩니다. 해당 디렉토리의 파일들은 build-model 이 호출될때 새롭게 만들어질 것입니다. 심포니는 웹프로젝트 초기의 반복적인 모델 변경을 쉽게 만들어줍니다.

이제 실제 데이터베이스를 고칠 차례입니다. SQL 문을 쓰시는 대신에, SQL 스키마를 만들고 테스트 데이터를 다시 저장하면 됩니다. (주 - symfony propel-insert-sql 을 사용하면, schema.sql 문을 심포니가 입력해줍니다.)

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

참고: TIMTOWTDI: 세상에는 한가지 길만 있는게 아닙니다 (There is more than one way to do it). 데이터베이스를 새로 만들지 않고, 직접 컬럼만 추가하는 방법도 있습니다.

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

Interest() 객체의 save() 메쏘드 수정하기

질문에 대한 흥미도가 추가될때마다 우리의 새로운 필드의 값이 업데이트되어야 합니다. 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() 메쏘드를 호출해야 합니다만, 그렇게 해서는 무한루프에 빠지기 때문에, 부모 클래스의 메쏘드인 parent::save() 를 호출합니다.

트랜잭션으로 업데이트 요청을 확실히 하기

Question 객체를 업데이트하는 과정과 Interest 객체를 업데이트하는 과정 사이에, 데이터베이스 접속이 끊어진다면 어떻게 될까요? 자료의 무결성이 깨질것입니다. 이것은 은행이 돈을 송금하는 과정에서 먼저 송금할 계좌에서 잔액을 줄이고, 송금될 계자의 잔액을 늘이는 것과 같은 과정입니다.

만약 어떤 두개의 요청이 상호간에 의존적이라면, 그들을 트랜잭션 으로 보호해야 합니다. 트랜잭션이란 어떤 요청들이 있을때 이들 모두가 성공하던지, 아니면 모두가 실패하게 만들어주는 기능을 합니다. 만약 요청들 사이에서 어떤 문제가 생겼다면, 기존에 이뤄졌던 요청들은 모두 취소가 되고, 이전 값으로 되돌려집니다.

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

먼저, 메쏘드는 Creole 을 통해 데이터베이스 연결을 엽니다. ->begin()->commmit() 사이에서 트랜잭션이 활성화 됩니다. 만약 그 사이에서 뭔가 예외상황이 발생한다면, 데이터베이스는 이전 상태로 값들을 되돌려 놓을 것입니다.

템플릿 수정

Question 객체의 ->getInterestedUsers() 메쏘드가 잘 동작하는지 확인하기 위해 _interested_user.php 조각파일을 수정합니다.

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

위 코드를 아래와 같이 수정합니다.

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

참고: 중복된 코드를 남기지 않고 조각파일을 미리 만들었기때문에, 우리는 한 번만 수정을 해주어도 됩니다. 만약 그렇게 하지 않았다면 listSuccess.phpshowSuccess.php, 두 개의 파일을 수정했어야 할 것입니다. 우리같은 게으른 친구들에겐 엄청난 일이지요.

요청 갯수와 실행시간 측면에서 향상이 있을 것입니다. 웹 디버그 툴바의 데이터베이스 아이콘 뒤의 숫자로 요청 갯수를 확인 하실 수 있습니다. 데이터베이스 아이콘을 클릭하시면 실제 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

숫자들이 정상적으로 나옵니다. 리펙토링이 제대로 되었습니다.

answer 모델 수정

방금 Question 모델에 count($question->getInterests()) 를 적용한 것처럼 대답들 목록에 대해서도 count($answer->getRelevancys()) 를 적용해 줄 수 있습니다. 차이점이라면 흥미도는 요청이 생겼을때 더하기만 하면 되는 반면, 관계도는 더하기도 하고 뺄수도 있어야 한다는 것입니다. 모델을 어떻게 수정하면 되는지 보셨으니, 이번에는 빠르게 진행하도록 하겠습니다. 아래에 보시면 변경사항들이 있습니다. 손으로 직접 입력하시지 않으셔도 오늘 사용된 소스들이 askeet SVN 저장소 에 있으므로 이를 이용하셔도 좋을 것입니다.

  • schema.xml 파일의 answer 테이블에 아래 컬럼을 추가합니다.

    <column name="relevancy_up" type="integer" default="0" />
    <column name="relevancy_down" type="integer" default="0" />
  • 모델을 다시 만들고, 데이터베이스를 변경합니다. (주 - mysql -u youruser -p askeet < data/sql/lib.model.schema.sql 대신에 symfony propel-insert-sql 을 사용하실 수도 있습니다.)

    $ 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>
  • 테스트데이터를 추가합니다.

    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

심포니의 기본 라우팅 시스템은 위의 요청을 아래와 같이 해석합니다.

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 에 사용될 제목을 따로 만들기

먼저, URL 에 사용될, 빈칸이 _ 로 치환된 제목을 만들어줘야 합니다. 물론 세상에는 한가지 방법만 있지 않습니다만, 우리는 Question 테이블에 하나의 새로운 제목에 대한 컬럼을 추가할 것입니다. schema.xml 파일의 Question 테이블에 다음을 추가하십시오.

<column name="stripped_title" type="varchar" size="255" />
<unique name="unique_stripped_title">
  <unique-column name="stripped_title" />
</unique>

(주 - schema.yml 파일을 사용하시는 경우, stripped_title: { type: varchar(255), index: unique } 을 입력하시면 됩니다.) 그리고 모델을 새로 구성하고, 데이터베이스를 업데이트합니다.

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

이제 Question 객체에 setTitle() 메쏘드를 재정의하여 객체가 저장될때 치환된 제목도 동시에 같이 저장되도록 하겠습니다.

사용자 클래스

사실 빈칸이 치환된 제목의 경우, 질문에 관한 URL 을 구성하는데에만 필요한 것이 아니라 대답들에 대해서도 필요할 수도 있습니다. 따라서 Question 모델에만 국한해서 메쏘드를 만들기보다는, 제목을 치환하는 사용자 클래스를 만들도록 하겠습니다.

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 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?stripped_title='.$question->getStrippedTitle()) ?></h2>

이제 question 모듈의 action.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

라우팅 규칙 바꾸기

하지만 이 URL 형식은, 우리가 정확히 원하는 형식은 아닙니다. 이제 라우팅 규칙을 바꿀 차례입니다. askeet/apps/frontend/config/ 디렉토리의 routing.yml 설정파일을 열고, 맨 위에 다음을 추가합니다.

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

url 부분에서, question 은 URL 바로 삽입되는 문자열이고, : 가 붙은 stripped_title 항목은 치환된 제목으로 바뀔 것입니다. : 와 문자가 만나면, 심포니는 이를 라우팅과 관련된 패턴 으로 인식하고 해당 문자열을 param 에 명시된 액션으로 전달할 것입니다. 또한 템플릿에서 사용되는 link_to() 헬퍼 역시, 라우팅 규칙이 적용되므로 링크 URL 역시, 해당 형식으로 바뀔 것입니다.

이제 마지막 테스트를 하겠습니다. 홈페이지를 여시고, 첫번째 질문의 제목을 클릭하십시오. 첫번째 질문의 상세 페이지가 보여지고 (링크가 깨지지 않았을 경우), 주소창의 주소가 아래와 같은 형식인지 확인하십시오.

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

라우팅 기능에 대한 좀 더 자세한 내용은 온라인 문서중 라우팅 정책 부분에서 찾으실 수 있습니다.

내일 이 시간에

오늘은 웹사이트에 많은 기능들을 추가하진 않았지만, 템플릿 코딩을 하는 방법, 모델을 수정하는 방법, 그리고 코드가 리펙토링 되는 것을 보셨습니다.

중복된 코드들을 조각파일이나 사용자 클래스로 만들어 코드 이곳저곳에서 재사용한다거나, 액션이나 템플릿등에서 하나의 모델에만 의존적인 코드들을 해당 모델 아래로 이동시키는 일들은 심포니 프로젝트가 진행되는동안 계속 일어나는 일들입니다. 이런 일들이 코드들을 여러 디렉토리들로 분산시키지만, 이 방식이 유지보수 및 기능추가에 더 효과적입니다. 덧붙여 심포니는 파일들이 각각의 속성에 맞게 (헬퍼, 모델, 템플릿, 액션, 사용자 클래스, 등) 각각의 디렉토리에 모여 있으므로, 파일을 찾는 것도 어렵지 않습니다.

오늘 한 리펙토링 작업은 앞으로의 개발을 쉽게 만들 것입니다. 또한 우리가 기능적인 측면에서 견고한 계획없이 프로젝트를 진행하고 있기 때문에, 엉망진창으로 프로젝트를 끝내지 않으려면, 앞으로도 리펙토링은 계속 해야 할 것입니다.

내일은 무엇을 할것인지 궁금하신가요? 내일은 폼을 만들기 시작할 것이고, 폼을 통해 정보가 어떻게 전달되는지 살펴볼 것입니다. 또한 질문들 목록도 페이지를 나눠 출력하도록 할 것입니다. 그동안 오늘의 코드를 SVN 저장소에서 둘러보시기 바랍니다.

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

질문은 askeet 메일링 리스트포럼 을 통해서 받고 있습니다.