Caution: You are browsing the legacy symfony 1.x part of this website.
Cover of the book Symfony 5: The Fast Track

Symfony 5: The Fast Track is the best book to learn modern Symfony development, from zero to production. +300 pages in full color showing how to combine Symfony with Docker, APIs, queues & async tasks, Webpack, Single-Page Applications, etc.

Buy printed version

6日目: モデルの詳細

昨日はすばらしい日でした。 プリティURLの作り方とたくさんの事を自動で行うsymfonyフレームワークの使い方を学習しました。

今日は、あちらこちらのコードを調整してJobeetのWebサイトを強化します。 作業の中で、このチュートリアルの最初の5日の間に紹介したすべての機能を詳しく学びます。

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

アクティブな求人は30日前以内に投稿されたものです。 ~Doctrine_Query~::execute()メソッドはデータベースへのリクエストを行います。 上記のコードにおいて、条件を指定しないのですべてのレコードがデータベースから読み取られることを意味します。

利用可能な求人情報だけが選択されるように変更してみましょう:

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は異なるデータベース間の違いを管理して、3日目に選んだデータベースエンジンに最適化されたSQLステートメントを生成します。 しかし時に、Doctrineが生成したSQLステートメントを見ることは非常に役に立ちます。 たとえば、期待通りに動かないCriteriaをデバッグするときなどです。 dev環境ではlog/ディレクトリ内にこれらのクエリのログをとります。 アプリケーションと環境の全ての組み合わせのログファイルがあります。 探しているのは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)

Doctrineがcreated_at column(WHERE j.created_at > ?)に対してWHERE句を用意していることがわかります。

note

クエリの文字列?はDoctrineがプリペアードステートメントを生成することを示します。 ?の実際の値(上記の例では'2008-11-08 01:13:35')はクエリ実行の間に渡されデータベースエンジンによって適切にエスケープされます。 プリペアードステートメントの利用によってSQLインジェクション攻撃への露出が劇的に減ります。

このことはよいことです。 しかし変更点のテストのため毎回ブラウザー、IDE、ログファイルの間を行ったりきたりするのは少し面倒です。 symfonyのWebデバッグツールバーのおかげで、必要な全ての情報がブラウザー上で快適に利用できます:

WebデバッグツールバーでのSQLステートメント

オブジェクトのシリアライズ

上記のコードが動作するとしても、2日目からの要件が考慮されていないので完璧とはほど遠い状態です:

"ユーザーは求人広告の有効期間を30日延長するために戻ることができる"

しかし上記のコードはcreated_atの値に依存するのと、このカラムは作成時の日付を保存するので上記の要件を満たすことができません。

しかし3日目に記述したデータベーススキーマを覚えているのであれば、expires_atカラムも定義しました。 フィクスチャデータに設定されていないので現在このデータの値は常に空です。

しかしjobが作成されるとき、これを現在の日付の後の30日に自動的に設定できます。

Doctrineオブジェクトがデータベースにシリアライズされる前に自動的に何かを行う必要があるときに、モデルクラスの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);
  }
 
  // ...
}

isNew()メソッドはオブジェクトがまだデータベースにシリアライズされていないときはtrueを返し、それ以外はfalseを返します。

では、アクティブな求人情報を選択するためにcreated_atカラムの代わりに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->jobeet_job_list = $q->execute();
}

将来、expires_atの日付で選択した仕事のみを対象とするためにクエリを制限します。

フィクスチャのさらなる使い方

Jobeetのホームページをブラウザーでリフレッシュしても数日前に投稿されたデータベース内の求人内容は変更されません。 フィクスチャにすでに期限切れした求人情報を追加してみましょう:

# 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

インデントを壊さないようにするためにフィクスチャファイルにコードをコピー&ペーストする際には気をつけてください。>expired_jobの前には2つのスペースだけしか置かなければなりません。

フィクスチャファイルに追加した求人を見ればわかるように、Doctrineによって自動的に満たされる場合でもcreated_atカラムの値を定義できます。 定義された値はデフォルト値を上書きします。 フィクスチャをリロードして古い求人が表示されないことを確認するためにブラウザーをリフレッシュします:

$ php symfony doctrine:data-load

created_atの値に基づき、exprires_atカラムがsave()メソッドによって自動的に満たされることを確認するために次のクエリを実行することもできます:

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

カスタムコンフィギュレーション

JobeetJob::save()メソッドにおいて、期限切れしている求人に関して日数を決め打ちしました。 30日を設定可能にするほうがよいです。 symfonyフレームワークはアプリケーション固有の設定用に組み込みの設定ファイル、app.ymlファイルを提供します。 このYAMLファイルは任意の設定を格納できます:

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

アプリケーションではこれらの設定はグローバルなsfConfigクラスを通して利用可能です:

sfConfig::get('app_active_days')

後から見るようにsfConfigクラスもsymfonyの設定へアクセス方法を提供するので、app_ がプレフィックスとしてつけられます。

新しい設定をコードに適用してみましょう:

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

app.ymlファイルはアプリケーションのグローバル設定を集めるよい方法です。 最後に、プロジェクト規模での設定が必要な場合、symfonyプロジェクトルートのconfigフォルダーで新しいapp.ymlファイルを作るだけです。

リファクタリング

書いたコードが動作してはいますが、まだ完全に正しいものではありません。問題はどこにあるのでしょうか?

Doctrine_Queryのコードはアクションに所属せず(Controllerレイヤー)、Modelレイヤーに所属します。 MVCモデルにおいて、Modelはすべてのビジネスロジックを定義し、Controllerはデータを読み取るモデルのみを呼び出します。 コードは求人のコレクションを返すので、コードをJobeetJobTableクラスに移動させてgetActiveJobs()メソッドを作りましょう:

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

このリファクタリングには以前のコードよりいくつかの利点があります。:

  • 利用可能な仕事を取得するロジックはModelにある
  • Controllerのコードはより読みやすくなる
  • getActionJobs()メソッドは再利用できる(たとえば別のアクションで使う)
  • Modelコードでユニットテストができる

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

orderByメソッドは生成されたSQLにORDER BY句を設定します(addOrderBy()も存在します)。

ホームページでのカテゴリ表示

2日目の必要要件を下記に示します:

"求人はカテゴリでまずソートされ、その次に投稿日時でソートされる(新しいものが最初に)"

これまで、求人のカテゴリについては考慮していませんでした。 必要要件からはホームページでカテゴリに基づいて表示しなければなりません。 まず最初に少なくとも1つの利用可能な求人から全てのカテゴリを取得することが必要です。

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はオブジェクトなのに、どうやってechoはカテゴリ名を表示するのでしょうか? 答えは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);
}

JobeetCategory::getActiveJobs()メソッドは渡されたカテゴリに対してアクティブな求人を読み取るためにDoctrine::getTable('JobeetJob')->getActiveJobs()メソッドを使います。

Doctrine::getTable('JobeetJob')->getActiveJobs()を呼び出すとき、カテゴリを提供することでさらに条件を制限することを考えます。 カテゴリオブジェクトを渡すことで、Doctrine_Queryオブジェクトを渡すことに決めました。 これが一般的な条件をカプセル化するための最良の方法だからです。

getActiveJobs()は独自クエリでこのDoctrine_Queryオブジェクトをマージする必要があります。 Doctrine_Queryはオブジェクトなので、これらはとてもシンプルです:

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

結果の制限

ホームページの仕事リストの中に実装すべき1つの要件がまだあります:

"各カテゴリごとに最新の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句はModelの中で決め打ちされていますが、この値を設定可能にすることはよいことです。 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

カテゴリでソートされるホームページ

動的なフィクスチャ

max_jobs_on_homepageに1より低い値がセットされなければ、違いはわからないでしょう。 フィクスチャを使ってたくさんの求人を追加することが必要です。 そこで、手作業で既存の求人を10〜12回ほどコピペできますがもっとよい方法があります。 たとえフィクスチャファイルであっても重複することは悪です。

symfonyのYAMLファイルはファイルを解析する直前に評価されるPHPコードを格納できます。

jobs.ymlフィクスチャを編集して末端に次のコードを追加します:

# 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パーサーはインデントが崩れていると動作しない点に注意してください。 YAMLファイルにPHPコードを追加する場合の簡単なティップスを下記に示すので覚えておいてください:

  • <?php ?>文は常に行の先頭か値に組み込まれている必要がある
  • もし<?php ?>文が行末にあるなら、改行するために("\n")が必要となる

doctrine:data-loadタスクでフィクスチャファイルをリロードしてホームページでProgrammingカテゴリに関して10件の求人だけが表示されるか見ます。 次のスクリーンショットにおいて、画像を小さくするために求人の最大数を5件に変更しました:

ページ分割

求人ページの保護

求人が期限切れになるとき、URLを知っているとしても、もはやアクセスできないようにすべきです。 期限切れの求人用のURLを試してください(idを実際のデータベースの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

method_for_queryはリリース1.2.2以前では動作しませんでした。

retrieveActiveJobメソッドはルートによってビルドされたDoctrine_Queryオブジェクトを受け取ります:

// 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エラーページ

カテゴリページへのリンク

今度はホームページにカテゴリページへのリンクを追加してカテゴリページを作りましょう。

しかし、少しお待ちください。 予定時間はまだ過ぎていないのであまり作業をしてきませんでした。 あなたには十分な時間と自分自身でこれをすべて実装するための知識があります! 練習してみましょう。 明日我々の実装を確認します。

また明日

ローカルなJobeetプロジェクトで実装に取り組んでください。 symfony公式サイトで利用できるオンラインのAPIドキュメントと すべてのドキュメントを遊んでみてください。

我々の実装と共に明日またお会いしましょう。

Good luck!