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

Ngày 6: Tìm hiểu thêm về Model

Tóm tắt

Hôm qua là một ngày tuyệt vời. Chúng ta đã học cách tạo một URL dễ nhìn và cách dùng symfony framework để tự động làm nhiều việc cho chúng ta.

Hôm nay, chúng ta sẽ chi tiết thêm Jobeet website bằng cách chỉnh sửa code đã có. Trong quá trình đó, bạn sẽ được học thêm về những tính năng chúng tôi đã giới thiệu trong tuần này.

Truy vấn đối tượng Doctrine

Đây là yêu cầu đề ra trong ngày 2:

"Khi người dùng vào trang Jobeet, anh ta sẽ thấy danh sách các công việc mới nhất."

Nhưng hiện tại tất cả các công việc đều được hiển thị:

class jobActions extends sfActions
{
  public function executeIndex(sfWebRequest $request)
  {
    $this->jobeet_job_list = Doctrine::getTable('JobeetJob')
      ->createQuery('a')
      ->execute();
  }
 
  // ...
}

Một active job là một công việc được đưa lên trong 30 ngày gần nhất. Ở đây, chúng ta đã lấy tất cả các công việc trong cơ sở dữ liệu.

Ta cần sửa lại để chỉ lấy các active job:

public function executeIndex(sfWebRequest $request)
{
  $q = Doctrine_Query::create()
    ->from('JobeetJob j')
    ->where('j.created_at > ?', date('Y-m-d h:i:s', time() - 86400 * 30));
 
  $this->jobeet_job_list = $q->execute();
}

Debug cho Doctrine từ câu SQL được sinh ra

Chúng ta không trực tiếp viết câu lệnh SQL, Doctrine sẽ sinh ra câu SQL phù hợp với database engines mà chúng ta đã chọn trong cấu hình. Đôi khi, chúng ta muốn xem câu SQL được tạo ra bởi Doctrine; ví dụ, khi chúng ta muốn debug một câu truy vấn làm việc không như mong muốn. Trong môi trường dev, symfony lưu lại những câu truy vấn này (và nhiều thứ khác) trong thư mục log/. Mỗi file log ứng với một application trong một môi trường nào đó. File chúng ta cần tìm có tên frontend_dev.log:

# log/frontend_dev.log
Dec 04 13:58:33 symfony [info] {sfDoctrineLogger} executeQuery : SELECT 
j.id AS j__id, j.category_id AS j__category_id, j.type AS j__type, 
j.company AS j__company, j.logo AS j__logo, j.url AS j__url, 
j.position AS j__position, j.location AS j__location, 
j.description AS j__description, j.how_to_apply AS j__how_to_apply, 
j.token AS j__token, j.is_public AS j__is_public, 
j.is_activated AS j__is_activated, j.email AS j__email, 
j.expires_at AS j__expires_at, j.created_at AS j__created_at, 
j.updated_at AS j__updated_at FROM jobeet_job j 
WHERE j.created_at > ? (2008-11-08 01:13:35)

Bạn có thể thấy rằng Doctrine đã tạo một mệnh đề where cho cột created_at (WHERE j.created_at > ?).

note

Kí tự ? trong câu truy vấn cho biết rằng Doctrine tạo ra một câu lệnh chuẩn bị. Giá trị thực sự của ? ('2008-11-08 01:13:35' trong ví dụ trên) được dùng trong quá trình thực thi câu truy vấn và được escape bởi database engine. Việc này giúp giảm nguy cơ từ tấn công SQL injection attacks.

Xem từ file log khá tiên lợi, nhưng có một chút bất tiện khi phải chuyển qua lại giữa trình duyệt, IDE, và file log mỗi khi bạn cần kiểm tra sự thay đổi. Nhờ có symfony web debug toolbar, bạn có thể xem tất cả các thông tin cần thiết này từ trình duyệt:

SQL statements in the web debug toolbar

Object Serialization

Mặc dù code trên hoạt đông, nhưng nó vẫn chưa đúng là những gì chúng ta muốn:

"Người dùng có thể kích hoạt lại hoặc tăng thời hạn cho công việc thêm 30 ngày nữa..."

Điều này là không thể thực hiện với code ở trên, do giá trị created_at chỉ được xác định một lần khi tạo job mới.

Hãy nhớ lại database schema, chúng ta có một cột expires_at. Hiện tại giá trị này để trống. Khi một công việc được tạo, nó phải được thiết lập giá trị là ngày cách ngày hiện tại 30 ngày. Để thực hiện một việc gì đó trước khi một đối tượng Doctrine được đưa vào database, bạn cần override phương thức save() :

// lib/model/doctrine/JobeetJob.class.php
class JobeetJob extends BaseJobeetJob
{
  public function save(Doctrine_Connection $conn = null)
  {
    if ($this->isNew() && !$this->getExpiresAt())
    {
      $now = $this->getCreatedAt() ? strtotime($this->getCreatedAt()) : time();
      $this->setExpiresAt(date('Y-m-d h:i:s', $now + 86400 * 30));
    }
 
    return parent::save($conn);
  }
  // ...
}

Phương thức isNew() trả về true nếu đối tượng chưa được ghi vào database, và false trong trường hợp ngược lại.

Sửa lại câu truy vấn trong action sử dụng cột expires_at:

public function executeIndex(sfWebRequest $request)
{
  $q = Doctrine_Query::create()
    ->from('JobeetJob j')
    ->where('j.expires_at > ?', date('Y-m-d h:i:s', time()));
 
  $this->jobs = $q->execute();
}

Chúng ta đã truy vấn để chỉ lấy các jobs còn thời hạn.

More with Fixtures

Refresh lại trang chủ bạn sẽ thấy không có gì thay đổi vì các job trong database chỉ mới đưa lên vài ngày trước. Hãy thêm một công việc đã hết hạn:

# data/fixtures/jobs.yml
JobeetJob:
  # other jobs
 
  expired_job:
    JobeetCategory: programming
    company:        Sensio Labs
    position:       Web Developer
    location:       Paris, France
    description:    Lorem ipsum dolor sit amet, consectetur adipisicing elit.
    how_to_apply:   Send your resume to lorem.ipsum [at] dolor.sit
    is_public:      true
    is_activated:   true
    created_at:     '2005-12-01 00:00:00'
    token:          job_expired
    email:          [email protected]

Ngay cả khi giá trị cột created_at được thêm tự động bởi Doctrine, bạn vẫn có thể override nó. Load lại file fixtures và refresh lại trình duyệt bạn sẽ thấy rằng công việc quá hạn không được hiển thị:

$ php symfony doctrine:data-load

Custom Configuration

Trong phương thức JobeetJob::save(), chúng ta đã để cố định cho số ngày mà một công việc trở thành quá hạn. Ta nên để số ngày này có thể cấu hình được. Symfony framework cung cấp sẵn file cho các cấu hình của application:app.yml. File YAML này có thể chứa bất kì cấu hình nào bạn muốn:

# apps/frontend/config/app.yml
all:
  active_days: 30

Trong application, những thiết lập này có thể được truy cập thông qua global sfConfig class:

sfConfig::get('app_active_days')

Tên của setting được gán app_ đằng trước vì lớp sfConfig có thể truy cập đến tất cả các symfony settings mà chúng ta sẽ thấy ở phần sau.

Hãy sửa lại code với setting vừa tạo:

public function save(Doctrine_Connection $conn = null)
{
  if ($this->isNew() && !$this->getExpiresAt())
  {
    $now = $this->getCreatedAt() ? strtotime($this->getCreatedAt()) : time();
    $this->setExpiresAt(date('Y-m-d h:i:s', $now + 86400 * sfConfig::get('app_active_days')));
  }
 
  return parent::save($conn);
}

File app.yml là nơi lý tưởng để chứa các global settings của application.

Refactoring

Mặc dù code chúng ta đã viết hoạt động tốt, nhưng nó vẫn còn vài vấn đề. Bạn có thể chỉ ra được những vấn đề đó?

Code Doctrine_Query không thuộc action, nó thuộc tầng Model. Do code trả về danh sách các job, nên chúng ta tạo phương thức này trong lớp JobeetJobTable :

// lib/model/doctrine/JobeetJobTable.class.php
class JobeetJobTable extends Doctrine_Table
{
  public function getActiveJobs()
  {
    $q = $this->createQuery('j')
      ->where('j.expires_at > ?', date('Y-m-d h:i:s', time()));
 
    return $q->execute();
  }
}

Bây giờ, ở action ta gọi phương thức vừa tạo để nhận các active job.

public function executeIndex(sfWebRequest $request)
{
  $this->jobeet_job_list = Doctrine::getTable('JobeetJob')->getActiveJobs();
}

Việc refactoring này có vài lợi ích so với code trước:

  • Việc xử lý để nhận các active job bây giờ đã nằm đúng chỗ của nó trong Model
  • Code trong controller trở nên dễ đọc hơn
  • Phương thức getActiveJobs() có thể được dùng lại (ví dụ trong action khác)
  • Model code bây giờ có thể unit test

Hãy sắp xếp các công việc theo expires_at:

public function getActiveJobs()
{
  $q = $this->createQuery('j')
    ->where('j.expires_at > ?', date('Y-m-d h:i:s', time()))
    ->orderBy('j.expires_at DESC');
 
  return $q->execute();
}

Phương thức orderBy thêm mệnh đề ORDER BY vào trong câu SQL (ta cũng có addOrderBy()).

Categories on the Homepage

Từ yêu cầu ở ngày 2:

"Công việc được hiển thị theo category và xếp theo ngày public (công việc mới ở trên)."

Hiện tại, chúng ta chưa nhóm các công việc theo category. Đầu tiên, chúng ta cần lấy tất cả các category có ít nhất một active job.

Thêm phương thức getWithJobs() vào lớp JobeetCategoryTable:

// lib/model/doctrine/JobeetCategoryTable.class.php
class JobeetCategoryTable extends Doctrine_Table
{
  public function getWithJobs()
  {
    $q = $this->createQuery('c')
      ->leftJoin('c.JobeetJob j')
      ->where('j.expires_at > ?', date('Y-m-d h:i:s', time()));
 
    return $q->execute();
  }
}

Sửa lại action index:

// apps/frontend/modules/job/actions/actions.class.php
public function executeIndex(sfWebRequest $request)
{
  $this->categories = Doctrine::getTable('JobeetCategory')->getWithJobs();
}

Trong template, chúng ta cần duyệt qua tất cả các categories và hiển thị các active job:

// apps/frontend/modules/job/indexSuccess.php
<?php use_stylesheet('jobs.css') ?>
 
<div id="jobs">
  <?php foreach ($categories as $category): ?>
    <div class="category_<?php echo Jobeet::slugify($category->getName()) ?>">
      <div class="category">
        <div class="feed">
          <a href="">Feed</a>
        </div>
        <h1><?php echo $category ?></h1>
      </div>
 
      <table class="jobs">
        <?php foreach ($category->getActiveJobs() as $i => $job): ?>
          <tr class="<?php echo fmod($i, 2) ? 'even' : 'odd' ?>">
            <td class="location"><?php echo $job->getLocation() ?></td>
            <td class="position"><?php echo link_to($job->getPosition(), 'job_show_user', $job) ?></td>
            <td class="company"><?php echo $job->getCompany() ?></td>
          </tr>
        <?php endforeach; ?>
      </table>
    </div>
  <?php endforeach; ?>
</div>

note

Để hiển thị tên của category trong template, chúng ta viết echo $category. Điều này có vẻ bất thường? $category là một đối tượng, làm thế nào mà echo lại hiển thị ra được tên của category? Câu trả lời đã có trong ngày 3 khi chúng ta dùng magic method __toString() trong tất cả các model classes.

Để code trên chạy được, chúng ta cần thêm phương thức getActiveJobs() vào lớp JobeetCategory :

// lib/model/doctrine/JobeetCategory.class.php
public function getActiveJobs()
{
  $q = Doctrine_Query::create()
    ->from('JobeetJob j')
    ->where('j.category_id = ?', $this->getId());
 
  return Doctrine::getTable('JobeetJob')->getActiveJobs($q);
}

Khi gọi Doctrine::getTable('JobeetJob')->getActiveJobs(), chúng ta cần cung cấp đối tượng Doctrine_Query hiện tại. Vì thế, trong getActiveJobs() ta cần thêm vào câu truy vấn của chúng ta. Do Doctrine_Query là object, nên việc này rất đơn giản:

// lib/model/doctrine/JobeetJobTable.class.php
public function getActiveJobs(Doctrine_Query $q = null)
{
  if (is_null($q))
  {
    $q = Doctrine_Query::create()
      ->from('JobeetJob j');
  }
 
  $q->andWhere('j.expires_at > ?', date('Y-m-d h:i:s', time()))
    ->addOrderBy('j.expires_at DESC');
 
  return $q->execute();
}

Limit the Results

Vẫn còn một yêu cầu cần thực hiện trong trang chủ:

"Với mỗi category, ta chỉ list 10 công việc đầu tiên và có một link cho phép xem tất cả các công việc của category."

Điều này có thể thực hiện đơn giản bằng cách thêm tham số vào phương thức getActiveJobs():

// lib/model/doctrine/JobeetCategory.class.php
public function getActiveJobs($max = 10)
{
  $q = Doctrine_Query::create()
    ->from('JobeetJob j')
    ->where('j.category_id = ?', $this->getId())
    ->limit($max);
 
  return Doctrine::getTable('JobeetJob')->getActiveJobs($q);
}

Giá trị trong mệnh đề LIMIT có thể cấu hình được. Sửa lại template để lấy số công việc được cấu hình trong app.yml:

<?php foreach ($category->getActiveJobs(sfConfig::get('app_max_jobs_on_homepage')) as $i => $job): ?>

và thêm setting vào file app.yml:

all:
  active_days:          30
  max_jobs_on_homepage: 10

Dynamic Fixtures

Trừ khi bạn sửa giá trị của max_jobs_on_homepage thành 1, nếu không bạn sẽ thấy trang chủ không có gì thay đổi. Chúng ta cần thêm một số job nữa vào file fixtures. Bạn có thể phải copy hàng chục lần những job đã có ... nhưng có một cách tốt hơn.Nên tránh việc lặp lại, kể cả trong file fixture!

File YAML trong symfony có thể chứa code PHP code, nó sẽ được dịch ra trước khi parse. Thêm đoạn code sau vào cuối file fixtures:

JobeetJob:
# Starts at the beginning of the line (no whitespace before)
<?php for ($i = 100; $i <= 130; $i++): ?>
  job_<?php echo $i ?>:
    JobeetCategory: programming
    company:      Company <?php echo $i."\n" ?>
    position:     Web Developer
    location:     Paris, France
    description:  Lorem ipsum dolor sit amet, consectetur adipisicing elit.
    how_to_apply: |
      Send your resume to lorem.ipsum [at] company_<?php echo $i ?>.sit
    is_public:    true
    is_activated: true
    token:        job_<?php echo $i."\n" ?>
    email:        [email protected]
 
<?php endfor; ?>

Hãy chú ý các khoảng lùi đầu dòng.Làm theo những chỉ dẫn đơn giản sau khi bạn thêm code PHP vào file YAML:

  • <?php ?> phải để ở đầu dòng hoặc để trong một giá trị.
  • Nếu <?php ?> kết thúc một dòng, bạn cần output thêm kí tự xuống dòng ("\n").

Secure the Job Page

Khi một công việc hết hạn, nếu bạn biết URL, bạn vẫn có thể truy cập nó. Hãy thử truy cập vào một công việc hết hạn theo URL (thay id bằng id trong database của bạn):

/frontend_dev.php/job/sensio-labs/paris-france/4/web-developer-expired

Thay vì hiển thị công việc, chúng ta cần chuyển người dùng đến trang 404. Làm thế nào chúng ta có thể làm được điều này khi job được nhận tự động bởi route?

# apps/frontend/config/routing.yml
job_show_user:
  url:     /job/:company_slug/:location_slug/:id/:position_slug
  class:   sfDoctrineRoute
  options:
    model: JobeetJob
    type:  object
    method_for_query: retrieveActiveJob
  param:   { module: job, action: show }
  requirements:
    id: \d+

Phương thức retrieveActiveJob nhận đối tượng Doctrine_Query tạo bởi route:

// lib/model/doctrine/JobeetJobTable.class.php
public function retrieveActiveJob(Doctrine_Query $q)
{
  $q->andWhere('a.expires_at > ?', date('Y-m-d h:i:s', time()));
 
  return $q->fetchOne();
}

Bây giờ, nếu bạn truy cập một công việc hết hạn, bạn sẽ được chuyển sang trang 404.

Link to the Category Page

Bây giờ, hãy thêm một link tới trang category ở trang chủ và tạo trang category.

Nhưng hãy đợi chút. Hôm nay là thứ 7, vì thế chúng ta sẽ không làm quá nhiều. Và bạn cũng đủ kiến thức để làm việc này! Hãy tự làm nó và chúng ta sẽ kiểm tra lại vào ngày mai.

Hẹn gặp lại ngày mai

Hãy tự bổ sung những thứ trên cho Jobeet project của mình. Sử dụng API documentationdocumentation khi bạn cần giúp đỡ.

Chúc may mắn!

Bạn có thể checkout mã nguồn ngày hôm nay tại:

http://svn.jobeet.org/tags/release_day_06/