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

Comments

Popular posts from this blog

How to construct a B+ tree with example

How to show only month and year fields in android Date-picker?

Visitor Counter Script Using PHP