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

第六天:更多模型知识

昨天是很棒的一天。你学习了如何生成友好URL,和如何使用symfony框架自动完成一些 工作。

今天,我们要通过整合分散的代码,增强Jobeet网站功能。这个过程中,你会学到更多本周介绍的特性。

Doctrine查询对象

第2天时我们描述这样的需求:

“当用户浏览Jobeet网站时,她看到一个已激活的招聘信息列表”。

但是现在,没有激活的工作也显示在网页上:

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

An active job is one that was posted less than 30 days ago. The ~Doctrine_Query~::execute() method will make a request to the database. In the code above, we are not specifying any where condition which means that all the records are retrieved from the database.

我们把它改成只获取激活的招聘信息:

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

调试Doctrine生成的SQL语句

即使你不写任何SQL语句,Doctrine仍会处理好各种数据库引擎直接的兼容,并为你选择的数据库引擎 (如MySQL)自动生成优化的SQL语句。有时这些SQL语句会很有用。例如,调试出问题的查询语句。 在开发环境中,这些查询语句(和其它东西一起)生成日志文件,保存在log/目录下。在这个目录下, symfony会为每种环境中每个程序都生成一个单独的日志文件,我们找的是frontend_dev.logdev环境下的frontend程序的日志文件):

# 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)

You can see for yourself that Doctrine has a where clause for the created_at column (WHERE j.created_at > ?).

note

The ? string in the query indicates that Doctrine generates prepared statements. The actual value of ? ('2008-11-08 01:13:35' in the example above) is passed during the execution of the query and properly escaped by the database engine. The use of prepared statements dramatically reduces your exposure to SQL injection attacks.

日志文件真的不错,但麻烦的是每次需要查看的时候,你都要重新打开一次日志文件 (因为用记事本打开的文件是不会自动刷新的)。还好我们有替代方法——WEB调试工具栏, 你可以直接通过浏览器查看全部信息:

SQL statements in the web debug toolbar

对象入库

index动作中的代码虽然可以得到我们想要的结果,但却不是我们应该使用的方式。 因为需求还要求:

”用户可以重新激活招聘信息并扩展30天有效期限…”。

因为created_at字段一旦生成无法改变,所以上面的代码无法实现这个需求,我们 需要重新修改代码。

你应该还记得数据库中有一个expires_at字段。现在这个值为空。当添加一条记录时, 这个字段应该被设置成30天后的日期(过期日期)。

你可以修改save()方法,在Propel对象被存储到数据库前设置expires_at字段的值:

// 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);
  }
 
  // ...
}

The isNew() method returns true when the object has not been serialized yet in the database, and false otherwise.

Now, let's change the action to use the expires_at column instead of the created_at one to select the active jobs:

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->jobeet_job_list = $q->execute();
}

我们限制查询条件,只选择expires_at日期没到的记录。

导入更多数据

Refreshing the Jobeet homepage in your browser won't change anything as the jobs in the database have been posted just a few days ago. Let's change the fixtures to add a job that is already expired:

# 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]

note

Be careful when you copy and paste code in a fixture file to not break the indentation. The expired_job must only have two spaces before it.

As you can see in the job we have added in the fixture file, the created_at column value can be defined even if it is automatically filled by Doctrine. The defined value will override the default one. Reload the fixtures and refresh your browser to ensure that the old job does not show up:

$ php symfony doctrine:data-load

You can also execute the following query to make sure that the expires_at column is automatically filled by the save() method, based on the created_at value:

SELECT `position`, `created_at`, `expires_at` FROM `jobeet_job`;

Custom Configuration

In the JobeetJob::save() method, we have hardcoded the number of days for the job to expire. It would have been better to make the 30 days configurable. The symfony framework provides a built-in configuration file for application specific settings, the app.yml file. This YAML file can contain any setting you want:

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

In the application, these settings are available through the global sfConfig class:

sfConfig::get('app_active_days')

The setting has been prefixed by app_ because the sfConfig class also provides access to symfony settings as we will see later on.

Let's update the code to take this new setting into account:

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

The app.yml configuration file is a great way to centralize global settings for your application.

Last, if you need project-wide settings, just create a new app.yml file in the config folder at the root of your symfony project.

重构(Refactoring)

虽然现在代码工作正常,但并不是非常好。你能看出问题吗?

The Doctrine_Query code does not belong to the action (the Controller layer), it belongs to the Model layer. In the MVC model, the Model defines all the business logic, and the Controller only calls the Model to retrieve data from it. As the code returns a collection of jobs, let's move the code to the JobeetJobTable class and create a getActiveJobs() method:

// 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();
  }
}

现在动作可以用这个方法调用激活的招聘信息了。

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

This refactoring has several benefits over the previous code:

重构(refactoring)后的代码有很多优点:

  • 将获取招聘信息的逻辑放入Model层
  • 控制层的代码更加易读
  • getActiveJobs()方法可以被其他动作重用
  • 模型代码现在可以进行单元测试了

现在用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();
}

The orderBy methods sets the ORDER BY clause to the generated SQL (addOrderBy() also exists).

首页中的信息分类

第2天提出的需求:

”招聘信息按类别显示,每个分类中的信息按发布日期排序(最新的放在第一个)”。

直到现在,我们还没有考虑信息分类。按要求,首页必须按分类显示招聘信息, 我们需要让每个分类至少包含一个已激活的招聘信息。

打开JobeetCategoryTable类添加getWithJobs()方法:

// 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();
  }
}

相应地修改index动作:

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

我们在模板中通过迭代,显示所以分类及该分类下已激活的招聘信息:

// apps/frontend/modules/job/templates/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

我们使用echo $category显示分类名,听起来很奇怪?$category是一个对象,如何象 字符串一样显示分类名?答案就是我们第3天给所有model类定义魔术方法__toString()。 这个方法返回设置好的字符串。当需要将对象作为字符串使用的时候,就会调用这个方法。

为了让上面的代码工作,我们在JobeetCategory类中添加getActiveJobs()方法:

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

The JobeetCategory::getActiveJobs() method uses the Doctrine::getTable('JobeetJob')->getActiveJobs() method to retrieve the active jobs for the given category.

When calling the Doctrine::getTable('JobeetJob')->getActiveJobs(), we want to restrict the condition even more by providing a category. Instead of passing the category object, we have decided to pass a Doctrine_Query object as this is the best way to encapsulate a generic condition.

The getActiveJobs() needs to merge this Doctrine_Query object with its own query. As the Doctrine_Query is an object, this is quite simple:

// 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();
}

限制结果

首页信息列表还有一个要求:

“首页每个分类只能显示前10条信息,进入分类页面允许显示改分类的所有工作”。

这太容易了,修改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);
}

现在,LIMIT子句使用的了硬编码(hard-coded),我们将最大值变量添加到app.yml中, 模板从配置文件中读取该值:

<!-- apps/frontend/modules/job/templates/indexSuccess.php -->
<?php foreach ($category->getActiveJobs(sfConfig::get('app_max_jobs_on_homepage')) as $i => $job): ?>

app.yml中添加新设置:

all:
  active_days:          30
  max_jobs_on_homepage: 10

Homepage sorted by category

动态导入数据

现在,除非你将max_jobs_on_homepage设置为1,否则你不会看到修改后代码的效果 (job的数量不够)。我们需要导入很多招聘信息到数据库中。你可以将已有的job复制 粘贴十几二十遍,不过即使是在数据导入文件(fixture)中代码重复仍是很糟糕的事情。 我们有更好的方法。

symfony的YAML文件中可以包含PHP代码,它们将在YAML代码被分析之前执行。打开 jobs.yml,添加下列代码到尾部:

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

小心,如果你的缩进没有对齐,YAML会出错,记住用空格代替Tab。当你添加PHP代码到YAML文件时, 记住下面两点:

  • 语句必须占一行,或嵌入到一个值中
  • 如果 在一行的结尾,需要输出一个新行(”\n”)

You can now reload the fixtures with the doctrine:data-load task and see if only 10 jobs are displayed on the homepage for the Programming category. In the following screenshot, we have changed the maximum number of jobs to five to make the image smaller:

Pagination

保护信息页面

招聘信息过期后,便不能通过网页访问。即使你知道URL,也不会看到相关的内容。 试试这个URL(id换为实际值)- SELECT id, token FROM jobeet_job WHERE expires_at < NOW()):

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

我们仍能看到信息页面,但我们希望显示的是404页面。我们可不可以在招聘信息被 自动取回的同时,通过路由实现这个功能呢?

# 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+
    sf_method: [GET]

note

The ~method_for_query~ setting was not working prior to the 1.2.2 release.

The retrieveActiveJob method will receive the Doctrine_Query object built by the route:

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

现在,如果你再尝试浏览过期信息,会被带到404页面。

404 for expired job

链接到分类页面

现在,给首页的分类页面添加链接,并创建分类页面。

不过今天的时间不够了,还是等到明天吧,不过你可以自己尝试一下。

明天见

在做Jobeet项目的同时,阅读一下在线API文档 和其它文档 会对你有很大帮助。

Good luck!