Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Is this a good way to match URI to class/method in PHP for MVC

I am new to MVC so this is my first attempt and I am sure you guys can give me improvement on this, thanks for any tips or help!

Below is what I have come up with for a router/dispatcher system for my personal framework I am working on, it is my first attempt at using the MVC pattern.

The first block of code is just my .htaccess file which routes all request through my index.php file.

The second block of code is my array of "Routes" which will tell the Router object, which class and method to call as well as any ID or paging numbers if they exists.

Third block of code is the router class.

Fourth block is just running the class

So the router class has to use regex to match the URI with a route in the route map, in theory, this just sounds like bad performance when there is a list of 50+ routes that the regex has to run on, should I be doing this differently? The main reason I use the regex is to match page numbers and ID numbers when they exists in the route.

Also please do not just tell me to use a framework, I am doing this to learn it better, I learn better this way and just prefer to not use an existing framework at this time, I have studies all the main ones and some less common ones for ideas already.

1) So the main question, does anything just not look right?

2) Is there a better way to detect what is in the URI than using the regex on an array like I am doing, consider it on a high traffic site?

3) Since everything is routed through the index.php file with this, how would I go about handling AJAX requests?

Sorry if this is confusing, I am a little confused mtyself!


.htaccess file

RewriteEngine On
RewriteCond %{REQUEST_FILENAME} !-d
RewriteCond %{REQUEST_FILENAME} !-f
RewriteRule ^(.*)$   index.php?uri=$1    [NC,L,QSA]

Map array()

/**
 * Map URI to class/method and ID and Page numbers
 * Must be an array
 */
$uri_route_map = array( 
    //forums
    'forums/' => array(
        'controller' => 'forums',
        'method' => 'index',
        'id_number' => '',
        'page_number' => ''),

    'forums/viewforum/(?<id_number>\d+)' =>  array(
        'controller' => 'forums',
        'method' => 'viewforum',
        'id_number' => isset($id_number),
        'page_number' => ''),  

    'forums/viewthread/(?<id_number>\d+)' =>  array(
        'controller' => 'forums',
        'method' => 'viewthread',
        'id_number' => isset($id_number),
        'page_number' => ''),

    'forums/viewthread/(?<id_number>\d+)/page-(?<page_number>\d+)' =>  array(
        'controller' => 'forums',
        'method' => 'viewthread',
        'id_number' => isset($id_number),
        'page_number' => isset($page_number)),

    // user routes
    // account routes
    // blog routes 
    // mail routes
    // various other routes
);

Router class that reads and matches the Map array above

/**
 * Run URI against our Map array to get class/method/id-page numbers
 */
 class Router
{
    private $_controller = '';
    private $_method = '';
    public $page_number = '';
    public $id_number = '';

    public function __construct($uri, array $uri_route_map)
    {
        foreach ($uri_route_map as $rUri => $rRoute)
        {
            if (preg_match("#^{$rUri}$#Ui", $uri, $uri_digits))
            {
                //if page number and ID number in uri then set it locally
                $this->page_number = (isset($uri_digits['page_number']) ? $uri_digits['page_number'] : null);
                $this->id_number = (isset($uri_digits['id_number']) ? $uri_digits['id_number'] : null);
                $this->_controller = $rRoute['controller'];
                $this->_method = $rRoute['method'];

                // just for debug and testing while working on it / will be removed from final code
                echo '<hr> $page_number = ' . $this->page_number . '<br><br>';
                echo '<hr> $id_number = ' . $this->id_number . '<br><br>';
                echo '<hr> $controller = ' . $this->_controller . '<br><br>';
                echo '<hr> $method = ' . $this->_method . '<br><br>';
                break;
            }else{
                $this->page_number = '';
                $this->id_number = '';
                $this->_controller = '404';
                $this->_method = '404';
            }
        }
    }

    public function getController()
    {
        return $this->_controller;
    }

    public function getMethod()
    {
        return $this->_method;
    }

    public function getPageNumber()
    {
        return $this->page_number;
    }

    public function getIDNumber()
    {
        return $this->id_number;
    }

    /**
     * Call our class and method from values in the URI
     */
    public function dispatch()
    {
        if (file_exists('controller' . $this->_controller . '.php'))
        {
            include ('controller' . $this->_controller . '.php');
            $controllerName = 'Controller' . $this->_controller;
            $controller = new $controllerName($this->getIDNumber(),$this->getPageNumber());
            $method = $this->_method;
            if (method_exists($this->_controller, $this->_method))
            {
                return $controller->$method();
            } else {
                // method does not exist
            }
        } else {
            // Controller does not exist
        }
    }

}

Run it

/**
 * Testing the class
 */
$uri = isset($_GET['uri']) ? $_GET['uri'] : null;
$router = new Router($uri, $uri_route_map);
$router->dispatch();

?>
like image 792
JasonDavis Avatar asked Aug 11 '11 21:08

JasonDavis


6 Answers

1) Look alright to me. The code looks a bit messy though.

2) Yes there is a better way. You're doing the regex because you want to match parts of the URL that you don't know. Why not do $parts = explode("/", $uri) then see if you can find the page you're looking for? You will need to define how many parameters you're expecting for each page or you wont know whether to pick forums with parameters array("viewform", 123) or forums/viewforum with parameters array(123).

explode feels loads better than a regex. It also adds the benefit of improved error handling. What if the argument passed to viewforum is not a number? Surely you can do better than "404" ;)

3) Make a seperate ajax handler. Ajax is hidden from view anyway so you don't need to bother with providing semantic URLs.

Example:

function find_route($parts) {
    foreach ($uri_route_map as $route => $route_data) {
        $route_check = implode("/", array_slice($parts, 0, count($parts) - $route_data['num_arguments']));
        if ($route_check === $route) {
            return $route_data;
        }
    }
    throw new Exception("404?");
}

$uri = "forum/viewforum/522";

$parts = explode("/", $uri);
$route = find_route($parts);
$arguments = array_slice($parts, count($parts) - $route['num_arguments']);

$controller = $rRoute['controller'];
$method = $rRoute['method'];
$controller_instance = new $controller();
call_user_func_array(array($controller_instance, $method), $arguments);

(untested)

Plugins

Because of $uri_route_map you can't 'dynamically' register more plugins or pages or 'routes'. I'd add a function to add more routes dynamically to the Router.

Additionally you could consider some auto-discovery scheme that, for instance, will check the folder plugins/ for folders with a file called "manifest.php" that, when called, will optionally add more routes to Router.

like image 121
Halcyon Avatar answered Oct 30 '22 18:10

Halcyon


1),2) I dont think that is good idea to put id_number and page_number in Router, because in future you can encounter many other parameters for url. Better just use controller and method and define in controller what to do with other parameters or create other class Request which deals with request info.

3) For ajax use url like ajax/module/action. And create ajax controller which do basic ajax security stuff, like checking against XSRF and then decides which controllers to run and action to call.

like image 33
codez Avatar answered Oct 30 '22 17:10

codez


1) & 2) I will not say ,it's not right but why not using default routes ? Most of the time a route like

controller/action/param1/param2

is good enought for a majority of your page.

You probably could do something like that to define default routes :

$this->controller = 'index';
$this->action = 'index';

private function getDefaultRoutes()
{
    $url = $_SERVER['REQUEST_URI'];
    $tabUrl = explode('/',$url);


    if(!empty($tabUrl))
    {
        $this->controller = array_shift($tabUrl);
        $this->action = array_shift($tabUrl);
        $this->params = $tabUrl;
    }
}

And then if you need more specific routes you can define them in an array or whatever you want. In your routeryou just have to check if the current URI match a specific routes or the default routes. By doing that you will decrease the number of routes to match and increase the speed of your router.

3) Your router is probably instancied by your index, without index no root, so unfortunately you probably can't avoid using it. That why it's very important to avoid expensive action in your index. Typically don't init your database connection in index if all your pages don't need it.

Also please do not just tell me to use a framework

Don't forget to download some famous framework and look their code. It's the better way to learn. By doing that you will probably find a lot of good practices and answers.

like image 42
grunk Avatar answered Oct 30 '22 17:10

grunk


1) So the main question, does anything just not look right?

Personally, I see this becoming more complicated as your site grows. An MVC framework, as I was taught, should pretty much be "Set it and forget it" – you're separating the request handler (controller) from the database querying and business end (model) and from the display elements (view).

[NB: You may need other core aspects. My standard framework includes some core elements that carry the session through the various parts, as well as handle fundamental aspects of the site work – For instance, while the models are responsible for making the right database calls as directed by the controller, there are core functions in a sql.class.php file that give me a standardized set of methods for making those calls and delivering or caching the results as needed.]

Your dispatch method is on the right track with this – you're extracting from the URI the name of the controller (Forums, Profiles, etc.). Do you need a uri map? I feel you're creating an unnecessary situation in which you have to update this map each time, rather than simply creating a new controller when you need new functionality, and registering it with the database. I'm not saying you're wrong per se, I just don't feel I'd have done it that way.

2) Is there a better way to detect what is in the URI than using the regex on an array like I am doing, consider it on a high traffic site?

Control the outcome (no pun intended, since it's the controller that does the work here). Consider this approach, and see how it works for you:

Your index.php file (aka "Main Controller") grabs the URI and explodes the values along "/" into bits. bit[0] is the controller ID – this says "I want to use the controller named bit[0]=>value". This is done as:

require_once( dirname( __FILE__ )."/controllers/".$bit[0]."controller.php" ); 

Personally, being a bit of a neat freak when it comes to directory structures, I use bit[0] to identify the directory in which controller.php is located, as I might have sub controllers.

It's this controller file that I use to parse other bits. For this, I'll use an example:

Assume that bit[0] carried the value "forums". I might pass, if it's set, bit[1] to a switch statement. By default, I always want to list, but I might specifically direct it to "list", "view", or "post" in bit[1]. This will tell me in the controller class which method to call. The method will then tell me to call the associated "forums" model if I need to perform queries and cache the forum listing, for instance.

The extraneous "bits" may do one of two things: they may be passed as simple arguments to the method as to what data to request from the model, or bit[1] may be complex enough to warrant a sub controller, and the subsequent bits will be passed to that controller to determine the appropriate action, as was done with the forums controller.

Regex, being slow, should be avoided when possible. Since we may have a URI of /forums/view/102305 we can assume that the forums controller will be passing 102305 to the method associated with the view argument (the method being something like private function displayPost( $id ) where $id is 102305). No regex is needed since we can simply explode the values along a common anticipated delimiter.

3) Since everything is routed through the index.php file with this, how would I go about handling AJAX requests?

Not terribly difficult. If the controller is set to, say, AJAX, you could rebuild the URL and direct access it. You could write exclusions in the .htaccess file (RewriteRule ^(AJAX)($|/) - [L]). Or (not ideal, but a sneaky workaround) is to append ../ to your AJAX URI to push the URI back to root – it's no longer attempting to access index.php so the rewrite rule doesn't apply.

Edit

Let's assume that we're using a URI of /forums/id-1234/page-4 per your example. Again, let's assume as I mentioned above that forums refers to the controller to be used, and every other / delimits arguments (what I like to call "drill downs"). So, in our forum controller file (let's call it forumcontroller.php, we might have something like this (extremely simplified) constructor:

// $registry is a class containing fundamental methods, and is meant to exemplify all 
// classes tied to the main controller "index.php". Keep in mind, I'm assuming we've 
// found the right controller by exploding the URI, and passed the remainder as bits
// to the constructor.
public function __construct( registry $registry ) {
  $this->registry = $registry;  //tying this controller to main controller.
  // For ease and clarity, we're assuming there's no case in which you wouldn't have
  // bits set. Error checking is easy.
  $bits = $this->registry->getURLBits;
  switch( $bits[0] ) {
    case 'view': $this->showForumEntry( $bits[1], (isset( $bits[2] ) ? $bits[2] : '' ); break;
    case 'edit': $this->editForumEntry( $bits[1] ); break;
    case 'post': $this->postForumEntry(); break;
    default: $this->listForumEntries(); break;
  }
}

private function showForumEntry( $thread, $offset ) {
  // Because you wanted to prepend id to the id element, we can use this for
  // cheekiness in the query if our DB is well designed.
  $data = explode('-', $thread);
  // Select all from forums where id = 1234
  $sql = "SELECT * FROM forums WHERE $data[0] = $data[1]";
  if( $offset != '' ) {
    $page = explode('-', $offset);
    $offset = $page[1] * 25; // Or whatever your max per page is. Make it dynamic.
    $max = $offset+25;
    $sql .= " LIMIT $offset, $max";
  }
  // You see where I'm going with this...
}

The point is that you're in control of what is being passed and how it gets handled. Control the URIs and you can simplify their processing.

Edit 2 Reading through again, there's a few concepts that I think will help you and that you should familiarize yourself with:

View the "Factory" pattern here (My $registry is, at it's heart, a set of factories): http://php.net/manual/en/language.oop5.patterns.php

A good breakdown of MVC graphically: http://best-practice-software-engineering.ifs.tuwien.ac.at/patterns/images/mvc3.jpg

More on Factory methods: http://www.devshed.com/c/a/PHP/Design-Patterns-in-PHP-Factory-Method-and-Abstract-Factory/

One other note, and this is a personal observation after having worked with Joomla, Drupal, Wordpress, and various different enterprise CMS and BBS solutions – Design solely with you in mind. When you start trying to become "something for everybody", you end up with a lot of unnecessary bloat that's getting loaded with each page and used 1 time out of 100. MVC is a design pattern, and using it as a pattern will tell you to get rid of excess in every aspect, including URIs. Processing /controller/arg1-Identifier/arg2-offset is unnecessary, and you can easily get away with /controller/id/offset (e.g. /forums/1234/4). If you want to make it SEO friendly, add the thread title, not a tag identifying the ID (e.g. /forums/1234-This-Is-A-Topic/4).

Now, let's also point out the obvious about my edit above – This is a controller intended solely for the forum element. Each element of your site (i.e. forums, galleries, profiles, etc.) should have it's own controller. Why? Because each is doing completely different things on its pages. So make use of that – you don't need to use a URI map so long as you understand that you're directing to the controller, and the controller is delegating responsibilities to the model and any sub controllers that might be needed.

I really hope that helps.

like image 44
stslavik Avatar answered Oct 30 '22 18:10

stslavik


1) Is it works? If yes, then yes. Since above code only contain array, regex and validation for that, i dont think theres a problem(s) with your code. As long as it works. But if you ask, 'is that code is scalable?' then the answer would be various, and its all depend at your MVC Framework purposes (for example, is that framework for general uses, eg : blog, or its particullary for REST API provider. And so on...)

2) Yes. Kohana, Zend, CI and other popular (and highly optimized) PHP framework use that(array+regex on router).

3) I think you could just give it a flag in route block/section, and make that flag available as a global variable. So that, in your controller, you can decide which response to send for different request type (ajax/non-ajax) by checking that flag (for example you can provide $this->is_ajax as global method which available in Controller scope).

like image 31
toopay Avatar answered Oct 30 '22 17:10

toopay


If I may add couple of points:

  • Remove id_number and page_number from the router - just pass everything that was matched to a controller, after all, it's a controller job to process that data, not router's

  • Don't pass $uri to a constructor, pass it to a dispatch() instead.

  • Why those isset()-s in $uri_route_map? Obviously they would be false, since $uri_route_map is defined before Router() object is instantiated.

  • Would recommend adding more logic to matching routine - in your current case sitename/forums will not match anything resulting in 404 (no trailing slash)

  • You can also define default parameters in your $uri_route_map, and then array_merge them with parameters matched. So, for example, when no page number is specified page_number will be equal to 1

  • If you are worried about perfomance on high traffic website, you can cache routes. After all, forums/viewforum/100 will always point to the same controller/method.

And why are you worried about sending AJAX requests to your index.php file? What's the problem with that?

like image 24
German Rumm Avatar answered Oct 30 '22 18:10

German Rumm