Archive

Archive for October, 2010

Improved Preview Handling

October 10th, 2010 1 comment

Recently the preview handling was improved and partly rewritten. The speed boost by the Fast File Responder was previously described here. Before r632 each preview was rendered by the original image which consumed a lot of time. Now an image preview manager was introduced which reuses existing down sampled images to gain speed. While each conversion from the original image costs about 4.4s, the down sampling from an 600px to 220px requires only 0.2s Further each preview configuration (defined in the Preview Manager Component) might be depend on a greater preview image. These dependencies might trigger the generation of all previews before calculating the requested one.

Unfortunately, the preview cache file schema changed and all previews have to be recalculated again. However, since r639 phTagr supports the batch generation of previews with a CakePHP shell script preview. You can create these previews at the command with

$ cd <phtagr base>
$ cakephp/lib/Cake/Console/cake preview generate
Generate preview files
 
Usage:
cake preview [subcommand] [options]
 
Subcommands:
 
generate  Create preview files
 
To see help on a subcommand use `cake preview [subcommand] --help`
 
Options:
 
--help, -h         Display this help.
--verbose, -v      Enable verbose output.
--quiet, -q        Enable quiet output.
--max              Maximum generation count. Default is 10. Use 0 to
                   generate all previews
--start-chunk      Set the start chunk number. A chunk has a size of 100
                   media. Default is 1
--size             Set the minimum preview size. Default is thumb
                   (choices: mini|thumb|preview|high)
--user             Generate only previews for given user
$ cakephp/lib/Cake/Console/cake preview generate --size mini -v
Page 1/180 (0.56%)
Page 2/180 (1.11%)
Page 3/180 (1.67%)
...

Update 2012-07-22: Updated commands to version 2.2-dev.

Categories: tools, user Tags: ,

Fast File Responder for CakePHP

October 3rd, 2010 1 comment

This article is very useful for all CakePHP applications which deal with lots of photos or other files which are shown in the resulting page. I will introduce an Fast File Responder for CakePHP which immediately sends the file to the client without stepping into the CakePHP stack. It increases the performances drastically to an minimum and got its inspiration from Lightning Fast Caching for CakePHP. The provided approach of the Fast File Responder is used in the open source social web gallery phTagr.

The web gallery phTagr is written on top of the great MVC framework CakePHP and deals with lots of photos. Its photo explorer displays by default 12 photos at once (see demo page). Therefore the CakePHP framework is called 13 times. The first request handles the authorized and the selection of the 12 photos. Then each photo is requested by the client to fetch it and to display it into the page. These requests check again the authorization and correctness of the user for each photo and requires a lot of time since it traverses again the whole MVC stack.

time = 1 x explorer page + 12 x image request = 13 x CakePHP stack = 13 x ~0.40s = 3,40s

Since the first request already checked the authorization of the photos the following 12 requests and checks are redundant and can be omitted. The user session can be used to store these authorization information for the 12 media requests and the photos could be send immediately before the CakePHP stack is called.

time = 1 x explorer page + 12 x fast image request = 1 x CakePHP stack = 0.45s

To do so a Fast File Responder component adds the file information to the session. It adds for each photo request the filename of the preview and an expiration date for safety.

<?php
 
class FastFileResponderComponent extens Object {
  var $controller = null;
  var $components = array('Session', 'FileCache');
  var $sessionKey = 'fastFile.items';
  var $expireOffset = 10; // seconds
 
  function initialize(&$controller) {
    $this->controller = $controller;
  }
 
  function add($key, $filename) {
    if (!is_readable($filename)) {
      return false;
    }
    $files = (array) $this->Session->read($this->sessionKey);
    $files[$key] = array('expires' => time() + $this->expireOffset, 'file' => $filename);
    $this->Session->write($this->sessionKey, $files);
    return true;
  }
 
  function addAll($data) {
    foreach ($data as $key => $filename) {
      $this->add($key, $filename);
    }
  }
}
?>

Than the app/webroot/index.php is modified to check for fast files before the MVC stack is called.

  // ...
  if (!defined('CORE_PATH')) {
    // ...
  }
  // Check the request URI if it matches the URI for photos
  if (isset($_GET['url']) && preg_match('/media\/\w+\/\d+/', $_GET['url'])) {
    require ROOT . DS . APP_DIR . DS . 'fast_file_responder.php';
 
    $fileResponder = new FastFileResponder();
    if ($fileResponder->exists()) {
      $fileResponder->send();
    } else {
      $fileResponder->close();
      unset($fileResponder);
    }
  }
  if (!include(CORE_PATH . 'cake' . DS . 'bootstrap.php')) {
    // ...
  }
  // ...

Finally the Fast File Responder itself located in app/fast_file_responder.php

<?php
 
/** This class enables a fast file response without the framework stack of
 * CakePHP. It checks the session and the URL and returns a valid file */
class FastFileResponder {
  /** Should be same as in app/config/core.php Session.cookie */
  var $sessionCookie = 'CAKEPHP';
  var $sessionKey = 'fastFile';
  var $items = array();
 
  function __construct() {
    $this->startSession();
  }
 
  /** Starts the session if the session sessionCookie is set */
  function startSession() {
    if (!isset($_COOKIE[$this->sessionCookie])) {
      return;
    }
    session_id($_COOKIE[$this->sessionCookie]);
    session_start();
    if (isset($_SESSION[$this->sessionKey])) {
      $this->items = (array) $_SESSION[$this->sessionKey]['items'];
      $this->deleteExpiredItems();
    }
  }
 
  /** Deletes expired itemes from the session list */
  function deleteExpiredItems() {
    if (!count($this->items)) {
      return;
    }
    $now = time();
    foreach ($this->items as $key => $item) {
      if ($item['expires'] < $now) {
        unset($_SESSION[$this->sessionKey][$key]);
      }
    }
  }
 
  /** Simple log function for debug purpos */
  function log($msg) {
    $h = @fopen(dirname(__FILE__) . DS . 'fast_file_responder.log', 'a');
    @fwrite($h, sprintf("%s %s\n", date('Y-M-d h:i:s', time()), $msg));
    @fclose($h);
  }
 
  /** Extracts the item key from the url and returns it. Returns false if no
   * key could be found. This function must be adapted for others */
  function getItemKey() {
    if (!isset($_GET['url'])) {
      return false;
    }
    $url = $_GET['url'];
    if (!preg_match('/media\/(\w+)\/(\d+)/', $url, $matches)) {
      return false;
    }
    return $matches[1] . '-' . $matches[2];
  }
 
  /** Returns the file of the media request */
  function getFilename() {
    $key = $this->getItemKey();
    if (!$key || !isset($this->items[$key])) {
      return false;
    }
    $item = $this->items[$key];
    if ($item['expires'] < time() || !is_readable($item['file'])) {
      return false;
    }
    return $item['file'];
  }
 
  /** Returns an array of request headers */
  function getRequestHeaders() {
    $headers = array();
    if (function_exists('apache_request_headers')) {
      $headers = apache_request_headers();
      foreach($headers as $h => $v) {
        $headers[strtolower($h)] = $v;
      }
    } else {
      $headers = array();
      foreach($_SERVER as $h => $v) {
        if(ereg('HTTP_(.+)', $h, $hp)) {
          $headers[strtolower($hp[1])] = $v;
        }
      }
    }
    return $headers;
  }
 
  /** Evaluates the client file cache and response if the client has still a
   * valid file
   * @param filename Current cache file */
  function checkClientCache($filename) {
    $cacheTime = filemtime($filename);
    $headers = $this->getRequestHeaders();
    if (isset($headers['if-modified-since']) &&
        (strtotime($headers['if-modified-since']) == $cacheTime)) {
      header('Last-Modified: ' . gmdate('D, d M Y H:i:s', $cacheTime).' GMT', true, 304);
      // Allow further caching for 30 days
      header('Cache-Control: max-age=2592000, must-revalidate');
      exit;
    }
  }
 
  function sendResponseHeaders($file) {
    $fileSize = @filesize($file);
    header('Content-Type: image/jpg');
    header('Content-Length: ' . $fileSize);
    header('Cache-Control: max-age=2592000, must-revalidate');
    header('Pragma: cache');
    header('Last-Modified: ' . gmdate('D, d M Y H:i:s', filemtime($file)));
  }
 
  /** Evaluates if a valid cache file exists */
  function exists() {
    return $this->getFilename() != false;
  }
 
  /** Sends the cache file if it exists and exit. If it returns an error
    * occured */
  function send() {
    $filename = $this->getFilename();
    if (!$filename) {
      return false;
    }
    $this->checkClientCache($filename);
    $this->sendResponseHeaders($filename);
 
    $chunkSize = 1024;
    $buffer = '';
    $handle = fopen($filename, 'rb');
    while (!feof($handle)) {
      $buffer = fread($handle, $chunkSize);
      echo $buffer;
    }
    fclose($handle);
    //$this->log("File send: $filename");
    exit(0);
  }
 
  /** Closes the session */
  function close() {
    session_write_close();
  }
}
?>

Now the requested images are shown almost simultaneously with the explorer photo page. Awesome!

Please leave a comment if you liked this proposal.

Categories: cakephp Tags: