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

symfony advent calendar day seven: model and view manipulation

1.0

지난 줄거리

이제 벌써 6일이나 지났습니다. 몇몇 분들은 아직 우리 어플리케이션이 그다지 유용하지 않다고 생각하실지도 모르겠습니다. 어떤 분들은 어플리케이션이 유용한지 그렇지 않은지를 어플리케이션이 제공하는 페이지 수로 판단하는 경향이 있습니다. 그런 면에서 볼때는 askeet 은 단순히 질문들 목록과 질문들에 대한 답변들을 출력하고, 사용자 세션을 다루는 정도입니다.

우리가 페이지 수를 중요하게 생각하지 않는 이유는 심포니에서 페이지 수를 늘리는 것은 아주 쉬운 일이기 때문입니다. 진짜인가요? 좋습니다. 오늘은 최근 입력된 질문들과 최근 답변된 질문들을 출력하고, 사용자 프로필 페이지, 그리고 각각의 페이지마다 네비게이션 메뉴를 달아서 위 기능들을 사용할 수 있도록 해보겠습니다. 이 작업들은 한 시간도 한 걸리기 때문에, 뷰 설정과 이번 한 주에 어떤 일들을 해왔는지도 함께 살펴보도록 하겠습니다. 준비되셨나요? 시작하겠습니다.

프리펙토링

이제 question/templates/_list.php 에 있는 페이지가 나뉜 목록과 페이지 목록같은 것들을 추가하도록 하겠습니다. 우리는 반복적인 일을 싫어하기 때문에 페이지 관련된 코드를 조각 템플릿에서 사용자 헬퍼 로 만들도록 하겠습니다. 헬퍼는 link_to()format_date() 같이 템플릿에서 사용이 가능한 PHP 함수들입니다.

askeet/apps/frontend/lib/helperGlobalHelper.php 파일을 만들고 다음을 입력하십시오.

<?php
 
function pager_navigation($pager, $uri)
{
  $navigation = '';
 
  if ($pager->haveToPaginate())
  {  
    $uri .= (preg_match('/\?/', $uri) ? '&' : '?').'page=';
 
    // First and previous page
    if ($pager->getPage() != 1)
    {
      $navigation .= link_to(image_tag('first.gif', 'align=absmiddle'), $uri.'1');
      $navigation .= link_to(image_tag('previous.gif', 'align=absmiddle'), $uri.$pager->getPreviousPage()).'&nbsp;';
    }
 
    // Pages one by one
    $links = array();
    foreach ($pager->getLinks() as $page)
    {
      $links[] = link_to_unless($page == $pager->getPage(), $page, $uri.$page);
    }
    $navigation .= join('&nbsp;&nbsp;', $links);
 
    // Next and last page
    if ($pager->getPage() != $pager->getCurrentMaxLink())
    {
      $navigation .= '&nbsp;'.link_to(image_tag('next.gif', 'align=absmiddle'), $uri.$pager->getNextPage());
      $navigation .= link_to(image_tag('last.gif', 'align=absmiddle'), $uri.$pager->getLastPage());
    }
 
  }
 
  return $navigation;
}    

페이지 목록 헬퍼는 이전에 우리가 작성한 코드보다 기능이 추가되었습니다. 어떠한 라우팅 규칙이라도 적용이 가능하고, 첫 페이지일 경우엔 '이전' 링크를 표시하지않으며 마지막 페이지일 경우에도 '다음' 링크를 표시하지 않습니다. 또 우리는 네가지 이미지 (first.gif, previous.gif, 'next.gif그리고last.gif`) 를 추가하여 링크들이 이쁘게 보여지도록 했습니다. askeet SVN 저장소 에서 이들 이미지를 다운 받으십시오. 아마 여러분들은 이후의 여러분 자신의 프로젝트에서 위 헬퍼를 사용하실 수 있으실 겁니다.

question/templates/_list.php 조각에 이 헬퍼를 사용하시기 위해서는 헬퍼 펑션을 다음과 같이 호출해야 합니다.

<?php use_helper('Text', 'Global') ?>
 
<?php foreach($question_pager->getResults() as $question): ?>
  <div class="question">
    <div class="interested_block">
      <?php include_partial('interested_user', array('question' => $question)) ?>
    </div>
 
    <h2><?php echo link_to($question->getTitle(), 'question/show?stripped_title='.$question->getStrippedTitle()) ?></h2>
 
    <div class="question_body">
      <?php echo truncate_text($question->getBody(), 200) ?>
    </div>
  </div>
<?php endforeach; ?>
 
<div id="question_pager">
  <?php echo pager_navigation($question_pager, 'question/list') ?>
</div>

이제 우리는 하나 이상의 헬퍼가 필요하기때문에 use_helper() 대신 use_helper() 가 사용된다는 점을 주의하십시오. Global 이라는 이름은 GlobalHelper.php 라는 이름으로 연결되도록 되어 있습니다.

아래 주소를 호출해서 기능이 정상작동하는지 살표보십시오.

http://askeet/frontend_dev.php/

개선된 페이지 목록

최근 질문 목록

question 모듈에 recent 액션을 생성합니다.

public function executeRecent()
{
  $this->question_pager = QuestionPeer::getRecentPager($this->getRequestParameter('page', 1));
}

간단하죠? 우리는 최근 질문들의 목록을 검색하는 것은 QuestionPeer 의 몫이라고 생각합니다. -Peer 클래스들은 해당 클래스의 객체들을 반환하도록 설계되어 있습니다. (자세한 사항은 온라인 문서들 중 모델 부분 에서 살펴보실 수 있습니다.) 하지만 getRecentPager() 클래스 메소드를 만들어야 합니다. askeet/lib/model/QuestionPeer.php 클래스를 열고 다음을 추가합니다.

public static function getRecentPager($page)
{
  $pager = new sfPropelPager('Question', sfConfig::get('app_pager_homepage_max'));
  $c = new Criteria();
  $c->addDescendingOrderByColumn(self::CREATED_AT);
  $pager->setCriteria($c);
  $pager->setPage($page);
  $pager->setPeerMethod('doSelectJoinUser');
  $pager->init();
 
  return $pager;
}

마지막으로 올라온 질문이 먼저 올라오도록 생성일을 기준으로 한 내림차순 정렬을 합니다. 이 메쏘드는 객체 함수가 아닌 클래스 함수이기 때문에 parent 대신에 self 를 사용합니다 (주 - 이 메쏘드는 객체가 생성된 이후에 ->getRecentPager() 형태로 호출되는것이 아니라 QuestionPeer::getRecentPager() 형태로 호출됩니다. 따라서 $thisparent 등을 사용할 수 없습니다). doSelect() 대신에 doSelectJoinUser 를 사용한 이유는 질문한 사람에 대한 자세한 정보가 필요하기 때문입니다. 기존에는 질문 목록을 먼저 질의하고, 각 질문에 대해서 사용자의 자세한 정보를 다시 질의했었습니다. doSelectJoinUser() 메쏘드는 하나의 질의로 동일한 작업을 수행합니다. 이제 우리가 아래 코드를 수행해도 데이터베이스에 질의하지 않게 됩니다.

$question->getUser();

joinUser 를 사용함으로써 데이터베이스 질의의 수를 1 + 질문 갯수에서 하나로 줄었습니다. 데이터베이스가 고마워할 것만 같습니다.

Propel 문서들 을 보시면 이런 멋진 기능에 대한 자세한 설명이 있습니다.

최근 질문 목록 템플릿은 메인페이지에 올라가는 질문목록과 매우 유사할 것입니다. askeet/apps/frontend/module/question/templates/recentSuccess.php 파일ㅇ르 만들고 아래 내용을 추가하십시오.

<h1>recent questions</h1>
 
<?php include_partial('list', array('question_pager' => $question_pager)) ?>

이제 왜 우리가 5일째 에 질문 목록을 리팩토링하였는지 이해하실 수 있으실 것입니다. 마지막으로 frontend/config/routing.yml 파일에, 4일째 했던 것처럼, recent_question 규칙을 추가합니다.

recent_questions:
  url:   /question/recent/:page
  param: { module: question, action: recent, page: 1 }

잠깐만요. question/_list 조각 파일은 question/list 라우팅 규칙을 이용하여 페이지를 만들기 때문에, 최근 질문 목록에서는 문제가 생길 수 있을 것입니다. 라우팅 규칙을 조각파일에 인자형태로 넘겨주도록 해야 할 것 같습니다. recentSuccess.php 의 마지막 부부능ㄹ 다음과 같이 수정합니다.

<?php include_partial('list', array('question_pager' => $question_pager, 'rule' => 'question/recent')) ?>

list.php 조각파일의 마지막 줄 역시 수정합니다.

<div id="question_pager">
  <?php echo pager_navigation($question_pager, $rule) ?>
</div>

modules/question/templates/listSuccess.php 파일에서 _list 조각파일을 호출하는 부분에 라우팅 규칙 인자를 추가하는 것도 잊지 마시기 바랍니다.

<h1>popular questions</h1>
 
<?php echo include_partial('list', array('question_pager' => $question_pager, 'rule' => 'question/list')) ?>

캐쉬를 삭제하면 (설정파일이 변경되었습니다) 끝입니다.

이제 최근 질문목록을 표시하시려면 아래 URL 로 접속하시면 됩니다.

http://askeet/recent

최근 질문 목록

최근 답변 목록

방금 한 것과 거의 비슷하기 때문에, 빨리 진행하도록 하겠습니다.

  • answer 모듈을 생성합니다.

    $ symfony init-module frontend answer
    
  • recent 액션을 생성합니다.

    public function executeRecent()
    {
      $this->answer_pager = AnswerPeer::getRecentPager($this->getRequestParameter('page', 1));
    }   
  • AnswerPeer 클래스를 확장합니다.

    public static function getRecentPager($page)
    {
      $pager = new sfPropelPager('Answer', sfConfig::get('app_pager_homepage_max'));
      $c = new Criteria();
      $c->addDescendingOrderByColumn(self::CREATED_AT);
      $pager->setCriteria($c);
      $pager->setPage($page);
      $pager->setPeerMethod('doSelectJoinUser');
      $pager->init();
     
      return $pager;
    }
  • recentSuccess.php 템플릿을 만듭니다.

    <?php use_helper('Date', 'Global') ?>
     
    <h1>recent answers</h1>
     
    <div id="answers">
    <?php foreach ($answer_pager->getResults() as $answer): ?>
      <div class="answer">
        <h2><?php echo link_to($answer->getQuestion()->getTitle(), 'question/show?stripped_title='.$answer->getQuestion()->getStrippedTitle()) ?></h2>
        <?php echo count($answer->getRelevancys()) ?> points
        posted by <?php echo link_to($answer->getUser(), 'user/show?id='.$answer->getUser()->getId()) ?> 
        on <?php echo format_date($answer->getCreatedAt(), 'p') ?>
        <div>
          <?php echo $answer->getBody() ?>
        </div>
      </div>
    <?php endforeach ?>
    </div>        
     
    <div id="question_pager">
      <?php echo pager_navigation($answer_pager, 'answer/recent') ?>
    </div>
  • 브라우저에서 테스트합니다.

    http://askeet/answer/recent
    

최근 답변 목록

좀 익숙해지시고 계신가요?

참고: 4일째 에도 자세한 답변을 출력하는데에 같은 코드가 사용되었던걸 눈치채셨는지 모르겠습니다. 이 코드가 최소한 두 군데에 쓰였기때무넹, 이를 _answer.php 조각파일로 옮기겨서 question/showanswer/recent 에 사용하도록 하겠습니다. 자세한 사항은 askeet SVN 저장소 에서 확인하실 수 있습니다.

사용자 프로필

질문에 출력되는 사용자 이름은, 아직 작성되진 않았지만, user/show 액션으로 연결될 것입니다. 이 페이지는 사용자 프로필 페이지로, 사용자에 관한 몇가지 자세한 사항과 함께 사용자가 작성한 최근 질문들과 답변들을 출력할 것입니다.

첫번째로 할 일은 액션을 생성하는 일입니다.

public function executeShow()
{
  $this->subscriber = UserPeer::retrieveByPk($this->getRequestParameter('id', $this->getUser()->getSubscriberId()));
  $this->forward404Unless($this->subscriber);
 
  $this->interests = $this->subscriber->getInterestsJoinQuestion();
  $this->answers   = $this->subscriber->getAnswersJoinQuestion();
  $this->questions = $this->subscriber->getQuestions();
}

->getInterestsJoinQuestion()->getAnswerJoinQuestion() 메쏘드들은 User 클래스의 기본 메쏘드들입니다. askeet/lib/model/om/BaseUser.php 클래스를 보시면 어떻게 동작하는지 확인하실 수 있으실 것입니다.

askeet/apps/frontend/modeules/user/templates/showSuccess.php 템플릿은 쉽게 만드실 수 있으실 것입니다.

<h1><?php echo $subscriber ?>'s profile</h1>
 
<h2>Interests</h2>
 
<ul>
<?php foreach ($interests as $interest): $question = $interest->getQuestion() ?>
  <li><?php echo link_to($question->getTitle(), 'question/show?stripped_title='.$question->getStrippedTitle()) ?></li>
<?php endforeach; ?>
</ul>
 
<h2>Contributions</h2>
 
<ul>
<?php foreach ($answers as $answer): $question = $answer->getQuestion() ?>
  <li>
    <?php echo link_to($question->getTitle(), 'question/show?stripped_title='.$question->getStrippedTitle()) ?><br />
    <?php echo $answer->getBody() ?>
  </li>
<?php endforeach; ?>
</ul>
 
<h2>Questions</h2>
 
<ul>
<?php foreach ($questions as $question): ?>
  <li><?php echo link_to($question->getTitle(), 'question/show?stripped_title='.$question->getStrippedTitle()) ?></li>
<?php endforeach; ?>
</ul>

물론 User 객체의 ->getInterestsJoinQuestion() 이나 ->getAnswersJoinQuestion(), getQuestion() 메 여기에 출력되는 결과의 갯수나 정렬순서를 바꾸고 싶으실 것입니다. 이것들은 askeet/lib/model/User.php 클래스에서 이들 메쏘드들을 재정의 (override) 함으로써 가능합니다. 이를 어떻게 하는지는 여기서 공개하진 않겠지만 오늘의 소스코드에서는 확인가능하실 것입니다.

자 이제 마지막 테스트를 할 시간입니다. 첫번째 사용자가 무엇을 했는지 보겠습니다.

http://askeet/user/show/id/1

사용자 프로필

사용자 프로필은 질문에서도 연결될 수 있습니다. question/templates/showSuccess.php/question/templates/_list.phpquestion_body 문단의 첫부분에 다음 줄을 추가하십시오.

<div>asked by <?php echo link_to($question->getUser(), 'user/show?id='.$question->getUser()->getId()) ?> on <?php echo format_date($question->getCreatedAt(), 'f') ?></div>

_list.php 페이지에 Date 헬퍼 사용을 명시하는 것을 잊지 마십시오.

네비게이션바 달기

글로벌 레이아웃을 수정하여 수평바를 넣도록 하겠습니다. 이 바에는 동적인 내용이 들어갈 것이지만, 각각의 템플릿에 들어가기보다는 레이아웃의 한 자리를 차지하도록 할 것입니다. 덧붙여서 템플릿에 바 관련 코드를 넣는 것은 반복적인 작업입니다. 우리가 그런것 싫어한다는 것을 잘 아시겠죠?

그것이 왜 바가 컴포넌트가 되어야 하는지에 관한 이유입니다. 컴포넌트는 하나의 액션의 결과가 (예를 들면 템플릿 실행의 결과로 HTML 코드가 형성됩니다) 변수 형태로 저장되는 것입니다. 온라인 문서중 뷰 부분 에 컴포넌트가 무엇이고, 조각파일과 컴포넌트가 어떻게 다른지가 설명되어 있습니다.

레이아웃에 컴포넌트 추가하기

글로벌 레이아웃 (askeet/apps/frontend/templates/layout.php) 을 엽니다. 이 부분 기억나시나요?

<div id="content_bar">
  <!-- Nothing for the moment -->
  <div class="verticalalign"></div>
</div>

주석 부분을 아래와 같이 수정합니다.

<?php include_component_slot('sidebar') ?>

이상입니다.

컴포넌트에 어떤 액션이 들어갈지 정하기

우리는 단순한 컴포넌트 보다는 좀 더 강력한 컴포넌트 슬롯을 사용하기로 하였습니다. 컴포넌트 슬롯은 어떠한 액션에서 호출되어지느냐에 따라서 행동이 변경되는 컴포넌트를 말합니다. 뷰 설정파일 (view.yml) 에서 컴포넌트 슬롯이 어떠한 액션과 연결되어져야 하는지를 정의합니다.

default:
  components:
    sidebar:      [sidebar, default]

이 예제에서는, sidebar 라는 컴포넌트 슬롯이 sidebar 모듈의 default 액션과 연결되도록 정의되었습니다.

뷰 설정은 전체 어플리케이션을 위해 정의되거나 (askeet/apps/frontend/config/ 디렉토리) , 하나의 모듈만을 위해 정의될 수 있습니다 (askeet/apps/frontend/modules/mymodule/config 디렉토리). 우리는 전체 어플리케이션을 위해 정의하고, 필요한 경우 재정의하도록 하겠습니다.

askeet/apps/frontend/config/view.yml 파일을 열고 위의 설정 내용을 저장합니다. 온라인 문서들중 관계된 부분 에서 좀 더 자세한 내용을 확인하실 수 있습니다.

sidebar/default 액션과 템플릿 작성하기

먼저 sidebar 모듈을 생성합니다.

$ symfony init-module frontend sidebar

다음으로 default 컴포넌트를 작성해야 합니다. askeet/apps/frontend/modules/sidebar/actions/ 디렉토리의 actions.class.phpcomponents.class.php 로 수정하고, 내용을 아래와 같이 수정합니다.

<?php 
 
class sidebarComponents extends sfComponents
{
  public function executeDefault()
  {
  }
}

컴포넌트 뷰 역시 다른 액션들의 템플릿과 같은 템플릿입니다. 다른점은 이름짓는 방식입니다. 컴포넌트 뷰는 일반 템플릿처럼 Success 등으로 끝나지 않고 조각파일처럼 _ 로 시작합니다. 따라서 askeet/apps/frontend/modules/sidebar/templates/_default.php 파일을 만들고 (그리고 사용되지 않을 indexSuccess.php 파일을 지우십시오) 다음 내용을 입력하시기 바랍니다.

<?php echo link_to('ask a new question', 'question/add') ?>
 
<ul>
  <li><?php echo link_to('popular questions', 'question/list') ?>
  <li><?php echo link_to('latest questions', 'question/recent') ?></li>
  <li><?php echo link_to('latest answers', 'answer/recent') ?></li>
</ul>

만약 여러분의 askeet 웹사이트에서 당장 네비게이션을 사용하려고 한다면 에러를 만나실 것입니다. 그것은 여러분인 설정들이 캐쉬되는 안정버전 (production) 의 웹사이트를 보시고 있기 때문입니다. 안정버전에서는 수정한 view.yml 이라는 설정파일이 아직 효력이 없습니다. 따라서 변경사항을 보시고 싶으시다면 캐쉬를 지우던지 아니면 개발버전을 사용하셔야 합니다.

$ symfony clear-cache

또는

http://askeet/frontend_dev.php/

네비게이션 바가 모든 페이지에서 정상적으로 동작하는 것을 보실 수 있습니다.

사이드바

참고: 이것은 안정버전의 일반적인 효과입니다. 따라서 설정파일 수정이 빈번한 개발도중에는 개발환경을 사용하시고, 설정파일이 바뀐 이후에 안정버전을 통해 웹사이트를 둘러보고 싶으시다면 캐쉬를 지우셔야 합니다.

좀 더 자세한 뷰 설정

apps/config/view.yml 파일을 좀 더 둘러보겠습니다.

default:
  http_metas:
    content-type: text/html; charset=utf-8

  metas:
    title:        symfony project
    robots:       index, follow
    description:  symfony project
    keywords:     symfony, project
    language:     en

  stylesheets:    [main, layout]

  javascripts:    []

  has_layout:     on
  layout:         layout

  components:
    sidebar:      [sidebar, default]

metas 부분은 전체 사이트에 관한 메타태그관련 설정이 모두 들어있습니다. title 키는 브라우저 윈도우에 출력될 제목이 저장되어 있습니다. 타이틀은 검색엔진이 가장 먼저 확인하는 것이므로 굉장히 중요합니다. 따라서 askeet 웹 사이트를 좀 더 잘 나타내는 것으로 변경하도록 하겠습니다.

  metas:
    title:        askeet! ask questions, find answers
    robots:       index, follow
    description:  askeet!, a symfony project built in 24 hours
    keywords:     symfony, project, askeet, php5, question, answer
    language:     en

현재 페이지를 새로고침 하십시오. 만약 변경사항이 보이지 않는다면, 안정버전을 사용하고 계시며 캐쉬를 지우시지 않으신 것입니다.

윈도우 제목

참고: 프로젝트 페이지의 기본 제목 이외에도, 심포니는 robots.txtfavicon.ico 를 웹 디렉토리 (askeet/web/) 에 생성합니다. 이들도 수정하는 것을 잊지 마십시오.

참고: 각 페이지마다 페이지 제목을 바꾸셔야 할지도 모릅니다. 각 모듈마다 view.yml 설정파일을 생성하셔서 하실수도 있지만, 어디까지나 페이지 제목이 정적인 것은 마찬가지 입니다. ->setTitle() 메소드를 이용하시면 동적으로 제목을 설정하실 수 있습니다. 자세한 내용은 온라인 문서중 뷰 설정 부분 을 참고하십시오.

  [php]
  $this->getResponse()->setTitle($title);

지난 줄거리

이제 7일째이니, 잠시 멈춰서 그동안 우리가 무엇을 해 왔는지 살펴보도록 하겠습니다. 현재의 데이터 모델을 포함해서 사용가능한 액션들이 무엇이 있는지 같은 것들에 대해서 기록을 남겨둘 좋은 기회이기도 합니다.

사실, 여러분은 코딩을 하고 있는 동안에 문서를 남겨야 합니다. 예를 들면 각각의 메쏘드에 PHP doc 스타일로 주석을 단다던가 하는 것처럼 말입니다. 심포니 프로젝트는 각 메쏘드나 함수들의 이름이 그것이 하는일에 대한 설명을 제공하기도 합니다. 메쏘드들이 짧고 매우 읽기 편하게 되어있습니다. 많은 경우에, 템플릿들은 foreachif 구문만을 사용하기 때문에 해당 기능을 파악하기가 쉽습니다. 그것이 askeet SVN 저장소 의 코드들이 주석을 많이 포함하고 있지 않은 이유입니다. 덧붙여서 7시간동안 우리가 무엇을 했는지에 대해서 설명을 작성하기도 했구요.

아 이제 ERD 가 어떻게 바뀌었는지 살펴보겠습니다.

ERD

사용 가능한 액션들은 다음과 같습니다.

answer/
  recent
question/
  list
  show
  recent
sidebar/
  default (component)
user/
  show
  login
  logout
  handleErrorLogin

모델들이 가지고 있는 메쏘드들은 아래와 같습니다.

Anwser()
  getRelevancyUpPercent()
  getRelevancyDownPercent()
AnswerPeer::
  getRecentPager()
Interest->
  save()
Question->
  setTitle()
QuestionPeer::
  getQuestionFromTitle()
  getHomepagePager()
  getRecentPager()
Relevancy
  save()
User->
  __toString()
  setPassword()

myUser->
  signIn()
  signOut()
  getSubscriberId()
  getSubscriber()
  getNickName()

그리고 사용자 도구들과 사용자 폼값 확인 클래스는 askeet/apps/frontend/lib/ 디렉토리에 있습니다.

7시간치곤 나쁘지 않죠?

내일 이 시간에

오늘은 어플리케이션 수정이 많았습니다. 그리고 또한 빠르게 진행되었습니다. 이제 사용자-컴퓨터 상호작용간에 AJAX 기능을 추가할 모든 준비가 되었습니다. 내일은 AJAX 를 이용해서 로그인하고 질문에 대한 홍미도를 추가할 수 있게 될 것입니다. 놓치지 마십시오!

오늘의 모든 코드는 askeet SVN 저장소 에서 release_day_7 태그로 다운로드 가능합니다. askeet 메일링 리스트 에서 광속보다 빠른 속도로 질문들을 답변해드리고 있습니다.