Added by Michael Sheakoski, last edited by Wil Sinclair on Jul 30, 2006  (view change)

Labels

 

Download PathRouter.zip

Latest changes 7-30-2006

  • added support for an alternative "controllers in subdirs" syntax, dir1_dir2_controller, to MJS_Controller_Dispatcher
  • proposed by Christopher Thompson

Latest changes 7-26-2006

  • fixed a bug where if a var with a default followed a path that wasn't a /, the regex wasn't making the trailing / optional
  • thanks to Richard Ingham for the fix!

Latest changes 7-25-2006

  • moved $path = trim($path, '/'); from MJS_Controller_PathRouter back into MJS_Controller_Router_PathRoute to maintain compatibility with RewriteRouter for now
  • simplified using controllers in subdirectories. No more overrides array (what was I thinking? ) Look below for updated instructions.

Download Stripper.php

Makes .php files in the Zend Framework on average 80% smaller by stripping out comments and whitespace. By reducing the filesize and making parsing easier, it makes your apps quicker. No original files are modified. See instructions in Stripper.php for details.


Overview of the classes:


MJS_Controller_PathRouter is an improved version of Zend_Controller_RewriteRouter.  I created it because RewriteRouter is not cross platform (does not work on IIS due to its reliance on REQUEST_URI), it doesn't generate a correct "RewriteBase", and I was annoyed that it forced me to use the default routes. MJS_Controller_PathRouter fixes all of these issues.

MJS_Controller_Router_PathRoute is a rewrite of Zend_Controller_Router_Route. It was written to address the shortcomings in Zend_Controller_Router_Route.  Mainly the fact that variables could only be separated by a '/' and parameters could not be captured as a string but only a key/value combination.  MJS_Controller_Router_PathRoute is 100% backwards compatible with the most recent Zend_Controller_Router_Route in SVN so you can use it with either MJS_Controller_PathRouter or Zend_Controller_RewriteRouter.

MJS_Controller_Dispatcher is an extension of Zend_Controller_Dispatcher that adds the ability to have controllers in subdirectories

MJS_View_Helper_Url is the same as Zend_View_Helper_Url with a change to call $router->getBasePath() instead of $router->getRewriteBase() for compatibility with MJS_Controller_PathRouter


Features:


MJS_Controller_PathRouter

  • compatible with Apache with and without mod_rewrite, running PHP as either a SAPI module or cgi/fastcgi
  • compatible with IIS with and without ISAPI_Rewrite, running PHP as either an ISAPI module or cgi/fastcgi
  • works correctly on 1and1 web hosting servers
  • automagic default routes. Does not force you to use the default routes if you define your own routes
  • accurately sets the BasePath (RewriteBase) and takes into account whether or not you are using mod_rewrite/ISAPI_Rewrite. PathRouter will set the BasePath to /path/to/base if you are using rewrite, or /path/to/base/index.php if you are not using rewrite
  • since BasePath is set correctly, PathRouter also processes the incoming REQUEST_URI and determines the route more reliably than Zend_Controller_RewriteRouter
  • most likely you will not need to call setBasePath() aka setRewriteBase() yourself because of the above improvements it will already be set correctly in the first place
  • on average, performance is equal to and sometimes better than Zend_Controller_RewriteRouter


MJS_Controller_Router_PathRoute

  • :variables can be defined anywhere and in any combination in your route, not just in between /'s. This allows for Zend Framework to have the same advanced matching capabilities as mod_rewrite and the most advanced URL matching out of any PHP framework.
  • PathRoute has more advanced route matching than Ruby on Rails
  • :variables can be next to each other
  • wildcards can be defined anywhere in your route and you can use more than one wildcard as opposed to Zend_Controller_Router_Route which only allows a single wildcard at the end of the route with a hardcoded var1/value1/var2/value2 format
  • you can prefix or append text to a variable after it has been matched in your url in order to modify it before it gets to the next step in the routing process
  • because of the above feature, you can prefix a folder name to a :controller which will allow you to have controllers in subdirectories.
  • syntax and behavior is 100% backwards compatible with the latest Zend_Controller_Router_Route so none of your code has to be changed
  • can easily be used as an addRoute() plugin to Zend_Controller_RewriteRouter
  • less overhead when defining a route. Absolutely no processing occurs until match() or assemble() is called. This should potentially save CPU cycles and memory for sites with large numbers of routes. Just as a comparison, Zend_Controller_Router_Route on each call to __construct() performs 2 typecasts, explode, a foreach loop containing 2 if statements, and 2 calls to substr(). Let's say you have a site with 100 routes and the 90th route you defined is a match. Using PathRoute you will only parse and process 10 routes but with Zend_Controller_Router_Route you parse all 100 routes every request whether or not you used them in addition to processing 10 routes before the 90th is matched.


How To Install:


Download PathRouter.zip and unzip the MJS folder into your library folder. If you created the folders in the correct place your folder structure should look like:

/path/to/library
            |-- Zend
            |-- MJS


Quick Start:


Your index.php (bootstrap) file should look something like this:

<?php

// Add Zend Framework to the PHP include path
set_include_path(get_include_path() . PATH_SEPARATOR . 'path/to/trunk/library');

// If you don't have an __autoload() function defined you need to require the files below
require_once 'Zend.php';
require_once 'Zend/View.php';
require_once 'Zend/Controller/Front.php';
require_once 'MJS/Controller/PathRouter.php';
require_once 'MJS/Controller/Dispatcher.php';

// Create a Zend_View object
$view = new Zend_View;
$view->setScriptPath('path/to/views');
Zend::register('view', $view);

// Create a MJS_Controller_PathRouter object
$router = new MJS_Controller_PathRouter();

// If you add your own routes and still want to use the default routes too then you need to
// call addDefaultRoutes().  If you don't add any of your own routes you do not need this line
// because the default routes will be used automatically.
$router->addDefaultRoutes();

// PathRouter can use the same routes as RewriteRouter
$router->addRoute('news', new Zend_Controller_Router_Route('news/:controller/:action/:id'));

// PathRoute routes are backwards compatible with Zend_Controller_Router_Route so the same syntax works
$router->addRoute('news', new MJS_Controller_Router_PathRoute('news/:controller/:action/:id'));

// but now you can also add routes which require advanced matching
$router->addRoute('logViewer',
    new MJS_Controller_Router_PathRoute(
        ':controller/:action/customer{:id}httpd-error.:ext',
        array(),
        array('id' => '\d+')
    )
);

// Create a Zend_Controller_Front object
$controller = Zend_Controller_Front::getInstance();
$controller->setRouter($router);
// MJS_Controller_Dispatcher is only needed if you are using the controllers in subdirectories feature
$controller->setDispatcher(new MJS_Controller_Dispatcher());
$controller->run(path/to/application/controllers');
:variable names follow the same rules as PHP

When naming :variables in your PathRoutes, use the PHP variable naming conventions. Basicly the name can only consist of the following characters: _, A-Z, 0-9, and ASCII characters from 127 through 255.
For more information please look at http://php.net/variables


Important notes about MJS_Controller_PathRouter and default routes:


If you do not set any routes with $router->addRoute(), PathRouter will use the built in default routes for '' and ':controller/:action/*'. If you do set your own routes, the built in default routes will not be used unless you call $router->addDefaultRoutes() before your other addRoute()'s


Controllers in subdirectories:


There are only two things you have to know to use controllers in subdirectories:

  1. In your defaults array you have to specify a path to prefix to the :controller var by using the special prefix syntax: '+controller' => 'mySubDir/'
  2. You must call $controller->setDispatcher(new MJS_Controller_Dispatcher()) before calling $controller->run() or $controller->dispatch() in order for the controller to locate controllers/actions in subdirectories. This is only temporary until the Zend Dispatcher supports subdirs.


Let's say you have your controllers organized like this:

/path/to/controllers
            |-- admin
                  |-- IndexController.php
                  |-- UsersController.php
            |-- IndexController.php
            |-- FooController.php
            |-- BarController.php

You can access the actions in admin/AccountsController by defining your route like this:

$router->addRoute('AdminUsers',
    new MJS_Controller_Router_PathRoute(
        'admincp/:controller/:action',
        array('+controller' => 'admin' . DIRECTORY_SEPARATOR)
    )
);

When you type in a URL such as http://mysite.com/admincp/users/edit it will assign 'controller' => 'users' and 'action' => 'edit' but then the prefix default will change it to 'controller' => 'admin/users'


Using the special Prefix and Append defaults:

The above example used the prefix default to add a subdirectory to a controller. You are not limited to just the :controller variable. You can prefix any variable that is matched in your URL by putting '+variable' => 'myPrefix' in your defaults array. You can also add text after your matched variables using the append default. By putting 'variable+' => 'addedToEnd' in your defaults array you can append to any variable that is matched in your URL.

For example:

$router->addRoute('myRoute',
    new MJS_Controller_Router_PathRoute(
        ':controller/:action',
        array(
            '+controller' => 'this_text_is_before_'),
            'controller+' => '_and_this_is_after',
            'action+'     => '-appendedToAction'
        )
    )
);

When you type in a URL such as http://mysite.com/ctrl/act it will assign 'controller' => 'ctrl' and 'action' => 'act' but then the the prefix and append defaults will change them to 'controller' => 'this_text_is_before_ctrl_and_this_is_after' and 'action' => 'act-appendedToAction'.


Examples:


In Zend_Controller_Router_Route, routes are limited to only one variable between each '/' in the url. Now you can define routes with variables anywhere such as:

$router->addRoute(
    'logViewer',
    new MJS_Controller_Router_PathRoute(
        ':controller/:action/file:id.:ext',
        array(),
        array('id' => '\d+')
    )
);

matches: http://mysite.com/log/view/file206.xml
Array
(
    [controller] => log
    [action] => view
    [id] => 206
    [ext] => xml
)

If a variable in the route is ambiguous, you can escape it with {}'s:

$router->addRoute(
    'logViewer',
    new MJS_Controller_Router_PathRoute(
        ':controller/:action/customer{:id}httpd-error.:ext',
        array(),
        array('id' => '\d+')
    )
);

matches: http://mysite.com/log/view/customer206httpd-error.log
Array
(
    [controller] => log
    [action] => view
    [id] => 206
    [ext] => log
)

You can use a wildcard at the end of your route to capture parameters in a key1/value1/key2/value2 format:

$router->addRoute(
    'books',
    new MJS_Controller_Router_PathRoute(
        'books/:action/*',
        array('controller' => 'book')
    )
);

matches: http://mysite.com/books/find/author/Hemingway/sort/title
Array
(
    [controller] => book
    [action] => find
    [author] => Hemingway
    [sort] => title
    [*] => author/Hemingway/sort/title
)

You can also use (multiple) regex wildcards to capture parameters as strings:

$router->addRoute(
    'directions',
    new MJS_Controller_Router_PathRoute(
        'search/:action/from/:from/to/:to',
        array('controller' => 'mapit'),
        array('from' => '.+', 'to' => '.+')
    )
);

matches: http://mysite.com/search/getDirections/from/1866 Euclid Ave/Cleveland/OH/44315/to/Rome/Italy
Array
(
    [controller] => mapit
    [action] => getDirections
    [from] => 1866%20Euclid%20Ave/Cleveland/OH/44315
    [to] => Rome/Italy
)

You can even put two variables next to eachother:

$router->addRoute(
    'onlineStore',
    new MJS_Controller_Router_PathRoute(
        ':controller/:action/:model_id:modelnum',
        array(),
        array('model_id' => '[a-zA-Z]+', 'modelnum' => '\d+')
    )
);

matches: http://mysite.com/product/info/MX1035347
Array
(
    [controller] => product
    [action] => info
    [model_id] => MX
    [modelnum] => 1035347
)


Benchmarks: (updated 7-25-2006 to reflect latest changes in code)


About:

I designed the benchmark based on finding a match out of 20 routes. A seperate bootstrap is created for each router. Apache is restarted before each script is tested and 10 runs of 1000 requests are made to each script. The command used to execute each test is:

apachectl restart && for i in 1 2 3 4 5 6 7 8 9 10; do ab -n 1000 -c 10 http://127.0.0.1/mjs.php | grep mean; done

The highest and lowest result from each script is discarded and then the average requests per second of the remaining 8 runs is recorded.


Test machine:

  • 2.4GHz Celeron, 1gig RAM
  • FreeBSD 6.1
  • Apache 2.2.2
  • PHP 5.1.4 FastCGI


Results:

  • MJS_Controller_PathRouter + MJS_Controller_Router_PathRoute
    • served 42.2025 pages in 1 second
    • took approximately 23.69525 milliseconds to find the correct route
  • Zend_Controller_RewriteRouter + Zend_Controller_Router_Route
    • served 42.7475 pages in 1 second
    • took approximately 23.393 milliseconds to find the correct route


As you can see from the results there is only a 0.54 pages served each second difference and a 0.3 millisecond time per request difference between the two routers. There no performance penalty for the features that PathRouter / PathRoute adds.


Source code:

config.php

<?php

$zfLibraryDir  = dirname(__FILE__) . '/../trunk/library';

$numberOfRoutes = 20;
$_SERVER['REQUEST_URI'] = $_SERVER['SCRIPT_NAME'] . '/' . rand(1, $numberOfRoutes) . '/benchmark/';
$controllerDir = dirname(__FILE__) . DIRECTORY_SEPARATOR . 'controllers';

mjs.php

<?php

require 'config.php';

set_include_path(get_include_path() . PATH_SEPARATOR . $zfLibraryDir);
require_once 'Zend.php';
require_once 'Zend/Controller/Front.php';
require_once 'MJS/Controller/PathRouter.php';

$router = new MJS_Controller_PathRouter();
$router->addDefaultRoutes();

for ($i=1; $i<=$numberOfRoutes; $i++) {
    $router->addRoute($i, new MJS_Controller_Router_PathRoute("$i/:controller/:action", array('action' => 'index')));
}

$controller = Zend_Controller_Front::getInstance();
$controller->setRouter($router);
$controller->run($controllerDir);

martel.php

<?php

require 'config.php';

set_include_path(get_include_path() . PATH_SEPARATOR . $zfLibraryDir);
require_once 'Zend.php';
require_once 'Zend/Controller/Front.php';
require_once 'Zend/Controller/RewriteRouter.php';

$router = new Zend_Controller_RewriteRouter();


for ($i=1; $i<=$numberOfRoutes; $i++) {
    $router->addRoute($i, new Zend_Controller_Router_Route("$i/:controller/:action", array('action' => 'index')));
}

$controller = Zend_Controller_Front::getInstance();
$controller->setRouter($router);
$controller->run($controllerDir);

This seemed to error out a bit on me, it wasn't matching things with their defaults...
For example, if:

$route = new MJS_Controller_Router_PathRoute('admin/news/:action/:id', array('controller' => 'admin_news', 'action' => 'index', 'id' => 0), array('id' => '\d+'));

Then $route->match('admin/news/'); returns false.

I think this is to do with the slashes and what happens to them, I think I've sort of fixed it with the following:

in PathRouter::route():
$path = trim($path, '/'); }}->{{ $path = trim($path, '/') . '/';

in PathRoute::prepare():
$this->_regex .= self::REGEX_DELIMITER; }}>{{ $this>_regex .= '(?:/)?$' . self::REGEX_DELIMITER;

And then it matches things with blanks where they should have defaults.

(This is with the latest version; I just downloaded the attachment today.)

Oops, I messed up the formatting on that a bit, I pressed post instead of preview :\

Try again:
in PathRouter::route():
$path = trim($path, '/'); => $path = trim($path, '/') . '/';

in PathRoute::prepare():
$this->_regex .= self::REGEX_DELIMITER; => $this->_regex .= '(?:/)?$' . self::REGEX_DELIMITER;

Hi Richard,

You are correct. When working on PathRouter I moved $path = trim($path, '/') from PathRoute into PathRouter because I didn't see a point in trimming the path every single route you define when it could be done once in the router itself. For now I will move it back into the PathRoute class to maintain compatibility with RewriteRouter. Thanks!!

No problem, it's a great set of classes, and I think it should definitely be in ZF proper.

I think the new overrides method is much neater too Can you prepend actions as well?

You sure can! You can change ANY variable with prefix and append.

$router->addRoute('AdminCP',
    new MJS_Controller_Router_PathRoute(
        'admincp/:controller/:action/*',
        array(
            '+controller' => 'admin' . DIRECTORY_SEPARATOR),
            '+action'     => 'my',
            '+userid'     => 'foo'
        )
    )
);

If you match the url: http://mysite.com/admincp/account/edit/userid/123

The prefix default will change it to:

Array
(
    [controller] => admin/account
    [action] => myedit
    [userid] => foo123
)

Which would then load:
/path/to/controllers/admin/AccountController.php

and run the myeditAction()

View the rest of this thread. Most recent comment: Jul 27, 2006
5 more comments by: Michael Sheakoski, Richard Ingham

Results:

  • MJS_Controller_PathRouter + MJS_Controller_Router_PathRoute: 42.2025 Requests Per Second
  • Zend_Controller_RewriteRouter + Zend_Controller_Router_Route: 42.7475 Requests Per Second

As you can see from the results there is only a 0.54 requests per second difference between the two routers. There no performance penalty for the features that PathRouter / PathRoute adds.

I would have to inject that 1/2 a second is a large amount of time in a web application considering the suggested max loadtime last time I read anything on the subject is around 8 seconds, Your router is using an additional 5% of the time you have to display a page and its only a small portion of the whole picture.

That being said I love the functionality your router providers and I can over anaylize things a bit but I think performance should be kepted in check and improved if possible which is why so many suggestions from me on the mailing list, I want this module to improve and be added to ZF

It isn't 1/2 second difference. It is 1/2 REQUEST per second difference. Basicly on my machine the RewriteRouter test can serve 42.74 pages in 1 second. PathRouter can serve 42.2 pages in 1 second.

Just to put it into the perspective of time, PathRouter takes 0.3 milliseconds longer to do its thing than RewriteRouter. So as you see the difference is only a few thousandths of a second, much less of an impact than 1/2 second

Why isn't this a formal proposal? I think this could easily be approved and moved to the incubator.

All this things can be made with a Plugin for Zend_Controller_Front

Or, since most of the cool stuff is MJS_Controller_Router_PathRoute you could just use that with Zend_Controller_RewriteRouter::addRoute()

Yeah, at this point the only part worth taking is MJS_Controller_Router_PathRoute. I wrote this before the MVC revsion which address similar issues that MJS_Controller_PathRouter does. I'm not sure if the new MVC components support controllers in subdirs but that might also be worth merging from this code in some form.

There is a small issue in the regex that affects Wikipedia style url's that contain a colon but that is an easy fix. Other than that it has been rock solid.

View the rest of this thread. Most recent comment: Jan 04, 2007
2 more comments by: Georg von der Howen, Aaron Heimlich