Tuesday, July 17, 2012

How to write a really small and fast controller with PHP (update: benchmark Slim, Silex, Zend Framework, Symfony2)

To handle a lot of traffic, we need a fast controller with very little memory overhead. First, we implement a dynamic controller.

The design is based on the micro frameworks Slim and Silex. The first example maps the URL "http://server/index.php/blog/2012/03/02" to a function with the parameters $year, $month and $day:
// index.php, handle /blog/2012/03/02
$app = new App();
$app->get('/blog/:year/:month/:day', function($year, $month, $day) {
  printf('%d-%02d-%02d', $year, $month, $day);
});
Our controller is a class named App and uses the get() function to map a GET request. Parameters mapped to the function are marked with a colon. Optional parameters are written inside brackets. Here is an example:
// handle /blog, /blog/2012, /blog/2012/03 and /blog/2012/03/02
$app = new App();
$app->get('/blog(/:year(/:month(/:day)))', function($year=2012, $month=1,
  $day=1) {
    printf('%d-%02d-%02d', $year, $month, $day);
});

Instead of printf(), we can use quote() to replace < and > with HTML entities. In the second example we map "index.php/product/42/super-coding-book" to a function with the parameter $id. We use * in the URL to match any character different from "/":
// index.php, handle /product/42/seo-text
$app = new App();
$app->get('/product/:id/*', function($id) use ($app) {
  echo 'You selected '.$app->quote($id);
});

In the third example we map "index.php/profile/jdoe" to a function with the parameter $username and render the output with a PHP template:
// index.php, handle /profile/jdoe
$app = new App();
$app->get('/profile/:username', function($username) use ($app) {
  $app->message = 'Hello '.$username;
  $app->display('profile.php');
});

// profile.php
<html><body>
Message: <?= $this->quote($this->message) ?>
</body></html>

Instead of using anonymous functions, we can also forward the request to a normal function. In the next example, the request is forwarded to the static method greet() in the class Hello:
// forwards /hello/world to Hello::greet('world')
$app = new App();
$app->get('/hello/:name', 'Hello::greet');

class Hello {
  static function greet($name) {
    echo 'Hello '.$name;
  }
}

The controller is also able to forward the request to more than one function. In this example, the request also calls header() and footer() from the Page class:
// forwards /welcome to Page::header(); User::show(); Page::footer();
$app->get('/welcome', ['Page::header', 'User::show()', 'Page::footer']);

To make testing easier, we can use a decorator subclassing to convert the output of a function to JSON:
$app = new AppJson();
$app->get('/json/range', function() {
  return range(0, 10);
});

// output
[0,1,2,3,4,5,6,7,8,9,10]

Instead of named parameters, we can also use anonymous parameters with the get_p() function:
// index.php, handle /blog/2012/03/02
$app = new App();
$app->get_p('/blog/:p/:p/:p', function($year, $month, $day) {
  printf('%d-%02d-%02d', $year, $month, $day);
});
Note that get_p() is 40 percent faster than get().

And finally, here is the controller:
set_exception_handler('App::exception'); // bootstrap

class App {
  protected $_server = [];

  public function __construct() {
    // skipped mocking here
    $this->_server = &$_SERVER;
  }

  public function get($pattern, $callback) {
    $this->_route('GET', $pattern, $callback);
  }

  public function get_p($pattern, $callback) {
    $this->_route_p('GET', $pattern, $callback);
  }

  public function delete($pattern, $callback) {
    $this->_route('DELETE', $pattern, $callback);
  }

  protected function _route($method, $pattern, $callback) {
    if ($this->_server['REQUEST_METHOD']!=$method) return;

    // convert URL parameter (e.g. ":id", "*") to regular expression
    $regex = preg_replace('#:([\w]+)#', '(?<\\1>[^/]+)',
      str_replace(['*', ')'], ['[^/]+', ')?'], $pattern));
    if (substr($pattern,-1)==='/') $regex .= '?';

    // extract parameter values from URL if route matches the current request
    if (!preg_match('#^'.$regex.'$#', $this->_server['PATH_INFO'], $values)) {
      return;
    }
    // extract parameter names from URL
    preg_match_all('#:([\w]+)#', $pattern, $params, PREG_PATTERN_ORDER);
    $args = [];
    foreach ($params[1] as $param) {
      if (isset($values[$param])) $args[] = urldecode($values[$param]);
    }
    $this->_exec($callback, $args);
  }

  protected function _route_p($method, $pattern, $callback) {
    if ($this->_server['REQUEST_METHOD']!=$method) return;

    // convert URL parameters (":p", "*") to regular expression
    $regex = str_replace(['*','(',')',':p'], ['[^/]+','(?:',')?','([^/]+)'],
      $pattern);
    if (substr($pattern,-1)==='/') $regex .= '?';

    // extract parameter values from URL if route matches the current request
    if (!preg_match('#^'.$regex.'$#', $this->_server['PATH_INFO'], $values)) {
      return;
    }
    // decode URL parameters
    array_shift($values);
    foreach ($values as $key=>$value) $values[$key] = urldecode($value);
    $this->_exec($callback, $values);
  }

  protected function _exec(&$callback, &$args) {
    foreach ((array)$callback as $cb) call_user_func_array($cb, $args);
    throw new Halt(); // Exception instead of exit;
  }

  // Stop execution on exception and log as E_USER_WARNING
  public static function exception($e) {
    if ($e instanceof Halt) return;
    trigger_error($e->getMessage()."\n".$e->getTraceAsString(), E_USER_WARNING);
    $app = new App();
    $app->display('exception.php', 500);
  }

  public function quote($str) {
    return htmlspecialchars($str, ENT_QUOTES);
  }

  public function render($template) { 
    ob_start();
    include($template);
    return ob_get_clean();
  }

  public function display($template, $status=null) {
    if ($status) header('HTTP/1.1 '.$status);
    include($template);
  }

  public function __get($name) {
    if (isset($_REQUEST[$name])) return $_REQUEST[$name];
    return '';
  }
}

class AppJson extends App {
  protected function _exec(&$callback, &$args) {
    header('Content-Type: application/json; charset=utf-8');
    echo json_encode(call_user_func_array($callback, $args));
    throw new Halt(); // Exception instead of exit;
  }
}

// use Halt-Exception instead of exit;
class Halt extends Exception {}

The controller fits perfectly into a high traffic scenario:
  • less than 100 lines of code
  • memory overhead less than 256 KB
  • runtime less than 1 ms

Here are some benchmarks (1.4 GHz):
Name without APC [seconds] with APC [seconds]
App 0.0009 0.0005
Slim 1.6.4 0.0159 0.0083
Silex 0.0596 0.0221
ZendFramework 1.11 0.1625 0.0631
Symfony 2.0.16 0.1968 0.0362
The numbers show that our controller is 17 times faster than Slim, 44 times faster than Silex, 72 times faster than Symfony and 126 times faster than ZF.

Here is the code:
// App
$start = microtime(true);
require 'App.php';
try {
  $app = new App();
  $app->get('/hello/:name', function ($name) use ($app) {
    $app->name = $name;
    $app->display('foo.php');
    // foo.php: echo 'Hello '.$this->quote($this->name);
  });
} catch (Exception $e) {}
echo ' '.(microtime(true)-$start);

// Slim
$start = microtime(true);
require 'Slim/Slim.php';
$app = new Slim();
$app->get('/hello/:name', function ($name) use ($app) {
  $app->render('foo.php', ['name' => $name]);
  // foo.php: echo 'Hello '.htmlspecialchars($name);
});
$app->run();
echo ' '.(microtime(true)-$start);

// Silex
$start = microtime(true);
require 'silex/vendor/autoload.php'; 
$app = new Silex\Application();
$app->get('/hello/{name}', function($name) use($app) {
  require('templates/foo.php');
  // foo.php: echo 'Hello '.htmlspecialchars($name);
}); 
$app->run(); 
echo ' '.(microtime(true)-$start);

// ZendFramework
// zf create project helloworld
// helloworld/application/controllers/IndexController.php
class IndexController extends Zend_Controller_Action {
  public function helloAction() {
    $this->view->name = $this->getRequest()->getParam('name');
  }
}
// helloworld/application/views/scripts/index/hello.phtml
Hello <?= $this->escape($this->name); ?>
// helloworld/public.php
$start = microtime(true);
...
echo ' '.(microtime(true)-$start);
// GET /index.php/index/hello/?name=world

// Symfony2
$start = microtime(true);
require_once __DIR__.'/../app/bootstrap.php.cache';
require_once __DIR__.'/../app/AppKernel.php';
$kernel = new AppKernel('dev', false);
$kernel->loadClassCache();
$kernel->handle(Symfony\Component\HttpFoundation\Request::createFromGlobals())->send();
echo ' '.(microtime(true)-$start);
// src\Acme\DemoBundle\Controller\DemoController.php
namespace Acme\DemoBundle\Controller;
use Symfony\Bundle\FrameworkBundle\Controller\Controller;
use Symfony\Component\HttpFoundation\Response;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Route;
class DemoController extends Controller {
  /**
   * @Route("/hello/{name}", name="_demo_hello")
   */
  public function helloAction($name) {
    return new Response('Hello '.htmlspecialchars($name, ENT_QUOTES));
  }
}
// GET /web/index.php/demo/hello/World

Lessons learned:
  • A good controller can speed up requests by a factor of 100
  • A good controller is the base for all kinds of performance optimizations
  • Controllers included in PHP frameworks are slow

Next: even more performance with static controller, parameter validation, Post and Put methods, File uploads, benchmark ZendFramework 2.0, Symfony 2.1

11 comments:

  1. this is so useful, used it in 2 projects so far works very well..

    btw u r using $this in static exception function which i fixed..

    great job thanks a lot

    ReplyDelete
    Replies
    1. Thanks for the hint, I've changed the code.

      Delete
    2. can you show me how you did it?

      Delete
  2. Hi
    Thx
    What about github for this project ? :)

    ReplyDelete
    Replies
    1. The idea behind the article is more to show how to implement it. The goal is not to provide a complete framework or component.

      Delete
  3. why do u have only functions for get and delete?

    ReplyDelete
    Replies
    1. The other methods are on my TODO list. I'll add them as soon as possible.

      Delete
  4. I like it, how to manage database connection? I hate orm, i prefer using data mapper, do you have an idea how to manage model so that it will not slow down this small controller?

    Thanks

    ReplyDelete
    Replies
    1. I have also written an article on orm: http://we-love-php.blogspot.de/2012/08/how-to-implement-small-and-fast-orm.html

      Delete
  5. Can you add the code to extend all the Request methods and a middleware? If not provide on ways to implement these? I would love to use it.

    ReplyDelete
    Replies
    1. you can do:

      $app = new App();
      $app->db = new mysqli();

      $app->get('/product/:id', function($id) use ($app) {
      $obj = new SomeController($app);
      $obj->getProduct($id, $_GET['filter'] ?? null);
      });
      $app->post('/product', function() use ($app) {
      $obj = new SomeController($app);
      $obj->createProduct($_POST['foo'] ?? '', $_POST['bar'] ?? '');
      });

      class SomeController {
      protected $app;

      public function __construct(App $app) {
      $this->app = $app;
      }

      public function getProduct($id, $filter) {
      if (!empty($filter)) {
      $this->app->db->query('...');
      }
      $this->app->message = 'getProduct '.$id;
      $this->app->display('product.php');
      }
      public function createProduct($foo, $bar) {
      // ...
      }
      }

      class App {
      public function post($pattern, $callback) {
      $this->_route('POST', $pattern, $callback);
      }
      }

      // curl -X GET http://myhost/index.php/product/42?filter=foo
      // curl -X POST -d "foo=41&bar=42" http://myhost/index.php/product

      Delete

Labels

performance (23) benchmark (6) MySQL (5) architecture (5) coding style (5) memory usage (5) HHVM (4) C++ (3) Java (3) Javascript (3) MVC (3) SQL (3) abstraction layer (3) framework (3) maintenance (3) Go (2) Golang (2) HTML5 (2) ORM (2) PDF (2) Slim (2) Symfony (2) Zend Framework (2) Zephir (2) firewall (2) log files (2) loops (2) quality (2) real-time (2) scrum (2) streaming (2) AOP (1) Apache (1) Arrays (1) C (1) DDoS (1) Deployment (1) DoS (1) Dropbox (1) HTML to PDF (1) HipHop (1) OCR (1) OOP (1) Objects (1) PDO (1) PHP extension (1) PhantomJS (1) SPL (1) SQLite (1) Server-Sent Events (1) Silex (1) Smarty (1) SplFixedArray (1) Unicode (1) V8 (1) analytics (1) annotations (1) apc (1) archiving (1) autoloading (1) awk (1) caching (1) code quality (1) column store (1) common mistakes (1) configuration (1) controller (1) decisions (1) design patterns (1) disk space (1) dynamic routing (1) file cache (1) garbage collector (1) good developer (1) html2pdf (1) internationalization (1) invoice (1) just-in-time compiler (1) kiss (1) knockd (1) legacy code (1) legacy systems (1) logtop (1) memcache (1) memcached (1) micro framework (1) ncat (1) node.js (1) openssh (1) pfff (1) php7 (1) phpng (1) procedure models (1) ramdisk (1) recursion (1) refactoring (1) references (1) regular expressions (1) search (1) security (1) sgrep (1) shm (1) sorting (1) spatch (1) ssh (1) strange behavior (1) swig (1) template engine (1) threads (1) translation (1) ubuntu (1) ufw (1) web server (1) whois (1)