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化して、置いておくのもいいかもしれません。もちろんバグがあることもあると思いますので、それに関しては教えてください。

やっぱり日本語でブログを書く

最近ブログを英語で書いていたのだけども、やっぱりこのブログには日本語で書くことにした。そして英語の方は、以前から眠らせているドメインに書くことにする。

しかし、ブログって難しいのぅ。他人が書いてないことを書きたいんだけども、それをすると調べるのに時間がかかってしまうし、そして、自分で実践するのにさらに時間がかかってしまう。さらにさらに、日本語が下手なので、ここでも時間がかかる。。。うーん。><

Bloglines feedburner