2009/10/30

conditionalBasicSecurityFilterってのもアリかな

はじめに

conditionalシリーズをもう少し考えてみました。今回はキャッシュではなくて、securityFilterです。

symfonyでは、settings.ymlを用いて、ユーザがログインしていないときに呼び出すアクションを指定することができます。その際に使用するのが、login_moduleとlogin_actionです。

また、ログインはしているが、権限がない(Credentialがない)ときに呼び出すアクションも指定することができます。その際に使用するのが、secure_module, secure_actionです。この二つとsecurity.ymlを使うことによって、is_secureなアクションにログインしていないユーザがアクセスした際に指定したログイン画面を出すことが可能となります。

今回は、この2つを拡張してみることにします。つまり、ユーザがログインしていないときに呼び出すアクションをもう少し条件を付けて、特定のページには違うlogin_actionを渡すようなことです。

問題

確かにsymfonyではlogin_module、login_actionで指定することができるのいいのですが、もっと細かに指定したいときってどうしたらいいんだろうという話を、先日耳にしました。今まで私はそんな需要がなかったので、考えたことはなかったのですが、「私だったらこうやるかな」という方法をいきなり午前1時より作り始めてしまいましたので、その勢いでブログにも書いていますw
secureの方の動作は確認していないのでアレなのですが、loginの方は確認しましたので、これで対応することができたと思います。実装環境は相変わらずsymfony1.3-ALPHA2なのですが、たぶんsymfony1.2系でも動くと思います。

方法

login_module, login_actionなどの指定が実際どこで呼ばれているかを調査しますと、sfBasicSecurityFilter.class.phpの中で使われています。このsfBasicSecurityFilterは、filters.ymlに書かれているsecurityのデフォルトとして使われるfilterです。このsfBasicSecurityFilterを読んでみると、login_module, login_actionは固定値としてsfConfig::get(’sf_login_module’)のように使われていますので、簡単には変更することができないようになっています。というわけでこれではダメなので、考えてみました。

さて、symfonyのプラグインの中で一番ダウンロードが多いのがsfGuardPluginなのですが、そこでfilterのsecurityFilterを書き換えますよね?securityFilterは変更可能なのですね。というわけで、同様に、今回指定したconditionalBasicSecurityFilterというものをsecurityFilterとして使用できるように指定します。そして、一緒に渡すパラメータもfilters.ymlで指定してみます。yamlで指定する値が増えすぎてわからなくなってしまうかもしれないのですが、conditionalCacheFilterと同じようにpageごとに指定ができるようにしてみました。

ソースコード

/apps/frontend/config/filters.yml

rendering: ~
#security:  ~
security:
  class: conditionalBasicSecurityFilter
  param:
    pages:
      - { module: post, action: new, login: { module: user, action: loginNew } secure: { module: default, action: error403 } }
      - { module: post, action: edit, login: { module: user, action: loginEdit } secure: { module: default, action: error403 } }

# insert your own filters here

cache:     ~
execution: ~
~

/apps/frontend/lib/conditionalBasicSecurityFilter.class.php

class conditionalBasicSecurityFilter extends sfBasicSecurityFilter
{

  public function execute($filterChain)
  {
    if (!$pages = ($this->getParameter('pages', false))) {
      parent::execute($filterChain);
      return;
    }

    foreach ($pages as $page) {
      $module = isset($page['module']) ? $page['module'] : null;

      $action = isset($page['action']) ? $page['action'] : $this->context->getActionName();

      $login = isset($page['login']) ? $page['login'] : null;

      $secure = isset($page['secure']) ? $page['secure'] : null;

      // skip
      if ($module !== $this->context->getModuleName()) {
        continue;
      }

      // skip
      if ($action != $this->context->getActionName()) {
        continue;
      }

      if (isset($login) and !$this->context->getUser()->isAuthenticated()) {
        $loginModule = isset($login['module']) ? $login['module'] : null;
        $loginAction = isset($login['action']) ? $login['action'] : null;
        if (isset($loginModule) and isset($loginAction)) {
          $this->forwardToAction($loginModule, $loginAction);
        }
      }

      $credential = $this->getUserCredential();
      if (isset($secure) and !is_null($credential) and !$this->context->getUser()->hasCredential($credential)) {
        $secureModule = isset($secure['module']) ? $secure['module'] : null;
        $secureAction = isset($secure['action']) ? $secure['action'] : null;
        if (isset($secureModule) and isset($secureAction)) {
          $this->forwardToAction($secureModule, $secureAction);
        }
      }
    }

    parent::execute($filterChain);
  }

  protected function forwardToAction($module, $action)
  {
    $this->context->getController()->forward($module, $action);

    throw new sfStopException();
  }
} 

解説

filters.ymlでsecurityFilterで使用するクラスとしてconditionalBasicSecurityFilterを指定します。そして、その中で受け取るパラメータを同時に指定させます。今回はいいサンプルのアイデアがなかったのですが、postモジュールで、アクションがnewするときとeditするときに違うloginフォームを出したい、という要望があったとして考えてみました。つまり、newの時はuserモジュールのloginNewアクションを呼びたいとします。そして、editの時はuserモジュールのloginEditアクションを呼びたいとします。secureは、とりあえずこんな感じで指定できるかな、という感じで書いてみましたので、今回は説明しません。

conditionalBasicSecurityFilter.class.phpに関しては、前回書きましたconditionalCacheFilterと同じロジックでpagesの分だけループで回し、それがloginが必要なものであったり、credentialのチェックが必要なものであった際には、forwardをするというようにしました。これで、newのときには、loginNewにforwardされ、editのときにはloginEditにforwardされるようになります。

まとめ

今回は、そのsfBasicSecurityFilterを拡張してconditionalBasicSecurityFilterを作ってみました。そこでは、モジュールやアクションによって、login_module, login_action,security_module, security_actionを分けて指定ができるようにしました。
今回は、午前1時になっていきなり、思い立ったように書いてみましたので、適当なことを言っているかもしれないのですが、せっかくなので、ソースコードまで落として、さらにブログにも書いてみました。ログインの際のロジックを拡張したい際にはsfBasicSecurityFilterを拡張して対応するというのは、結構使えるのではないかな、と思います。
しかし、ここまで書いた後に、よく考えてみるとこれはアプリケーションを別にして作ればある程度は回避できる問題なんだろうなぁ、と少し思っています。それでも、上に書いたように分けたい場合はこんな感じで対応してみてはどうでしょうか?

そろそろ寝ないと。。。勢いで書いたので、間違っているところもあると思います。それは後ほど修正します。

2009/10/26

conditionalCacheFilterをさらに考える

はじめに

前回、conditionalCacheを説明した後に、さらにそれを使った事例を考えていたのですが、考えていたところ直したいところがいくつか出てきたので、さらにconditionalCacheを考えてみました。一つは、enabledオプションによって、キャッシュをクリアすることは必要ないと考えたことです。本来の動作としては、enabledがfalseであれば、キャッシュを使わずに、そのまま表示するという方法を取るのが一番でしょうね。これはviewCacheManagerのisCacheableメソッドを直せば可能となると思います。とりあえずその方法は後に説明するということで、今回はenabledオプションを消しました。
そして、今回は、セッション関係を持つuserクラスを使って、キャッシュをするか、どうかを追加してみることにしましたので、それを説明しようと思います。

問題

ユーザ認証しているかどうかだけではなくて、ユーザ認証して、かつ自分のコントロールできるページか否かというオプションがあったらウレシイですよね。つまり、そのユーザの投稿やプロフィールページであれば、「編集する」や「削除する」というアクションが表示されるのが普通でしょう。ユーザ認証をしていても、他のユーザの内容であれば、編集や削除ができたら困りますよね。ここでもconditionalCacheの出番が必要ではないか、と考えました。

方法

セッションを操るuserを使いましょう。filters.ymlで条件としてuser_conditionを追加してみます。そこで、user_conditionに、キャッシュ可能かどうかを条件判断させるメソッドを文字列で渡してみます。

ソースコード

/apps/frontend/config/filters.yml

rendering: ~
security:  ~

# insert your own filters here
conditionalCacheFilter:
  class: conditionalCacheFilter
  param:
    pages:
      - { module: post, action: index, format: [xml] }
#      - { module: post, action: index, format: [html], user_condition: usesCache }
      - { module: post, action: index, format: [html], user_condition: isNotAuthenticated, with_layout: true }

cache:     ~
execution: ~

/apps/frontend/lib/conditonalCacheFilter.class.php

class conditionalCacheFilter extends sfFilter
{
  protected
    $cacheManager    = null,
    $request         = null,
    $user            = null,
    $uri             = null,
    $defaultLifetime = null;

  public function initialize($context, $parameters = array())
  {
    parent::initialize($context, $parameters);

    $this->cacheManager    = $context->getViewCacheManager();
    $this->request         = $context->getRequest();
    $this->user            = $context->getUser();
    $this->uri             = $context->getRouting()->getCurrentInternalUri();

    $lifetime = (isset($this->cacheManager)) ? $this->cacheManager->getLifeTime($this->uri) : 0;
    $this->defaultLifetime = ($lifetime > 0) ? $lifetime : 86400;
  }

  public function execute($filterChain)
  {
    if ((!isset($this->cacheManager)) or !($this->getParameter('pages', false))) {
      $filterChain->execute();
      return;
    }

    foreach ($this->getParameter('pages') as $page) {

      $module = isset($page['module']) ? $page['module'] : null;

      $action = isset($page['action']) ? $page['action'] : $this->request->getParameter('action');

      $format = isset($page['format']) ? $page['format'] : array();
      if (!is_array($format)) {
        $format = array($format);
      }

      $user_condition = isset($page['user_condition']) ? $page['user_condition'] : null;

      // maybe better to throw exception?
      if (is_null($module)) {
        continue;
      }
      // skip
      if ($module !== $this->request->getParameter('module')) {
        continue;
      }
      // skip
      if (!empty($format) and !in_array($this->request->getParameter('sf_format', 'html'), $format)) {
        continue;
      }

      // skip
      if (isset($user_condition)) {
        $func = array($this->user, $user_condition);
        if (!(is_callable($func) and call_user_func($func, $this->request))) {
          continue;
        }
      }

      $options['lifeTime'] = isset($page['lifetime']) ? $page['lifetime'] : $this->defaultLifetime;
      // if the page is not cached already, you can set with_layout option
      // xxx symfony page cache cannot have with_layout and without_layout cache same time
      if (!$this->cacheManager->isCacheable($this->uri)) {
        $options['withLayout'] = isset($page['with_layout']) ? $page['with_layout'] : false;
      }

      //add
      $this->cacheManager->addCache($module, $action, $options);
    }

    // chain
    $filterChain->execute();
  }
}

/apps/frontend/lib/myUser.class.php

class myUser extends sfBasicSecurityUser
{
// snip some methods for user signin or signout methods.
//
  public function getUsername()
  {
    if ($this->isAuthenticated()) {
      return $this->getAttribute('username', '', 'test');
    }
    return null;
  }

  public function usesCache($request)
  {
    $username = $request->getParameter('username', false);
    return $this->getUsername() !== $username;
  }

  public function isNotAuthenticated()
  {
    return !$this->isAuthenticated();
  }
}

解説

今回は、userクラスで条件判断をつけるuser_conditionのキーをfilters.ymlで選択させるようにしてさらに柔軟にしていました。つまり、user_conditionに与えるメソッドによって、キャッシュするかどうかを変更することができます。ここでは、isNotAuthenticatedによって、認証されていないユーザにはキャッシュをするというように与えています。前回のis_authenticatedオプションではなく、直接userクラスに判断させるようにしてあります。user_conditionが指定していなければ、認証していようが、いまいが、キャッシュが有効になります。そして、認証している際にさらに条件を加えたい場合は、user_conditionでもう少し詳しい内容を実装したメソッドを指定してみましょう。

例えば、user_conditionにusesCacheを指定すれば、リクエストパラメターにusernameがあった際に、セッションに入っているusername(ログインした際に持っておく)と比較をし、同じものであれば(そのユーザに関するページ)、キャッシュをしない、ということも可能となります。

さて、with_layoutオプションですが、これは実は少し厄介なのです。現在のsymfonyでは、同じページのキャッシュを複数持つことができないのですね。つまり、with_layoutとwithout_layoutのキャッシュが同時に持てずに、切り分けることができないのです。実際は、キャッシュといっても、シリアライズされた内容をキャッシュディレクトリに格納しているのですが、with_layoutの方は、オブジェクトなのに対し、without_layoutの方はarrayでシリアライズされているので、ここで問題が起きてしまいます。これは、すでにcache.ymlでキャッシュを指定していた際のwith_layoutの値と違う値を指定するとエラーが起きてしまうので、このようにすでになければ指定可能だが、すでにあれば、with_layoutオプションは使えない、というようにしました。

まとめ

前回の内容からさらにconditionalCacheを考えて、userクラスのメソッドにそれを判断させるキーをfilters.ymlに追加しました。このことにより、認証済みでも、さらに自分に関するページに関してはキャッシュをしない、等の処理が可能となりました。つまり、認証していても他のユーザのページであれば、キャッシュを使えるということになります。

課題

キャッシュに関しては、まだ考えているところがあります。cache.ymlにて、enabledをtrueにしているが、conditionalCacheFilterに渡すenabledをfalseにした際に、キャッシュをクリアするのではなく、そのまま置いておいて、そのページはキャッシュを使わずに表示させたいと思います。また、パーシャルキャッシュについても考えた方が良いでしょう。前回と今回に関しましては、ページキャッシュを扱いましたが、パーシャルキャッシュもログイン状態によって切り分けたいときがあると思います。それに関しても考えていきたいと思います。

ちなみに、ここで私が載せている内容は、正しい唯一の方法ではなく、「現在の私だったら、こうやって実装するかな。」という内容です。なので、時間が経つに連れて、より良い方法が見つかった際には、そちらに移行すると思います。その際には、また、ブログに書くでしょう。

2009/10/24

conditionalCacheFilterをより一般的に考えてみる

はじめに

symfonyでアプリケーションを作ろうとする際にキャッシュを使っていますか?キャッシュと一言に言ってもいろんなレイヤーでキャッシュを実現する方法があるので、なんとも答えに困る質問ですね。しかし、webアプリを作る際に言われているキャッシュであれば、

  1. apcなどでphpスクリプトをキャッシュ
  2. ページ出力内容を静的ファイルもしくは、メモリ上にキャッシュ
  3. クライアントのブラウザ側に残してもらうキャッシュ

等が一般的に考えられるのではないかと思います。おそらく、opcodeに関しては、多くの人はapcなどを使うとしても、チューニングの設定等を意識する必要はあると思いますが、実際に中でどう動いているのかを考えることが少ないと思います。「普通のやつらの下を行く」ことを目的とするユーザ(下のレイヤーで勝負するという意味)はそちらを調べてもらうとして、Webアプリ作成屋としては一番腕の見せ所は2番目ではないかと思います。つまり、どの出力をキャッシュ化して、どの出力をキャッシュ化しない、といったようなことです。そして、キャッシュを絶妙のタイミングでクリアさせることです。これによって、データベースへの接続の際のクエリーが激減させることができますので、大幅なパフォーマンスの改善が望めるからです。

今回のポストでは、この2番目の内容をもう少し詳細まで扱おうと思います。

問題

symfonyでは、cacheをサポートしており、cache.ymlで指定させることによって、次に上げるようなページキャッシュが可能となっています。

  1. layoutを含むキャッシュなのか、そうでないのか、という指定
  2. キャッシュの有効期間の指定
  3. partial, component等のページ全体ではなくて、一部のみのキャッシュの指定

これでだいたいの場合はキャッシュの使用として解決ができると思います。しかし実際にアプリケーションを作成していると、もう少し詳細なキャッシュ機構があったらいいな、というときがあります。たとえば、Jobeetのfeedの章であったようなことです。つまり、同じactionを使用するが、sf_formatの値によって、出力をatomにするのか、htmlにするのか、切り分ける場合です。atomの出力とhtmlの出力で同じキャッシュの指定をしたいですか?もし、同じでいいのであればいいのですが、私は分けたいと思いました。htmlの方ではログインしているかどうかも関係してくるのですが、atomの場合は、ログインしていようがいまいが同じ出力を返すからです。

同様に、ログインしているかどうかによって切り分けるページもあるでしょう。例えば、ログインしていると、ヘッダに、「hogehogeさん」って名前が出てくるようなものです。見せるコンテンツはログインしていようがいまいが同じだけども、ログインしていない際にはページ全体をキャッシュさせたいと思いますし、ログインしていたら、キャッシュ化させる内容は分けたいと思います。現在は、同じページでwith_layoutとwithout_layoutで切り分けることはできません。なので、ログインしてない際にはキャッシュを有効にさせ、ログインしている際にはページを無効にさせるという方法を取ります。

これらの内容は、cache.ymlだけでは実現が不可能ですので、他の方法が必要となります。

方法

symfonyにおいて、キャッシュを使う方法はいくつか用意されています。一番大きな方法としては、cache.ymlで指定することです。次にviewの中でキャッシュを使用するかどうかを指定することでしょう。さらにcacheManagerを通してキャッシュのロジックを変更することができます。キャッシュを自分の好きなタイミングでクリアしたり、有効にしたりすることができます。このcacheManagerを通してキャッシュを使用するには、filterが一番相性がいいようです。クリアするのは、データの変更後などになりますので、actionに書くことになってしまいますが、requestによってキャッシュを使用するしないを変更するにはfilterを使うことになります。The Definitive Guilde to symfonyのcacheの章のconditionalCacheFilterのような方法です。今回のポストでは、このconditionalCacheFilterをもう少し拡張してみることにします。

ソースコード

今回使用したsymfonyのバージョンは、1.3.0-ALPHA2です。1.2系は同じようにできると思いますが、1.0系はおそらく実装が異なると思います。

/apps/your_apps/config/filters.yml

rendering: ~
security:  ~

# insert your own filters here
conditionalCacheFilter:
  class: conditionalCacheFilter
  param:
    pages:
#      - { module: post, action: index, format: [atom,xml] }
#      - { module: post, action: index, format: [xml], enabled: false }
      - { module: post, format: [xml], enabled: true , is_authenticated: false }

cache:     ~
execution: ~

/apps/your_app/lib/conditionalCacheFilter.class.php

class conditionalCacheFilter extends sfFilter
{
  protected
    $cacheManager    = null,
    $request         = null,
    $user            = null,
    $uri             = null,
    $defaultLifetime = null;

  public function initialize($context, $parameters = array())
  {
    parent::initialize($context, $parameters);

    $this->cacheManager    = $context->getViewCacheManager();
    $this->request         = $context->getRequest();
    $this->user            = $context->getUser();
    $this->uri             = $context->getRouting()->getCurrentInternalUri();

    $lifetime = (isset($this->cacheManager)) ? $this->cacheManager->getLifeTime($this->uri) : 0;
    $this->defaultLifetime = ($lifetime > 0) ? $lifetime : 86400;
  }

  public function execute($filterChain)
  {
    if ((!isset($this->cacheManager)) or !($this->getParameter('pages', false))) {
      $filterChain->execute();
      return;
    }

    foreach ($this->getParameter('pages') as $page) {

      $module = isset($page['module']) ? $page['module'] : null;

      $action = isset($page['action']) ? $page['action'] : $this->request->getParameter('action');

      $format = isset($page['format']) ? $page['format'] : array();
      if (!is_array($format)) {
        $format = array($format);
      }

      $is_authenticated = isset($page['is_authenticated']) ? $page['is_authenticated'] : null;

      // maybe better to throw exception?
      if (is_null($module)) {
        continue;
      }
      // skip
      if ($module !== $this->request->getParameter('module')) {
        continue;
      }
      // skip
      if (!empty($format) and !in_array($this->request->getParameter('sf_format', 'html'), $format)) {
        continue;
      }

      if (is_null($is_authenticated) or ($is_authenticated === $this->user->isAuthenticated())) {
        // remove
        if (isset($page['enabled']) and $page['enabled'] === false) {
          $this->cacheManager->remove($this->uri);
          continue;
        }

        //add
        $this->cacheManager->addCache($module, $action, array(
          'lifeTime' => isset($page['lifetime']) ? $page['lifetime'] : $this->defaultLifetime
        ));
      }
    }

    $filterChain->execute();
  }
}

解説

filters.ymlにて、cache filterが通る前にconditionalCacheFilterを呼びます。filterに渡すことのできるパラメターとしては、The Definitive Guilde to symfonyのcacheの章にあるようにpagesとします。そして、そこに配列として指定したいモジュール名とアクション(ない場合はそのモジュールに属すすべてのアクション)format, enabled, is_authenticated, lifetimeなどのパラメターを指定できるようにしました。is_authenticatedに関しては3つのパターンがありますね。つまり、認証済みの場合、非認証の場合、認証しているかどうかは関係のない場合です。

また、symfony1.3からは、yamlのバージョンの扱いが1.2になりますので、on/off, yes/noは使えなくなりますので、気をつけてください。true/falseで切り分けるようにしましょう。

そして、conditionalCacheFilter.class.phpにおいては、それぞれのパラメターによってキャッシュの使用について分けるようにしました。cacheManagerに関しましては、キャッシュを有効化していないとnullを持ちますので、そのチェックが必要です。デフォルトではenableとなっているページもこのfilters.ymlでenabledをfalseに指定することにより、毎回キャッシュをクリアするようにできますので、より柔軟にキャッシュの変更ができるようになっています。

まとめ

今回のポストでは、symfonyのキャッシュフレームワークを使用する際に役に立つtipsとしてconditionalCacheFilterを一般的な使用方法で使えるように拡張しました。この拡張によって、次の2つが可能となりました。

  1. sf_formatによってキャッシュをするかどうかを切り分ける
  2. ユーザが認証済みかどうかでキャッシュをするかどうかを切り分ける

sf_formatによってキャッシュをするかどうかを切り分けることによって、例えば、feedのようなxml形式のものはある程度キャッシュをしつつ、アプリケーションとしてwebで表示されるviewであるhtml形式のものはキャッシュを使用しなくするということが可能となります。xml以外にも考えられるものはjsonなどのAPI提供です。これらは特別な理由がない限りキャッシュ化されたものを返すのがいいと考えています。

また、ユーザが認証済みかどうかでキャッシュをするかどうかを切り分けることができることにより、認証をしていないユーザに返すレスポンスに速くなることでしょう。これはWebサイトに訪問してくれたユーザのみならず、Googleなどの検索エンジンのクローラーにも言えることです。クローラーによってアクセスさせるものに毎回DBアクセスをさせるのではなく、キャッシュを返して、定期的にそのキャッシュを更新させることによって、負荷を下げましょう。

拡張しようと思えば、その余地を残してくれているのがsymfonyフレームワークです。今回のconditonalCacheFilterは、特定のアプリケーションに依存するというよりは、より一般的なアプリケーションに必要な機能だと考えていますので、plugin化して、置いておくのもいいかもしれません。もちろんバグがあることもあると思いますので、それに関しては教えてください。

2009/07/22

still working on routing cache issue

I’m getting know routing cache problem now, but still struggling how to fix this. :( In my last post, I introduced lazy_routes_deserialize is the way to avoid this problem. Well, I believe some is true, but not everything. After I fixed my factories.yml configuration for sfPatterRouting, it seems that the performance of the application got a lot better, and I no longer across the error messages which I was frustrated very much. :)

However, I ran the application for a couple days, and found different problem. As the symfony blog mentioned , it generates too many routing cache files!!!!! It was more than 600000. 600000 in one directory is not very healthy. So, I changed my idea to use lazy_routes_deserialize. Instead, I decided to use sfAPCCache. The application was using APC anyway, so I thought put every routing cache in it. It worked fine, but I came across the exact same problem again, which is the size of routing cache gets huge! It ate almost 150M in 3 days, and it seems it is going to consume more in the near future. For now, I just run apache reload task one in a day, so it is working great. However, I don’t like the idea I’m using now. I have to find other strategies for this routing cache.

I think the base of this problem is my routing configuration. It gets so huge, and I need to find out better way to avoid it. The more specific URL you have, the bigger your routing cache gets. I understand it, but it seems the number it grows exponentially! :( It is not fair!

Is there any way to optimize my routing cache issue?

O.K, I found an answer. Do not use routing cache. Set sfNoCache instead.

2009/07/19

symfony routing cache and memory error

I was struggling with this trouble for almost 2 months!!!! My current project uses symfony as always, but I had a big problem with memory issue.

I am engaging this project for almost a year. This application was first created with symfony1.1, but now it is compatible with 1.2. Currently it uses many only 1.2 features such as routing, cache, and etc. I was very satisfied with symfony1.2, except this memory problem.

There were many out of memory error lines in my httpd error log.

  1. PHP Fatal error:  Allowed memory size of 100663296 bytes exhausted (tried to allocate 5926568 bytes) in /xxxxx/cache/frontend/prod/config/config_core_compile.yml.php on line 3366

Because of this error, I increased memory_limit to 96M in php.ini. However, it was not enough. Plus, because my server was a bit old and has only 512M, it always uses swap, and gets really bad sometimes. I was so frustrated with this problem, and googled this a lot. It is still a guess, and I don't know the solution I did was the exact one. BUT, It is working very good so far.

After I googled this issue, I found a couple similar topics in symfony forum as follows:

The first one was the problem with symfony1.2.4, and it said routing cache files size gets huge and takes too much memory to serialize it. At first, I did not know this post relates with my problem, because I was too lazy to look inside of line 3366 in config_core_compile.yml.php. After checking line 3366, I found it was the same problem. Too much memory consumption exceeded allowed memory size. One of this fix is using SQLite to handle this cache, but in my understanding, symfony1.2.7 provides different way to solve this problem, which you can find symfony 1.2.7 - more power.

  1. all:
  2.   routing:
  3.     class: sfPatternRouting
  4.     param:
  5.       generate_shortest_url:            true
  6.       extra_parameters_as_query_string: true
  7.       lazy_routes_deserialize:          true
  8.       lookup_cache_dedicated_keys:      true

I checked how this fix affects. This fix generate many routing cache files in cache/app_name/prod/config/routing/. The file size was not huge, but number of files is huge, as the symfony blog says. I might need to use different storage for this cache handling, but now the memory error is disappeared. I am very happy with it. :)

Man, i should have noticed symfony1.2.7 fix. :(

BUT, it did not fix, but it is working now, The answer is "Do not use routing cache. Set sfNoCache instead."

2009/04/08

Don’t want .html file extention for my link!

To make the symfony1.1 base application compatible with symfony1.2 is not very difficult. Because symfony1.2 considers backward compatibility. If you look inside of symfony, you will find many strange implements, such as link_to1, link_to2. In this case, link_to1 is for symfony1.1 base application, and link_to2 is for symfony1.2.

It made us easy to move on to symfony1.2. And I also upgrade the application I developed with symfony1.1 to symfony1.2. One of the new symfony1.2 feature is routing. Yeah! When I did Jobeet tutorial, it took a bit time to understand the new routing framework. I understand it after I implemented askeet with symfony1.2. :) When I developed askeet with symfony1.2, I used pure symfony1.2, so the generated codes are symfony1.2 base, and did not have any problems. However, an application I'm currently working on was developed with symfony1.1 first. It works with symfony1.2 now , but routing ruling is still syfmony1.1 base, and there are other symfony1.1 base code as well. :(

One of the routing features which added in symfony1.2 is sfPropelRouteCollection. Before the emergence of the useful class, I had to write many CRUD and more rule in routing.yml, but sfPropelRouteCollection helped me a lot to implement model base module. I was trying my symfony1.1 base application to upgrading routing rule for symfony1.2. After adding sfPropelRouteCollection in routing.yml, it seemed it is working great, and result of app:routes command is just what I was expected. :) However, urls displayed converted with link_to or url_for are not what I wanted. :( These helper functions add ".html" for urls. I don't want them! :( I struggled to removing this for a bit. Well, It was easy, but it took me two hours to find this fix. When I found the solution, I was so happy that I could not write post related with my problems.

The problem is generate_shortest_url and extra_parameters_as_query_string values in factories.yml. Well, if you used symfony1.2 to generate application using generate:app, you do not have this problem, because symfony1.2 generate factories.yml with this values. However my application was generated with symfony1.1 almost half year ago, and generate_shortest_url and extra_parameters_as_query_string was not in factories.yml.

As you find in symfony1.2 base application's factories.yml, you need following values in your symfony1.1 base factories.yml.

  1. all:
  2.   routing:
  3.     class: sfPatternRouting
  4.     param:
  5.       generate_shortest_url: true
  6.       extra_parameters_as_query_strings: true

O.K, that's it! :) It is very simple, if you know the reason.

2009/04/05

Playing with sfForm with Askeet Part 2

It's another post related with sfForm. After using symfony1.2 several months, I found mastering sfForm is crucial for symfony developers. :) I'm still learning it, too. :)

In this post, I will explain QuesionForm, QuestionTagForm. Maybe I should also explain BackendQuestionForm, because QuestionForm uses tag form with auto complete, and BackendQuestionForm does not, and you might want to choose whether you use it or not. Well, I do! ;)

O.K, let's clarify the specification of posting question and tagging. In askeet, you can only post a question from question posting page. Yeah, as it is. Look at the image below. This is the question posting form.

However, tagging is different. You can set tags when you post a question as an author of the question. Plus, if you are authenticated user, you can set/add tag for the questions no matter who post the question. So, there are two ways for tagging. The one you see above, which is tagging with posting question, and the form you found in sidebar when you logined askeet and go to the question detail page as a below image:

To explain how to do it, I use following codes:

  • lib/form/QuestionForm.class.php
  • lib/form/QuestionTagForm.class.php
  • lib/form/BackendQuestionForm.class.php
  • lib/form/sfWidgetFormProtoculousAutocompleter.class.php
  • apps/frontend/modules/question/actions/actions.class.php(executeNew, executeCreate)
  • apps/frontend//modules/question/templates/newSuccess.php
  • apps/frontend/modules/tag/actions/actions.class.php(executeCreate, executeAutocomplete)
  • apps/frontend/modules/tag/components/components.class.php
  • apps/frontend/modules/tag/templates/_add.php
  • apps/frontend/modules/tag/templates/autocompleteSuccess.php

O.K, let's start with the posting question form.

lib/form/QuestionForm.class.php

  1. class QuestionForm extends BaseQuestionForm
  2. {
  3.   protected $questionTagForm = null;
  4.   public function configure()
  5.   {
  6.     unset(
  7.       $this['created_at'],
  8.       $this['updated_at'],
  9.       $this['interested_users'],
  10.       $this['reports'],
  11.       $this['user_id'],
  12.       $this['html_body'],
  13.       $this['stripped_title'],
  14.       $this['report_question_list'],
  15.       $this['question_tag_list'],
  16.       $this['interest_list']
  17.     );
  18.  
  19.     $this->widgetSchema['title'] = new sfWidgetFormInput( array('label' => 'question:')
  20.     );
  21.     $this->validatorSchema['title'] = new sfValidatorString(
  22.       array(),
  23.       array('required' => 'You must give a title to your question')
  24.     );
  25.  
  26.     $this->widgetSchema['body'] = new sfWidgetFormTextarea(
  27.       array('label' => 'describe it:'),
  28.       array('cols' => 40, 'rows' => 10)
  29.     );
  30.     $this->validatorSchema['body'] = new sfValidatorString(
  31.       array('min_length' => 10),
  32.       array(
  33.         'required' => 'You must provide a brief context for your question',
  34.         'min_length' => 'Please, give some more details'
  35.       )
  36.     );
  37.  
  38.     $this->setQuestionTagForm();
  39.     $this->mergeForm($this->questionTagForm);
  40.   }
  41.  
  42.   public function updateObject($values = null)
  43.   {
  44.     $object = parent::updateObject($values);
  45.     if (is_null($object->getUserId())) {
  46.       $userId = sfContext::getInstance()->getUser()->getSubscriberId();
  47.       $object->setUserId($userId);
  48.     }
  49.     $interest = new Interest();
  50.     $interest->setUserId($userId);
  51.     $object->addInterest($interest);
  52.  
  53.     return $object;
  54.   }
  55.  
  56.   public function doSave($con = null)
  57.   {
  58.     parent::doSave($con);
  59.  
  60.     $this->taintedValues['question_id'] = $this->object->getId();
  61.     $this->questionTagForm->bind($this->taintedValues);
  62.     $this->questionTagForm->doSave($con);
  63.   }
  64.  
  65.   protected function setQuestionTagForm()
  66.   {
  67.     $this->questionTagForm = new QuestionTagForm(
  68.       null,
  69.       array('url' => sfContext::getInstance()->getController()->genUrl('tag_autocomplete'))
  70.     );
  71.   }
  72. }

Check out doSave method. It is a bit tricky, because we need to save posted tags as well. When tagging, you are not going to set only one tag. You might want to tag more than one phrases. Also, using mergeForm does not call doSave for the merged form, so you have to override doSave method here. I need to bind the question_id for QuestionTagForm

In the end of configure method, I called setQuestionTagForm and mergeForm, because I will override setQuestionTagForm in BackendQuestionForm, not to use auto completing feature. See the BackendQuestionForm now.

lib/form/BackendQuestionForm.class.php

  1. class BackendQuestionForm extends QuestionForm
  2. {
  3.   public function configure()
  4.   {
  5.     parent::configure();
  6.     $this->widgetSchema['user_id'] = new sfWidgetFormInputHidden();
  7.     $this->validatorSchema['user_id'] = new sfValidatorPropelChoice(array(
  8.       'model' => 'User', 'column' => 'id', 'required' => false
  9.     ));
  10.   }
  11.  
  12.   protected function setQuestionTagForm()
  13.   {
  14.     $this->questionTagForm = new QuestionTagForm(null);
  15.   }
  16.  
  17.   public function updateDefaultsFromObject()
  18.   {
  19.     parent::updateDefaultsFromObject();
  20.     $tag = implode(" ", $this->object->getTags());
  21.     $values['tag'] = $tag;
  22.     if ($this->isNew) {
  23.       $this->setDefaults(array_merge($values, $this->getDefaults()));
  24.     } else {
  25.       $this->setDefaults(array_merge($this->getDefaults(), $values));
  26.     }
  27.   }
  28. }

As I saild above, I overrided the setQuestionTagFrom for without auto completing function.

O.K, now it's about time for tagging.

lib/form/QuestionTagForm.class.php

  1. class QuestionTagForm extends BaseQuestionTagForm
  2. {
  3.   public function configure()
  4.   {
  5.     unset(
  6.       $this['user_id'],
  7.       $this['normalized_tag'],
  8.       $this['created_at']
  9.     );
  10.  
  11.     $this->widgetSchema['question_id'] = new sfWidgetFormInputHidden();
  12.     $this->validatorSchema['question_id'] = new sfValidatorPropelChoice(array('model' => 'Question', 'column' => 'id', 'required' => false));
  13.  
  14.     $url = $this->getOption('url');
  15.     if (!empty($url)) {
  16.       $this->widgetSchema['tag'] = new sfWidgetFormProtoculousAutocompleter(array(
  17.         'label' => 'tags:',
  18.         'url' => $url,
  19.         'use_style' => true
  20.       ));
  21.     } else {
  22.       $this->widgetSchema['tag'] = new sfWidgetFormInput(array(
  23.         'label' => 'tags:'
  24.       ));
  25.     }
  26.     $this->widgetSchema->setNameFormat('question[%s]');
  27.   }
  28.  
  29.   public function doSave($con = null)
  30.   {
  31.     $tags = Tag::splitPhrase($this->taintedValues['tag'] . (sfConfig::get('app_permanent_tag') ? ' '.sfConfig::get('app_permanent_tag') : '' ));
  32.     $question = QuestionPeer::retrieveByPk($this->taintedValues['question_id']);
  33.  
  34.     foreach ($tags as $tag) {
  35.       try {
  36.         $questionTag = new QuestionTag();
  37.         $questionTag->setQuestionId($question->getId());
  38.         $questionTag->setUserId($question->getUserId());
  39.         $questionTag->setTag($tag);
  40.         $questionTag->save($con);
  41.       } catch (PropelException $e) {
  42.         // do nothing
  43.       }
  44.     }
  45.     // hmm, i don't like this... :(
  46.     $this->object = $question;
  47.   }
  48. }

To create auto complete widget, I made sfWidgetFormProtoculousAutocompleter. There was JavaScript helper for auto complete. However, I do not want to use it, because I prefer to use sfForm. :) Also symfony1.2 integrated some useful function with jQuery. :) jQuery is a wonderful library, but I hate use two different JavaScript library in one application. If I use jQuery, I only use jQuery, and askeet has a lot of prototype base code, so I created autocompletor widget for sfProtoculousPlugin. :) Well, it was easy anyway.
So, if there was url option when intiating QuestionTagForm, use sfWidgetFormProtoculousAutocompleter. Without option, use another sfWigetFormInput.

Actually I do not like my implementation of doSave. As I saild above, you need to set more than one tag same time when posting. So, I need to save all the tags. Because saving more than one tag, i don't know what to return. I just return the question object here. :(

Good thing about this QuestionTagForm, you can use this both mergeForm and only itself. As I mentioned above, there are two ways to tag, and both are using this class.

lib/form/sfWidgetFormProtoculousAutocompleter.class.php

  1. class sfWidgetFormProtoculousAutocompleter extends sfWidgetFormInput
  2. {
  3.   protected function configure($options = array(), $attributes = array())
  4.   {
  5.     $this->addRequiredOption('url');
  6.     $this->addOption('config', '{ }');
  7.     $this->addOption('use_style');
  8.  
  9.     parent::configure($options, $attributes);
  10.   }
  11.  
  12.   public function render($name, $value = null, $attributes = array(), $errors = array())
  13.   {
  14.     $response = sfContext::getInstance()->getResponse();
  15.     $response->addJavascript(sfConfig::get('sf_prototype_web_dir').'/js/prototype');
  16.     $response->addJavascript(sfConfig::get('sf_prototype_web_dir').'/js/effects');
  17.     $response->addJavascript(sfConfig::get('sf_prototype_web_dir').'/js/controls');
  18.  
  19.     if ($this->getOption('use_style')) {
  20.       $response->addStylesheet(sfConfig::get('sf_prototype_web_dir').'/css/input_auto_complete_tag');
  21.     }
  22.     return parent::render($name, '', $attributes, $errors) .
  23.       content_tag('div' , '', array('id' => $this->generateId($name) . '_autocomplete', 'class' => 'auto_complete')) .
  24.       sprintf('<script type="text/javascript">new Ajax.Autocompleter("%s", "%s", "%s", %s);</script>',
  25.         $this->generateId($name),
  26.         $this->generateId($name . '_autocomplete'),
  27.         $this->getOption('url'),
  28.         $this->getOption('config')
  29.       );
  30.   }
  31. }

I was a bit lazy, so above code is not well written. :( That's all for form code, and let's see the action and templates from here.

/apps/frontend/modules/questions/actions/actions.class.php

  1. // snip
  2.  public function executeNew(sfWebRequest $request)
  3.   {
  4.     $this->form = new QuestionForm();
  5.   }
  6.   public function executeCreate(sfWebRequest $request)
  7.   {
  8.     $this->form = new QuestionForm();
  9.     $this->form->bind($request->getParameter($this->form->getName()));
  10.     if ($this->form->isValid()) {
  11.       $this->form->save();
  12.       $this->redirect($this->generateUrl('question_show', $this->form->getObject()));
  13.     }
  14.     $this->setTemplate('new');
  15.   }
  16. // snip

executeNew and executeCreate is very simple, and probably, you don't need any instruction with this. Good thing about mergeForm is you don't need to know you are using tag form in action class, because we have already merge it in QuestionForm class. :) You just render the quetion form, and bind and save it.

apps/frontend/modules/question/template/newSuccess.php

  1. <h1><?php echo __('ask a question') ?></h1>
  2.  
  3. <div class="in_form">
  4.   <p>
  5.   <?php echo __('Have you looked for similar questions? Check if a related question already exists: The more interesting a question is, the more people will be willing to answer it.') ?>
  6.   </p>
  7.   <p>
  8.   <?php echo __('Be as accurate as you can when giving a title to your question. Keep it short and put the details in the question body.') ?>
  9.   </p>
  10. </div>
  11.  
  12. <form action="<?php echo url_for('question') ?>" method="post" class="form">
  13.   <fieldset>
  14.     <?php echo $form['title']->renderError() ?>
  15.     <?php echo $form['title']->renderLabel() ?>
  16.     <?php echo $form['title'] ?>
  17.     <br class="clearleft" />
  18.  
  19.     <?php echo $form['body']->renderError() ?>
  20.     <?php echo $form['body']->renderLabel() ?>
  21.     <?php echo $form['body'] ?>
  22.     <br class="clearleft" />
  23.     <?php echo include_partial('content/markdown_help') ?>
  24.  
  25.     <?php echo $form['tag']->renderError() ?>
  26.     <?php echo $form['tag']->renderLabel() ?>
  27.     <?php echo $form['tag'] ?>
  28.     <br class="clearleft" />
  29.     <div class="small in_form"><?php echo __('example: askeet "how to"') ?></div>
  30.  
  31.   </fieldset>
  32.  
  33.   <div class="right">
  34.     <input type="submit" value="<?php echo __('ask it') ?>" />
  35.   </div>
  36. </form>

View file is also simple. Because we merge the QuestionTagForm, you can use $form['tag'], here! O.K, now you are able to post a question with tagging with auto complete feature!

O.K, it is about time to write about tag form that you find in sidebar. We will use the same QuestionTagForm here. :) To add tag, you have to be authenticated and go to the question/show page. In the component slot, _question.php, you include tag/add component. This component displays the tagging form.

/apps/frontend/modules/tag/actions/components.class.php

  1. class tagComponents extends sfComponents
  2. {
  3.   public function executeAdd(sfWebRequest $request)
  4.   {
  5.     $questionTag = new QuestionTag();
  6.     $questionTag->setQuestionId($this->question_id);
  7.     $this->form = new QuestionTagForm($questionTag, array('url' => $this->generateUrl('tag_autocomplete')));
  8.   }                                                                             
  9. }

In the action, executeAdd, we set QuestionTag object with question_id. Plus auto_complete url is also set when initialize form class. As you can see the template file, this form is ajax form.

/app/frontend/modules/tag/templates/_add.php

  1. <?php if ($sf_user->isAuthenticated()): ?>
  2. <div><?php echo ('Add your own:') ?>
  3. <?php echo form_remote_tag(array(                                             
  4.   'url'    => 'tag_create',
  5.   'update' => 'question_tags',
  6.   'complete' => '$("ask_question_tag_tag").value=""'
  7. )) ?>
  8. <?php echo $form['question_id'] ?>
  9. <?php echo $form['tag'] ?>
  10. <input type="submit" value ="<?php echo __('tag') ?>" />
  11. </form>
  12. </div>
  13. <?php endif; ?>

This form request action goes tag_create rule, which is tag/executeCreate action.

apps/frontend/modules/actions/tag/actions/actions.class.php

  1. // snip
  2.  public function executeCreate(sfWebRequest $request)
  3.   {
  4.     $form = new QuestionTagForm();
  5.     $form->bind($request->getParameter($form->getName()));
  6.     if ($form->isValid()) {
  7.       // xxx this form->save returns qustion object
  8.       $this->question = $form->save();
  9.     } else {
  10.       $this->question = $form->getObject()->getQuestion();
  11.     }
  12.     $this->tags = $this->question->getTags();
  13.   }
  14. // snip

As I mentioned above QuestionTagForm's doSave method returns not QuestionTag object, but Question Object, which is not really nice I think. :( Anyway, let's render the tags value to the view file and return value for ajax request. In createSuccess.php, it is same as tag/question_tags partial. What you need to do here is just updating the question tags list which has just tagged by you.

apps/frontend/modules/actions/tag/actions/actions.class.php

  1. // snip
  2.  public function executeAutocomplete(sfWebRequest $request)
  3.   {
  4.     $this->tags = array();
  5.     $form = new QuestionTagForm();
  6.     $form->bind($request->getParameter($form->getName()));
  7.     if ($form->isValid()) {
  8.       $values= $form->getTaintedValues();
  9.  
  10.       $this->tags = QuestionTagPeer::getTagsForUserLike(
  11.         $this->getUser()->getSubscriberId(),
  12.         $values['tag'],
  13.         10
  14.       );
  15.     }
  16.   }
  17. // snip

2009/03/29

Playing with sfForm with Askeet Part 1

It took time to write a new post. :( My current project had a tight schedule, and it is very important for me to work on this project. Sorry. ;)

So, in this post, I will explain some tips for sfForm using the code I implemented in Askeet. I will explain these two cases for form handling:

  • user login / registration switching
  • question posting with tags and tags posting(next post)

To explain the code, I will use following files.

  • lib/form/UserLoginForm.class.php
  • lib/form/UserMixForm.class.php
  • apps/frontend/modules/user/actions/actions.class.php(executeNew, executeLogin, executeCreate
  • apps/frontend/templates/layout.php
  • apps/frontend/modules/user/actions/components.class.php
  • apps/frontend/modules/user/templates/login.php
  • apps/frontend/modules/user/templates/newSuccess.php

Before I explain the code I need to clarify the specification of user login and registration process in Askeet. There are two ways to login Askeet:

  • For unauthorized users, when some actions require authentication, such as posting questions and answers, voting for interest, moderating and etc, the login form dynamically appears on the page. Like following image.
  • Following signin/register link lead you the page you can login and register. When login failed by both ways this page is used as well. Like following image.

O.K, let's start from the first one. Because I'm using symfony1.2, I should use sfForm instead writing every input tags or tag_helper in the view template. I didn't know how to implement such form, but I got a good idea from Exgear's tutorial, and I followed the instruction.
Here, I wrote some code in UserLoginForm.class.php, layout.php, components.class.php, and its template, _login.php. The code are follows:

UserLoginForm.class.php

  1. class UserLoginForm extends BaseUserForm
  2. {
  3.   public function configure()
  4.   {
  5.     unset(
  6.       $this['id'],
  7.       $this['created_at'],
  8.       $this['first_name'],
  9.       $this['last_name'],
  10.       $this['email'],
  11.       $this['sha1_password'],
  12.       $this['salt'],
  13.       $this['ask_interest_list'],
  14.       $this['ask_relevancy_list']
  15.     );
  16.     $this->validatorSchema['nickname'] = new sfValidatorString(
  17.       array('min_length' => 5),
  18.       array('required' => 'your nickname is required', 'min_length' => 'nickname must be 5 or more characters')
  19.     );
  20.  
  21.     $this->widgetSchema['password'] = new sfWidgetFormInputPassword();
  22.     $this->validatorSchema['password'] = new sfValidatorString(
  23.       array(),
  24.       array('required' => 'your password is required')
  25.     );
  26.     $this->widgetSchema['referer'] = new sfWidgetFormInputHidden();
  27.     $this->validatorSchema['referer'] = new sfValidatorPass();
  28.     $request = sfContext::getInstance()->getRequest();
  29.     if ($request->isMethod('get')) {
  30.       $this->setDefault('referer', $request->getReferer());
  31.     } else {
  32.       $this->setDefault('referer', $request->getParameter($this->getName().'[referer]'));
  33.     }
  34.  
  35.     // check valid user
  36.     $this->validatorSchema->setPostValidator(new sfValidatorCallback(array(
  37.       'callback' => array($this, 'validUser'),
  38.     )));
  39.  
  40.     $this->validatorSchema->setOption('allow_extra_fields', true);
  41.   }
  42.  
  43.   public function validUser($validator, $values)
  44.   {
  45.     if (empty($values['nickname']) or empty($values['password'])) {
  46.       return;
  47.     }
  48.     $nickname = $values['nickname'];
  49.     $c = new Criteria();
  50.     $c->add(UserPeer::NICKNAME, $nickname);
  51.     $user = UserPeer::doSelectOne($c);
  52.  
  53.     if (is_null($user) || sha1($user->getSalt().$values['password']) != $user->getSha1Password()) {
  54.       throw new sfValidatorError($validator, 'no user available');
  55.     }
  56.     $this->object = $user;
  57.  
  58.     return $values;
  59.   }
  60. }

Well, this code is straightforward and not many tricks.If I have to say something, I would say using sfValidatorCallback to authorizing user request as post validator. Actually this idea is also from exgear's site. They are really good. :)
BTW, some might think why I put allow_extra_fields there? Well, I will explain this later. As it is, extra fields are expecting for UserLoginForm.

So, create apps/frontend/modules/user/actions/components.class.php, and render the form object of the class.

components.class.php

  1. class userComponents extends sfComponents                                       
  2. {
  3.   public function executeLogin(sfWebRequest $request)
  4.   {
  5.     $this->form = new UserLoginForm();
  6.   }
  7. }

Simple! Huh! :)

Now its view file, apps/frontend/modules/users/templates/_login.php

_login.php

  1. <form action="<?php echo url_for('login') ?>" method="POST" id="loginform">
  2. <?php echo $form['nickname']->renderLabel() ?> <?php echo $form['nickname'] ?>
  3. <?php echo $form['password']->renderLabel() ?> <?php echo $form['password'] ?>
  4. <?php echo $form['referer'] ?>
  5. <input type='submit' value="<?php echo __('login') ?>">
  6. <?php echo link_to_function(__('cancel'), visual_effect('blind_up', 'login', array('duration' => 0.5))) ?>
  7. </form>

As I am a programmer, I do not like to write every echo $form['blabla'] in view files, but I was too lazy to think other way right now. :( I might try better ways later if I had any chances. :) Anyway, this is very easy, so no explanation here either. Blinding up animation is there, because it was found in the Askeet tutorial, and I liked the cool animation. :) I did not write any error message in this template, you will see why later.

Now you need to include this components in layout.php

layout.php

  1. // snip
  2.       <div id="login" style="display: none">
  3.         <h2><?php echo __('please sign-in first') ?></h2>
  4.         <?php include_component('user', 'login') ?>
  5.       </div>
  6. // snip

Now you can see the form container is invisible, and use link_to_function to will change the status to visible. When actions need user authentication, echo the next helper function, and the login form will appear!

  1. link_to_function('some action label', visual_effect('blind_down', 'login', array('duration' => 0.5)));

O.K, that's one way to login for user. It was longer than I thought. :) We didn't reach the tricky tips yet. Now you might think "what about login error page? How do users know if he or she typed right nickname and password? ", Well, those error messages are displayed signin /register page. So, when user login failed, you have different login page, which I will explain from now.

For easy user registration and login, many web applications use the same form to login or registering. Askeet also use this strategy. The default page of signin/register page only displays nickname and password fields, and a checkbox. Yes, this checkbox having label as "click here to create a new account", switches this form action to be login or registering. Look at the image again.

If you check the checkbox, two more fields will appear in the page like following image:

O.K, finally! I had struggled a bit to implementing this with sfForm. Askeet tutorial was easy, because the tutorial write the input helper directly. I'm using sfForm framework, and I want to avoid this. To implement this form, I created UserMixForm.class.php for this page. UserLoginForm.class.php was for only login, but this form class is for registration mainly. Maybe naming is not good, though :(
Anyway, I copy all the source of UserMixForm.class.php here:

  1. class UserMixForm extends BaseUserForm
  2. {
  3.   public function configure()
  4.   {
  5.     unset(
  6.       $this['id'],
  7.       $this['created_at'],
  8.       $this['first_name'],
  9.       $this['last_name'],
  10.       $this['sha1_password'],
  11.       $this['salt'],
  12.       $this['ask_interest_list'],
  13.       $this['ask_relevancy_list']
  14.     );
  15.  
  16.     $this->widgetSchema['nickname'] = new sfWidgetFormInput(array('label' => 'Nickname:'));
  17.     $this->validatorSchema['nickname'] = new sfValidatorString(
  18.       array('min_length' => 5),
  19.       array('required' => 'your nickname is required', 'min_length' => 'nickname must be 5 or more characters')
  20.     );
  21.  
  22.     $this->widgetSchema['password'] = new sfWidgetFormInputPassword(array('label' => 'Password:'));
  23.     $this->validatorSchema['password'] = new sfValidatorString(
  24.       array(),
  25.       array('required' => 'your password is required')
  26.     );
  27.     $controller = sfContext::getInstance()->getController();
  28.     $this->widgetSchema['new'] = new sfWidgetFormInputCheckbox(array(
  29.       'label' => 'click here to create a new account'
  30.       ),
  31.       array('style' => 'display: inline; float: none',
  32.         'onclick' => "
  33.       if (Element.visible('new_account')) {
  34.         Effect.BlindUp('new_account');
  35.         $('login_form').action = '" . $controller->genUrl('login') . "';
  36.        } else {
  37.         Effect.BlindDown('new_account');
  38.         $('login_form').action = '" . $controller->genUrl('user_create') . "';
  39.        }
  40.     "));
  41.     $this->validatorSchema['new'] = new sfValidatorPass();
  42.  
  43.     $this->widgetSchema['password_biz'] = new sfWidgetFormInputPassword(
  44.       array('label' => 'confirm your password')
  45.         );
  46.     $this->validatorSchema['password_biz'] = new sfValidatorString(
  47.       array(),
  48.       array('required' => 'password confirmation is required')
  49.     );
  50.  
  51.     $this->widgetSchema['email'] = new sfWidgetFormInput(
  52.       array('label' => 'your email')
  53.     );
  54.     $this->validatorSchema['email'] = new sfValidatorEmail(
  55.       array('min_length' => 5),
  56.       array(
  57.         'required' => 'your email is required',
  58.         'invalid' => 'email address is invalid'
  59.       )
  60.     );
  61.  
  62.     $this->validatorSchema->setPostValidator(new sfValidatorAnd(array(
  63.       new sfValidatorSchemaCompare(
  64.         'password', '==', 'password_biz', array(),
  65.         array('invalid' => 'password and confirmed password does not match')
  66.       ),
  67.       new sfValidatorPropelUnique(
  68.         array('model' => 'User', 'column' => array('nickname')),
  69.         array('invalid' => 'the nickname is already taken.')
  70.       ),
  71.       new sfValidatorPropelUnique(
  72.         array('model' => 'User', 'column' => array('email')),
  73.         array('invalid' => 'the email address is areadly registered.')
  74.       )
  75.     )));
  76.  
  77.     $this->widgetSchema['referer'] = new sfWidgetFormInputHidden();
  78.     $this->validatorSchema['referer'] = new sfValidatorPass();
  79.     $request = sfContext::getInstance()->getRequest();
  80.     if ($request->isMethod('get')) {
  81.       $this->setDefaults(array('referer' => $request->getReferer()));
  82.     }
  83.   }
  84.  
  85.   public function updateObject($values = null)
  86.   {
  87.     $object = parent::updateObject($values);
  88.     $password = $this->taintedValues['password'];
  89.     $object->setPassword($password);
  90.     return $object;
  91.   }
  92.  
  93.   public function mergeStatus($loginform)
  94.   {
  95.     if (isset($loginform)) {
  96.       $nickname = $loginform->taintedValues['nickname'];
  97.       $referer = $loginform->taintedValues['referer'];
  98.       $this->errorSchema = $loginform->getErrorSchema();
  99.       $this->setDefault('nickname', $nickname);
  100.       $this->setDefault('referer'$referer);
  101.     }
  102.   }
  103. }

Wow, this is long, but the longest method, configure, does not need any explanation I think. The method, updateObject call setPassword, because there is no password field for user model. There are sha1_password and sha1 fields. So, setPassword of user model set both fields. Actually it is same as Askeet tutorial, so I skip this, too. I just need to update the field, so I overrided the method.

What about mergeStatus method? Well, this is something new. Hehehe. :) Because this is my original idea. I did not refer any site to implement this, and I believe this is a tricky part. As I mentioned above, UserMixForm is actually for user registration. However, this form also handles user login and displaying login error messages as well. To succeed the login failure messages and input values, this method was needed. You will see the reason soon.

So, the signin/register page is assigned to executeNew method in actions. executeCreate is for binding, validating, and saving the user registration form. Finally executeLogin binding, validating login authentication, and if the validation failed it will render the error messages as well. O.K, let's see the code :)

  1. // snip
  2.   public function executeNew(sfWebRequest $request)
  3.   {
  4.     if ($this->getUser()->isAuthenticated()) {
  5.       $this->redirect('homepage');
  6.     }
  7.     $this->form = new UserMixForm();
  8.   }
  9.  
  10.   public function executeCreate(sfWebRequest $request)
  11.   {
  12.     $this->form = new UserMixForm();
  13.     $this->form->bind($request->getParameter($this->form->getName()));
  14.     if ($this->form->isValid()) {
  15.       $this->form->save();
  16.       $this->forward('user', 'login');
  17.     }
  18.     // use user registration form in login template
  19.     $this->getRequest()->setAttribute('newaccount', true);
  20.     $this->setTemplate('new');
  21.   }
  22.   public function executeLogin(sfWebRequest $request)
  23.   {
  24.     $loginform = new UserLoginForm();
  25.     $loginform->bind($request->getParameter($loginform->getName()));
  26.     if ($loginform->isValid()) {
  27.       $this->getUser()->signIn($loginform->getObject());
  28.       $this->redirect($request->getParameter($loginform->getName() .'[referer]', 'homepage'));
  29.     }
  30.     $this->form = new UserMixForm();
  31.     $this->form->mergeStatus($loginform);
  32.     $this->setTemplate('new');
  33.   }
  34.  
  35. // snip

The method, executeNew does not need any explanation. It just renders the UserMixForm object. I don't want authorized user to access this page, so let them forward to homepage route. O.K. the method, executeCreate is also straightforward. It is just another form transaction (bind, validate, save). In case the validation failed, set newaccount attribute for checkbox to be checked, and use the same template as executeNew.

O.K, finally! We are in tricky part, executeLogin. Because UserMixForm and UserLoginForm are children of BaseUserForm class, both share same fields, such as nickname, password. So, UserLoginForm require nickname, password, referer, and the for send the parameters. UserMixForm also send nickname, password, referer(and other fields, too). Well, yes! UserMixForm has other fields. That's why allow_extra_fields is on in UserLoginForm. If the user authentication failed, this action uses newTemplate which means using UserMixForm. You need to render the form value, and you need to set the error messages and input values from the last request to the form as well. To do this, you have to pass them from userLoginForm to UserMixForm. Here is mergeStatus method. I don't know if this name is proper, though. :(

Oops, I forgot to mention newSuccess.php.

newSuccess.php

  1. <h1><?php echo __('sign in / register') ?></h1>
  2.  
  3. <div class="in_form">
  4. <p><?php echo __('Registration is free and required only to create a new question or rate an answer.') ?></p>
  5. </div>
  6. <?php echo $form->renderGlobalErrors() ?>
  7. <form action="<?php echo $sf_request->getAttribute('newaccount', false) ? url_for('user_create') : url_for('login') ?>" method="POST" id="login_form" class="form">
  8.   <fieldset>
  9.     <?php echo $form['nickname']->renderLabel() ?>
  10.     <?php echo $form['nickname']->renderError() ?><?php echo $form['nickname'] ?>
  11.     <br class="clearleft"/>
  12.     <?php echo $form['password']->renderLabel() ?><?php echo $form['password']->renderError() ?>
  13.     <?php echo $form['password'] ?>&nbsp;<?php echo link_to(__('forgot your password?'), 'user_require_password') ?>
  14.     <br class="clearleft"/>
  15.     <div class="in_form">
  16.       <?php echo $form['new'] ?>
  17.       &nbsp;<?php echo $form['new']->renderLabelName() ?>
  18.     </div>
  19.     <br class="clearleft"/>
  20.     <div id="new_account"<?php echo $sf_request->getAttribute('newaccount', false) ? '' : ' style="display: none"' ?>>
  21.       <?php echo $form['password_biz']->renderLabel() ?>
  22.       <?php echo $form['password_biz']->renderError() ?>
  23.       <?php echo $form['password_biz'] ?>
  24.       <br class="clearleft"/>
  25.  
  26.       <?php echo $form['email']->renderLabel() ?>
  27.       <?php echo $form['email']->renderError() ?>
  28.       <?php echo $form['email'] ?>
  29.       <br class="clearleft"/>
  30.  
  31.       <div class="small in_form"><?php echo __('askeet will never disclose this address to a third party') ?></div>
  32.     </div>
  33.   </fieldset>
  34.   <?php echo $form['referer'] ?>
  35.   <div class="right">
  36.   <input type='submit' value="<?php echo __('sign in') ?>">
  37.   </div>
  38. </form>

It is another usual form. No explanation.

Because there are difficulties to explain in English (Probably it would be hard to write this topic in Japanese anyway), I may fix some words or explanation I posted today.

Anway, that's it. Wow, this is a long post, I have to split the tips to two posts. Hmm. Next is Question and QuestionTag form handling. Have fun! :) :) :) :)

2009/03/10

How about sfAskeetPlugin!

I was talking with akky a few minutes ago, and he had a great idea!

It was posting askeet as symfony plugin like a sfJobeetPlugin!

I'm going to work on this for my free time, so it might take a little time. Please wait!

BTW, I replaced the tar ball, because I found a bug. Answering by anonymous user broke the table constraint, so I fixed some code.

I need more unit / functional testing. :)

2009/03/09

Askeet with symfony1.2

O.K, this is my first post in English. I will try my best. :)

After symfony1.2 launched, Jobeet was introduced to the world for users to learn through developing it. The tutorial is very effective for symfony users. I also did the tutorial and learned a lot of symfony1.2 new feature.

Back to 2005, when symfony emerged, there was an application like Jobeet for symfony learners. The application was Askeet! I have used symfony since symfony0.6, and learned a lot from the Askeet tutorial that time. However, the tutorial had some small mistakes, and the source code you can get from svn repository and tar ball was a bit buggy. :( I struggled a lot to complete the tutorial. Maybe Fabien and Francois did it on purpose to motivate us to investigate inside of symfony further. Hehe. ;) Currently nobody care the Askeet tutorial, because there is Jobeet, and Askeet tutorial is obsolete, since symfony is now 1.2!

Is Askeet just a tutorial? Even Askeet is an open source application, I have not encountered any web sites integrate Askeet. Why? I have some reasons in my mind. First, as I wrote on above, it is a bit buggy, such as same duplicate question title cause error, user_interest rule and related actions are never used, and etc. Second, even there was a cache chapter in the tutorial, more caching is needed for real world applications. Third, symfony is growing rapidly, which is good, but applications written by old version of symfony are hard to be taken care. Forth, there is no active source code repository for the application. Of course there is a repository for tutorial, but not for the application.

I was waiting for that somebody develops Askeet with symfony1.2, and give us its tutorial. Actually there were a couple tries in Japan, and I was excited about their work to finish.... BUT, after symfony team introduced Jobeet, everybody went to Jobeet, and the tries are gone to somewhere. :(

So, I did it! :)

Well, since I am too lazy, I'm not going to write tutorials for beginners, but I will give you some tips I found from the experience that I was developing Askeet with symfony1.2. I'm still working on caching strategy for this application, so any help is welcome. Actually my friend, akky, is helping for i18n now.

O.K, for today, I give you the current source code. License? Same as symfony! I'm using symfony1.2.4(current version:3/10/09). You can learn a lot of symfony features from the code I believe. Why I open the source code? Well, there are some reasons. First I love symfony and would like to contribute to symfony community, and second, askeet was an open source application, and why not to. Third, to show how much I can do. People will know my current skill, so that I might get job requests in the future. ;) Finally, even I open the code, I know I am the one who know the code most. :)

You can download the source code from here:Askeet with symfony1.2

O.K. In a few weeks, I will probably post following topics which I found from developing Askeet with symfony1.2:

  • Playing with sfForm
  • Building complex Criteria
  • Cross filtering between different models in Admin Generator
  • Command task from Web

In English version, it works good. However my language, Japanese, has some problems because of full text search and multibyte issues. We are working on it now, and probably, I will post the fixed one later. Well, I need a svn repository, where to use?

Bloglines feedburner