Showing posts with label architecture. Show all posts
Showing posts with label architecture. Show all posts

How to implement really small and fast ORM with PHP (Part 7: IDE)

Fork me on GitHub

Queries are gaining more and more complexity, data is getting bigger and bigger. Most optimizations in database technology are done in the database server. This is an approach to optimize queries on the client side.

With this ORM, queries ...
  • don't select more data than needed
  • contain less joins when data is expected to be consistent
  • can be written manually in pure SQL
  • are not written in a new query language

We need a good API, so ...

  • it should be easy to learn
  • method names must be short and intuitive
  • the goal is to map datasets and relations to objects
  • the API should offer method chaining
  • special features like auto-increments should be included
  • the code should be small, no getters and setters
  • the database schema is created before writing PHP code
  • relationships should be defined in the database, not in the code
  • we get low latencies combined with low memory usage

To make things easier, we make some restrictions:

  • only UTF-8
  • only MySQL/MariaDB (mysqli)
  • only PHP 5.4.0+
  • only buffered queries

Our ORM should have the same efficiency as handwritten SQL. So the following statements should produce only 1 query:

  • DBo::Guestbook(42)->Comments()->delete(); // "Guestbook" and "Comments" are tables
  • DBo::Guestbook(42)->Comments()->update('hidden', 1);
  • DBo::Guestbook(42)->Comments()->count();
  • DBo::Student()->Attend()->Lecture()->Uses()->Book(); // "Uses", "Book", etc. are tables

The following statements should only update 1 column:

  • DBo::Guestbook(42)->Comments()->update('hidden', 1);
  • DBo::Guestbook(42)->update('title', 'hello');

The following statements should only select 1 column (that's the hard one):

  • foreach (DBo::Guestbook() as $gb) echo $gb->title;

This gets implemented with a two-pass method: We store the columns used in the first run in DBo::usage_col[code position] and reuse it in the second run. The "code position" is the line where the constructor was called (returned from debug_backtrace()).

Meta data like columns or indexes should be fetched statically into the code (not during runtime). To create a join out of "Guestbook()->Comments()", the relevant columns are chosen during runtime. The syntax is:
  • table.primary_key_field = other_table.table_primary_key_field
  • e.g. sale.id = salepos.sale_id

To export the schema, we use:


// export schema to schema.php
DBo::conn(new mysqli('127.0.0.1', 'root', 'some_pw'));
DBo::exportSchema();

To keep control over the queries, we allow normal queries and debugging:


$entries = DBo::query('SELECT * FROM guestbook WHERE id=?', [42]); // Iterator
foreach ($entries as $obj) {...}

$id = DBo::query('INSERT INTO guestbook VALUES (...)'); // LastInsert ID
// 42

$affected = DBo::query('UPDATE guestbook SET active=0'); // Affected rows
// 10

$subject = DBo::value('SELECT subject FROM guestbook WHERE id=42'); // String
// 'Hello World'

$categories = DBo::values('SELECT DISTINCT categories FROM guestbook'); // Array
// ['Sports', 'Movies', 'Music']

$row = DBo::one('SELECT * FROM guestbook WHERE id=42'); // Array
// [id=>42, title=>'hello']

$row = DBo::keyValue('SELECT id,title FROM guestbook'); // Array
// [42=>'hello', 43=>'world']

$row = DBo::keyValues('SELECT id,title,subject FROM guestbook'); // Array
// [42=>['title'=>'hello', 'subject'=>'world'], 43=>['title'=>...]

echo DBo::Guestbook(42)->Comments(); // SQL String
// SELECT a.* FROM Comments a, Guestbook b WHERE ...

echo DBo::Guestbook(42)->Comments()->explain(); // explain SQL string
// EXPLAIN SELECT ... id | select_type | table | type ...

DBo::Guestbook(42)->Comments()->print_r(); // print_r related comments
// Array( id=... )

We also allow transactions:


DBo::begin();
$dbo = DBo::Guestbook(42);
$dbo->Comments()->delete(); // DELETE FROM Comments ...
$dbo->update('comments_count', 0); // UPDATE Guestbook SET ...
DBo::commit();

Here are some examples how the ORM should work:


// create a new entry in table "Guestbook"
// set attribute values to "hello" and "world"
// finally print out the primary key (auto-generated by auto-increment)
// - gives INSERT INTO Guestbook SET subject='hello', details='world'
$obj = DBo::Guestbook();
$obj->subject = 'hello';
$obj->details = 'world';
echo $obj->insert(); // 43 (from auto_increment)
echo $obj->id; // 43
// or $_POST = ['subject'=>'hello', ...]
echo DBo::Guestbook()->insert($_POST); // 43

// map entry in table "Guestbook" with primary key "42" to "$obj"
// - gives SELECT * FROM Guestbook WHERE id=42
$obj = DBo::Guestbook(42);
if (!$obj->exists()) {...}
echo $obj->subject;
// or
echo DBo::Guestbook(42)->subject;

// update antry in table "Guestbook" with primary key "42", set "hidden" to "1"
// - gives UPDATE Guestbook SET hidden=1 WHERE id=42
$obj = DBo::Guestbook(42);
$obj->hidden = 1;
$obj->update();
// or
DBo::Guestbook(42)->update('hidden', 1);
// or $_POST = ['hidden'=>'1', ...]
DBo::Guestbook(42)->update($_POST);

// delete entry in table "Guestbook" with primary key "42"
// - gives DELETE FROM Guestbook WHERE id=42
DBo::Guestbook(42)->delete();

// increment a field in table "Guestbook" with primary key "42"
// - gives UPDATE Guestbook SET likes=likes+1 WHERE id=42
DBo::Guestbook(42)->update('likes=likes+1');

Doing 1:n and n:m relations should be also very easy:


// get all Comments for Guestbook entry with primary key 42
// join a 1:n relationship (table.id = table2.table_id)
// - gives SELECT * FROM Comments WHERE guestbook_id=42
$comments = DBo::Guestbook(42)->Comments();
foreach ($comments as $comment) {...}

// update comments
// - gives UPDATE Comments SET hidden=1 WHERE guestbook_id=24
// - note that traditional ORMs do one update statement for each dataset
DBo::Guestbook(42)->Comments()->update('hidden', 1);

// update comments with where predicate
// - gives UPDATE Comments SET active=0 WHERE active=1 AND guestbook_id=24
DBo::Guestbook(42)->Comments('active=1')->update('active', 0);

// deleting comments works in the same way
// - gives DELETE FROM Comments WHERE guestbook_id=24
DBo::Guestbook(42)->Comments()->delete();

// n:m Students attend Lectures
// - gives SELECT a.* FROM Lecture a, Attend b WHERE b.student_id=21 AND
// b.lecture_id = a.id
$lectures = DBo::Student(21)->Attend()->Lecture();
foreach ($lectures as $lecture) {...}

// n:m Students attend Lectures, Lecture uses Books
// - gives SELECT a.* FROM Book a, Uses b, Lecture c, Attend d
// WHERE d.student_id = 21 AND d.lecture_id = c.id
// AND c.id = b.lecture_id AND b.book_id = a.id
$books = DBo::Student(21)->Attend()->Lecture()->Uses()->Book();
foreach ($books as $book) {...}

Sometimes it is better to avoid normalization and store multiple values inside a string. This reduces the number of tables, relations and costly joins, e.g. using a string value like "100,101,102" instead of a join. The data can be also encoded as a JSON string with '[100,101,102]' or '["100","101","102"]'. To do the encoding and decoding automatically, the names of the members can be amended with "_arr" and "_json". Here is an example:


// automatic encoding and decoding of values
// - gives UPDATE Guestbook SET tags_arr='sport,music,tv' WHERE id=42
// UPDATE Guestbook SET tags_json='{"a":"b","c":"d"}' WHERE id=42
$obj = DBo::Guestbook(42);
$obj->update('tags_arr', ['sport','music','tv']); // field tags (Varchar)
// or
$obj->update('tags_json', ['a'=>'b', 'c'=>'d']); // field tags2 (Varchar)

$obj = DBo::Guestbook(42);
print_r($obj->tags_arr); // Array([0] => sport\n [1] => music\n [2] => tv)
// or
print_r($obj->tags_json); // Array([a] => b\n [c] => d)

Predicates can be defined in several ways:


// select one dataset
// - gives SELECT * FROM Guestbook WHERE id=10
DBo::Guestbook('id=10');
DBo::Guestbook('id=?', 10);
DBo::Guestbook(['id'=>10]);
DBo::Guestbook(10); // id is a numeric primary key

// select multiple datasets
// - gives SELECT * FROM Guestbook WHERE id IN (10,11,12)
DBo::Guestbook('id in (10,11,12)');
DBo::Guestbook('id in ?', [10,11,12]);
DBo::Guestbook(['id'=>[10,11,12]]);
DBo::Guestbook([10,11,12]); // id is a primary key

// select multiple primary keys
// - gives SELECT * FROM Guestbook WHERE (id,id2) IN ((10,11))
DBo::Guestbook('id=10 and id2=11');
DBo::Guestbook('(id,id2) in ?', [10,11]);
DBo::Guestbook('id=? and id2=?', 10, 11);
DBo::Guestbook(['id'=>10, 'id2'=>11]);
DBo::Guestbook([[10,11]]); // id and id2 are a primary key

// select multiple datasets with multiple primary keys
// - gives SELECT * FROM Guestbook WHERE (id,id2) IN ((10,1),(11,2))
DBo::Guestbook('(id,id2) in ((10,1), (11,2))');
DBo::Guestbook('(id,id2) in ?', [[10,1], [11,2]]);
DBo::Guestbook([[10,1], [11,2]]); // id and id2 are a primary key

// additional predicates
DBo::Guestbook(10, "active=1");
DBo::Guestbook("active=1 and foo=?", "bar");

// limit
foreach (DBo::Order("status=open")->limit(100) as $obj) {...

Joins are skipped when data is expected to be consistent:


foreach (DBo::guestbook(42)->comments() as $comment) {...}
// gives SELECT * FROM comments WHERE guestbook_id=42

Custom SQL can be used:


$comments = DBo::object("SELECT * FROM Comments WHERE guestbook_id=?", [42]);
foreach ($comments as $comment) {...}

Aggregate functions can be used:


// SELECT count(*) FROM order where customer_id=42
DBo::Customer(42)->Order()->count(); // 40

// SELECT sum(price) FROM order where customer_id=42
DBo::Customer(42)->Order()->sum('price'); // 600

// SELECT avg(*) FROM order where customer_id=42
DBo::Customer(42)->Order()->avg('price'); // 23

// SELECT stddev(*) FROM order where customer_id=42
DBo::Customer(42)->Order()->stddev('price'); // 8

Data can be archived:


// archiving is not yet implemented

// create archive table
// CREATE TABLE Customer_archive LIKE Customer;
// # remove primary & auto_increment, add index and timestamp
// ALTER TABLE Customer_archive DROP primary key, MODIFY id int,
// ADD index(id), ADD ts timestamp;

// archive record before updating
// Customer.id = primary key, Customer_archive.id = index
// INSERT INTO test.Customer_archive
// SELECT a.*, now() FROM test.Customer a WHERE a.id=42
// UPDATE test.Customer a SET a.hidden=1 WHERE a.id=42
DBo::Customer(42)->archive()->update('hidden', 1);

// copy record to table
// INSERT INTO somedb.sometable
// SELECT a.* FROM test.Customer a WHERE a.id=42
DBo::Customer(42)->copyTo('somedb.sometable');

Custom classes can also be used:


class DBo_Sales extends DBo {
public function buildData($insert=false) {
// custom validation
$data = parent::buildData();
if (empty($data["some_val"])) throw new Exception(...);
return $data;
}

public function insert($arr=null) {
// execute some pre trigger
parent::insert($arr);
// execute some post trigger
}

public function delete() {
if ($this->status != 'draft') throw new Exception(...);
parent::delete();
}

public function completed() {
return DBo::query('SELECT * FROM sales WHERE completed=1');
}

public function get_age() {
return date_diff(new DateTime($this->birthdate), new DateTime())->y;
}
}

// DBo_{table} is automatically used as class
print_r(DBo::Sales());
=> DBo_Sales Object (...

print_r(DBo::Sales()->completed());
=> returns completed() from Dbo_Sales

print_r(DBo::object("SELECT * FROM Sales")->completed());
=> returns completed() from Dbo_Sales

// get_{field}() is automatically used when the member not exists
print_r(DBo::Sales()->age);
=> returns get_age()

The database connection should be opened when the first query is being executed. We use:


class mysqli_lazy extends mysqli {
public function query($query) {
if (!@$this->host_info) parent::connect('127.0.0.1', 'root', '', 'db');
// or persistent connection: 'p:127.0.0.1'
return parent::query($query);
}
}
DBo::conn(new mysqli_lazy, 'db');

// instead of
DBo::conn(new mysqli('127.0.0.1', 'root', '', 'db'), 'db');
// or persistent connection: 'p:127.0.0.1'

To get all queries on stdout, we use:


class mysqli_log extends mysqli {
public function query($query) {
echo $query."\n";
return parent::query($query);
}
}
DBo::conn(new mysqli_log('127.0.0.1', 'root', '', 'db'), 'db');

The fastest way to get data is reading it from a hash table in the main memory. With the APC extension, we can persist data between many requests. The syntax for caching looks like this:


// caching is not yet implemented

// cache categories for 60 seconds
$payments = DBo::Categories()->cache(60);
// [{id=>0, name=>Sports}, {id=>1, name=>Movies}, ...]

$payments = DBo::Categories()->oarray(60);
// [[id=>0, name=>Sports], [id=>1, name=>Movies], ...]

$payments = DBo::Categories()->ovalues('col_name', 60);
// [Sports, Movies, ...]

$payments = DBo::Categories()->okeyValue('col_id', 'col_name', 60);
// [0=>Sports, 1=>Movies, ...]

$payments = DBo::Categories()->count(60); // 42

Broken queries or connection errors can be handled with try-catch:


try {
DBo::conn(new mysqli('127.0.0.1', 'root', '', 'db'), 'db');
DBo::query('select * from invalid');
}
catch (mysqli_sql_exception $e) {
echo $e->getMessage();
exit(1);
}

If column names are ambiguous, we need to prefix them with "@":


// SELECT a.* FROM app a, os b WHERE a.os_id=b.id AND id=42 AND id=13
DBo::os('id=42')->app('id=13');

// SELECT a.* FROM app a, os b WHERE a.os_id=b.id AND b.id=42 AND a.id=13
DBo::os('@id=42')->app('@id=13');

Security:

Scalar parameters are escaped automatically if the previous parameter is a string. When using scalar parameters without predicates, you need to cast them manually:


DBo::Guestbook('id=?', $_GET['id']); // automatic
DBo::Guestbook((int)$_GET['id']); // manual!
DBo::Guestbook((array)$_GET['ids']); // manual!

Other parameters and field names are automatically escaped:


DBo::Guestbook([ $_GET['id'], $_GET['val'] ]); // automatic
DBo::Guestbook([ 'id' => $_GET['id'] ]); // automatic

DBo::Guestbook()->setFrom($_POST)->insert(); // automatic
DBo::Guestbook()->limit($_POST['limit']); // automatic

IDE integration: (e.g. PhpStorm)


// auto-generation of PHPdoc hints is not yet implemented

// type hint
$sales = DBo::Sales(); /* @var $sales DBo_Sales */

// or class hint
/**
* @method static DBo_Sales Sales some description
*/
class DBo implements IteratorAggregate {...}

// or extended class hint
/**
* @method static DBo_Sales Sales some description
*/
class HDBo extends DBo {}

$sales = HDBo::Sales();

/**
* @property int id
* @property decimal_6_2 price
* @property varchar_40 desc
*/
class DBo_Sales extends DBo {}





Keyboard shortcuts:
- auto-complete: Ctrl+space
- documentation: Ctrl+q
- variable info: Ctrl+mouseover


The implementation in detail:

Running DBo::Student()->Attend()->Lecture()->array() as a single query can be implemented by using a stack. The query itself presents a chain of tables being joined by some predicates. Attend() and others are handled by PHP's magic __call(). Every table in the chain pushes a new element on the stack containing the name of the table, the primary key and the required predicate(s).

Even with the best ORM, you can still write bad code:


foreach (DBo::os(10)->app() as $app) {
if (!$app->active) continue; // bad
...
}
foreach (DBo::os(10)->app('active=1') as $app) {...} // good, less data

foreach (DBo::os(10)->app() as $app) {
foreach ($app->compontent() as $compontent) {...} // bad, many queries
}
foreach (DBo::os(10)->app()->compontent() as $compontent) {...} // good

foreach (DBo::os(10)->app() as $app) $app->update('active', 1); // bad
DBo::os(10)->app()->update('active', 1); // good, 1 query

foreach (DBo::sale(10)->salepos() as $salepos) {
if (DBo::logistics($salepos->id, 'shipped=1')->exists()) {
$salepos->update('complete', 1); // bad
}
}
DBo::sale(10)->salepos()->logistics('shipped=1')
->salepos()->update('complete', 1); // good

In general, it is better to select as little data as possible from the database. Joins are often expensive, but if the database handles them correctly, it is much more efficient than performing joins directly in PHP.

Changelog:


The code is available at: https://github.com/thomasbley/DBo (~500 loc, test coverage 100%)


ircmaxell: Framework Fixation - An Anti Pattern

ircmaxell: Framework Fixation - An Anti Pattern, short summary:
  • delegation of architecture decisions to frameworks may not be optimal or even wrong
  • only use frameworks when doing prototypes or projects you don't need to maintain
  • frameworks don't save time/money in the long term
  • frameworks don't make it easier to hire good programmers
  • using a framework prevents developers from understanding backgrounds
  • not all framework developers are super heroes (look at the bug trackers ...)
  • favor libraries over frameworks
Quotes:
  • Most of the monolithic frameworks are red herrings...solving problems of yesterday
  • The typical mantra of "just let a framework do it for you" helps nothing except creating brainless code monkeys. (source)
My opinion:
  • frameworks are not so bad, but you need to evaluate them very carefully before using
  • evaluation means checking code quality, features AND performance matching the specific problems of your business
  • choosing the wrong framework throws your business back for years
  • make sure that you can maintain the framework yourself without the maintainers
  • make sure how to upgrade to new versions of the framework
  • only use a framework if your code size gets smaller
  • writing your own framework to solve only your problems can be quite good
  • frameworks can be used very well to find solutions for problems in your own code

Using V8 Javascript engine as a PHP extension (update: write PHP session)

"We Are Borg PHP. We Will Assimilate You. Resistance Is Futile!"
Just got to something described as: This extension embeds the V8 Javascript Engine into PHP.
It is called v8js and the documentation is already available on php.net, examples and the sources are here. V8 is known to work well in browsers and webservers like node.js, but does it work inside PHP? YES!

Here is the installation on Ubuntu 12.04:

sudo apt-get install php5-dev php-pear libv8-dev build-essential
sudo pecl install v8js
sudo echo extension=v8js.so >>/etc/php5/cli/php.ini
sudo echo extension=v8js.so >>/etc/php5/apache2/php.ini
php -m | grep v8

Let's run a small test script:

<?php
$start = microtime(true);
$array = array();
for ($i=0; $i<50000; $i++) $array[] = $i*2;

$array2 = array();
for ($i=20000; $i<21000; $i++) $array2[] = $i*2;

foreach ($array as $val) {
foreach ($array2 as $val2) if ($val == $val2) {}
}
echo (microtime(true)-$start)."\n"; // 8.60s


$start = microtime(true);
$v8 = new V8Js();
$JS = <<< EOT
var array = [];
for (i=0; i<50000; i++) array.push(i*2);

var array2 = [];
for (i=20000; i<21000; i++) array2.push(i*2);

for (key=0; key<array.length; key++) {
for (key2=0; key2<array2.length; key2++) if (array[key] == array2[key2]) {}
}
print('done.');
EOT;
$v8->executeString($JS, 'basic.js');
echo ' '.(microtime(true)-$start)."\n"; // 3.49s

Using Javascript – inside PHP – can make some operations at least 2 times faster.
Running the same Javascript code with node.js gives:

time node /tmp/test.js
real 0m0.729s
Since v8js is currently in beta status, we can expect even more performance coming with the next releases.

Here is an example using databases, sessions and request handling from PHP inside V8:

// v8js currently crashes with mysqli_result, so wrap it
class DB extends MySQLi {
public function query($sql) {
return parent::query($sql)->fetch_all(MYSQLI_ASSOC);
}
}

// v8js currently has no support for references or magic __set(), so wrap it
class Session {
public function set($key, $value) {
$_SESSION[$key] = $value;
}
public function __get($key) {
if (isset($_SESSION[$key])) return $_SESSION[$key];
return null;
}
public function toJSON() {
return $_SESSION;
}
}

session_start();
$js = new V8Js();
$js->db = new DB('localhost', 'root', '', 'mydb');
$js->session = new Session();
$js->request = $_REQUEST;

$js->executeString(<<<EOT
print( PHP.db.query('select * from some_table limit 1')[0].some_column );
print( PHP.request.hello );

print( JSON.stringify( PHP.session ) ); // calls toJSON
print( PHP.session.invalid ); // null
print( PHP.session.hello );
PHP.session.set('hello', 'world');
EOT
);

More coming soon: Javascript form validation on server side, Javascript template rendering on server side, APC-Cache

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

Disadvantages of ORM

ORM has attracted a lot of attention in the last years. So let's get a bit deeper into it.

The biggest advantage of ORM is also the biggest disadvantage: queries are generated automatically
  • queries can't be optimized
  • queries select more data than needed, things get slower, more latency
    (some ORMs fetch all datasets of all relations of an object even though only 1 attribute is read)
  • compiling queries from ORM code is slow (ORM compiler written in PHP)
  • SQL is more powerful than ORM query languages
  • database abstraction forbids vendor specific optimizations

Other problems coming up with ORM
  • compiling ORM logic from phpDoc instructions or XML files is slow, but can be cached
  • ORM validates relations and field names outside the database, but can't keep relations consistent
  • ORM libraries are often used in projects without making a benchmark before
  • ORM libraries are often used because the documentation of the library says it is very fast
  • ORM libraries are often used by default without checking the project's needs
  • database abstraction is often required but changing the database never happens
  • databases are not object oriented
  • ORM violates the basic database performance principle: you get the best performance when your data is stored in the same structure it gets read

General coding problems with ORM
  • having objects instead of SQL, programmers tend to write joins directly in PHP
  • ORM code can be much longer than normal code with PHP and SQL
    (increase of complexity, error rates and maintenance efforts)
  • how to handle null values? (assign null => isset gives false)
  • people often document PHP code but not the database schemas
    (e.g. empty comments in MySQL fields and tables, docs not up-to-date)
  • new versions of ORM libraries often forbid reusing older ORM code
  • slow code is often wrapped with caching, so you always serve old data

Where can ORM be good?
  • avoid building SQL strings for simple insert, update, delete
  • using ORM with magic getters/setters in PHP
  • allow models to inherit attributes and methods from other models
  • separate models from views and controllers
  • centralize validation rules, save or delete methods to one class per entity
  • handle escaping and serialization of values automatically

Performance in numbers?
e.g. Doctrine 2, watch slide 50 and 54: Doctrine is >3 times slower than raw PHP on 20 inserts, imagine what happens with 20000 ... real numbers are much slower, see slide 47, here the authors only benchmarked flush() instead of the whole code

Coming soon: How to write a really small and fast O/R-mapper with PHP