Using a Dependency Injection Container to simplify Routing in an MVC framework
Updated 19/11/2018: Examples now use Dice 4.0
Introduction
Today, I'm going to show how combining several different OOP techniques and design patterns can create a very powerful yet simple application entry point for an MVC framework. From the ground up, I'll walk you through building a Convention-Over-Configuration router for a MVC framework. By the end you'll have something smaller, far more robust and powerful than is available in any of the popular frameworks!
I'll be using my own Dependency Injection Container: Dice (available here) to create a smart router/dispatcher for a simple MVC Framework. Any Dependency Injection Container can be used for this job, but Dice takes away 99% of the configuration so makes everything far simpler.
Why use a Dependency Injection Container for this task?
In MVC In PHP part 2: MVC On the web. I discussed the problem of routing that the web architecture presents. I chose not to get too technical in that article as I wanted everything to be self-contained. However, this is a more advanced version of that and while it addresses the same concepts and the same issues, this is far more complex and designed to be used in the real-world. It's not just for demonstration.
As such, and sticking with the theme of Separation of Concerns that MVC strives for, Using a different tool for the creation of objects within the system creates a far more robust and flexible application. I won't go into the merits of dependency injection here as there are lot of articles around which discuss that. However, I hope the code examples here will speak for themselves even if you're not familiar with Dependency Injection.
Routing in MVC web frameworks
To recap, the biggest issue faced when deploying MVC on the web is:
How does the centralised entry point (front controller) know which Model, View and Controller to initiate?
By delegating that job to a Dependency Injection Container it's easy to avoid the trivial binding logic of "This controller needs this model, this view needs this model".
Firstly, how the router will interact with the rest of the system must be defined. Basic functionality dictates that it will take a route such as '/users/list' and return the names (or instances) of the model, view and controller that it's going to use.
Here is a skeleton router API with the basic logic:
class Router {
public function find($route) {
//Convert $route into a Route object as defined below
return new Route('...', '...', '...');
}
}
class Route {
private $view;
private $controller;
private $model;
public function __construct(View $view, $model = null, $controller = null) {
$this->view = $view;
$this->controller = $controller;
}
public function getView() {
return $this->view;
}
public function getController() {
return $this->controller;
}
public function getModel() {
return $this->model;
}
}
To follow the basic rules of encapsulation, the three properties are private. This essentially makes them read-only and once a route has been created it is immutable. This is good because it stops unknown external code changing the route during the application's execution.
However, this can be simplified slightly. If there is a Model in use, the View and/or Controller will ask for it in its constructor:
class MyView implements View {
public function __construct(MyModel $model) {
}
}
class MyController {
public function __construct(MyModel $model) {
}
}
In MVC the View and Controller encapsulate the model. The top level of the application that knows about the route doesn't need to know of the Model's existence, and as such it should be removed from the Route object entirely:
class Router {
public function find($route) {
//Convert $route into a Route object as defined below
return new Route('...', '...');
}
}
class Route {
private $view;
private $controller;
public function __construct(View $view, $controller = null) {
$this->view = $view;
$this->controller = $controller;
}
public function getView() {
return $this->view;
}
public function getController() {
return $this->controller;
}
}
Later on a dispatcher will be required to call the correct controller action and this will need to know which controller it's dealing with. And the top level of the application needs to know which view to render. Nothing apart from the View and Controller need the model so we can minimise the data needed in the Route object.
A convention based router
Back to the router itself, let's say the first two parts of the URL to generate the route. For example /user/edit/12 becomes "\User\Edit\Controller" and "\User\Edit\View" by default. The default action is then called by the dispatcher with a parameter of "12". A Convention-over-configuration approach can be used so that this can be overridden where needed.
This could be defined manually in the router:
class Router {
/*
* @param string
* @description: Convert $route into a Route object as defined below
* @return \Route
*/
public function find($route) {
//Split the route on / so the first two parts can be extracted
$routeParts = explode('/', $route);
//A name based on the first two parts such as "\User\Edit" or "\User\List"
$name = '\\' . array_shift($route) . '\\' . array_shift($route);
$viewName = $name . '\\View';
//Does the class e.g. "\User\List\View" exist?
if (class_exists($viewName)) {
$view = new $viewName;
}
else {
//Exit, this should display an error or throw an exepction but for simpliticy in this case, routes that
//do not have a view will be disabled
return false;
}
//E.g. "\User\Edit\Controller"
$controllerName = $name . '\\Controller';
if (class_exists($controllerName)) {
$controller = new $controllerName;
}
else {
$controller = null;
}
//Finally, return the matched route
return new Route($view, $controller);
}
}
The obvious problem here is that it won't work. The logic is sound but the controller and view can't be initialised in this way because they'll never get passed the model. This is where Dice, the Dependency Injection Container, comes in. It can automatically resolve dependencies:
Rather than having Dice construct the Model, View and Controller, Dice will instead, dynamically construct the Route object with the correct parameters.
This means that the Router is never aware of any dependencies that controllers, models or views may have and resolving those dependencies is all down to Dice. This ensures a proper separation of concerns that abide by the single responsibility principle. The router is concerned with routing and the Dependency Injection Container is concerned only with managing dependencies.
class Router {
private $dice;
public function __construct(\Dice\Dice $dice) {
//Dice, the dependency injection container
$this->dice = $dice;
}
public function find($route) {
//Convert $route into a Route object as defined below
//A name based on the first two parts such as "\User\Edit" or "\User\List"
$name = '\\' . array_shift($route) . '\\' . array_shift($route);
$viewName = $name . '\\View';
if (!class_exists($viewName)) return false;
$controllerName = $name . '\\Controller';
//Auto-generate a rule for a route if it's not already been generated
if ($this->dice->getRule('$autoRoute_' . $className) == $this->dice->getRule('*')) {
$rule = [];
//Dice will be creating a Route object and pass it a specific view and controller
$rule['instanceOf'] = 'Route';
//The first parameter will be a view
$rule['constructParams'] = [
[\Dice\Dice::INSTANCE => $view]
];
//Only pass the controller to the route object if the controller exists
$rule['constructParams'][] = (class_exists($controllerName)) ? [\Dice\Dice::INSTANCE => $controllerName] : null;
//The model (or ViewModel) will need to be shared between the controller and view
$modelName = $className . '\\ViewModel';
//The model doesn't need to exist, in its purest form, an MVC triad could just be a view
//This tells Dice that the same instance of the model should be passed to both the view and the controller, if it exists
if (class_exists($modelName)) $rule['shareInstances'] = [$modelName];
//Add the rule to the DIC
$this->dice = $this->dice->addRule('$autoRoute_' . $className, $rule);
}
//Have Dice construct the Route object with the correct controller and view set.
//Dice will automatically pass the model into the View nad Controller if they ask for it in their constructors
return $this->dice->create('$autoRoute_' . $className);
}
}
This works by having Dice create the Route object with a specific View and Controller that are using a convention based name
Dice will create an instance of the Route object passing it the required View and Controller. If the controller or view ask for the model in their constructor, they will be automatically passed that too.
The best part about this is that thanks to Dice, the controller (e.g. \User\Edit\Controller) can ask for additional dependencies and they will be automatically resolved by Dice. Amending the controller to ask for a session object and a request object will just work without any further configuration! And not need to alter or reconfigure the router in any way.
namespace User\Edit;
class Controller {
public function __construct(UserModel $user, Session $session, Request $request) {
}
}
Nice! This is now a very simple Convention-based router. That is, it will always use the naming convention to work out the route. However, it's not very flexible. It ties you to a very specific naming convention for your classes. It must initiate $route1$route2View and $route1$route2 controller. This is far from ideal as it's impossible to reuse a class. For instance You may wish to use a generic "\List\View" rather than a specific "\User\List\View"
A configuration based router
Sometimes you want to be able to manually define which controller and view to initialise. This is important for reusability, if you have to use the convention approach you must needlessly use inheritance if you want to reuse a controller or view on two different routes
By defining the routes using Dice's Named Instances feature, it's possible to have complete control over an instance:
$rule = [
//It will be an instance of the Route object
'instanceOf' => 'Route',
//Define the View and Controller which will be used.
//These don't rely on a naming convention
'constructParams' => [
//Here a generic ListView is used with a user-specific UserListController
[\Dice\Dice::INSTANCE => 'ListView'],
[\Dice\Dice::INSTANCE => 'UserListController']
]
];
//And add a rule containing the route
$dice = $dice->addRule('$route_user/edit', $rule);
This means that when Dice is told to create a component called "$route_user/edit" it creates the an instance of the Route class by passing it an instance of ListView and an instance of UserListController in its constructor.
And now change the router to allow Dice to piece together all the relevant components:
class Router {
private $dice;
public function __construct(\Dice\Dice $dice) {
$this->dice = $dice;
}
public function find($route) {
//Convert $route into a Route object as defined below
$routeParts = explode('/', $route);
$name = $routeParts[0] . '/' . $routeparts[1];
return $this->dice->create('$route_' . $name);
}
}
And Dice has done all the work! Of course, the fallback to the default view has been lost. That should be added back in:
$rule = [
'instanceOf' => 'Route',
'constructParams' => [
[\Dice\Dice::INSTANCE => 'DefaultView'];
];
];
//Add a named instance for the fallback view:
$dice = $dice->addRule('$route_default', $rule);
And then in the router:
class Router {
private $dice;
public function __construct(\Dice\Dice $dice) {
$this->dice = $dice;
}
public function find($route) {
//Convert $route into a Route object as defined below
$routeParts = explode('/', $route);
$name = '$route_' . $routeParts[0] . '/' . $routeparts[1];
//If there is no special rule set up for this $name, revert to the default
if ($this->dice->getRule($name) === $this->dice->getRule('*')) {
return $this->dice->create('$route_default');
}
else return $this->dice->create($name);
}
}
This will now fall back to whatever has been defined as the default route. The problem with this approach is that every single route still needs to be defined manually. This is a Configuration based (or static) router where each route is manually defined. The downside is that every single possible route in the system must be defined as a rule. This can very quickly grow to a very long list in large application and become a pain in larger applications. Doing something as simple as adding a page means reconfiguring the router.
Combining the two to create a Convention-Over-Configuration router
By combining the two, it's easy to create a Convention-over-configuration router that has all the flexibility of the Configuration-based and along with the convenience of the Convention-based router! And what's more, by using Dice you don't need to worry about locating any dependencies the View or Controller may have.
class Router {
private $dice;
public function __construct(\Dice\Dice $dice) {
$this->dice = $dice;
}
public function find($route) {
//Convert $route into a Route object as defined below
$routeParts = explode('/', $route);
$configName = '$route_' . $routeParts[0] . '/' . $routeparts[1];
//Check if there is a manual configuration for this route
if ($this->dice->getRule($configName) === $this->dice->getRule('*')) {
$className = '\\' . array_shift($route) . '\\' . array_shift($route);
$viewName = $className . '\\View';
//If the view doesn't exist, the convention rule can't continue
if (!class_exists($viewName)) return false;
$controllerName = $className . '\\Controller';
//Auto-generate a rule for a route if it's not already been generated
if ($this->dice->getRule('$autoRoute_' . $className) == $this->dice->getRule('*')) {
$rule = new \Dice\Rule;
//Dice will be creating a Route object and pass it a specific view and controller
$rule->instanceOf = 'Route';
//The first parameter will be a view
$rule->['constructParams'] = [
[\Dice\Dice::INSTANCE => $viewName]
];
//Only pass the controller to the route object if the controller exists
$rule['constructParams'][] = (class_exists($controllerName)) ?
[\Dice\Dice::INSTANCE => $controllerName] : null;
//The model (or ViewModel) will need to be shared between the controller and view
$modelName = $className . '\\ViewModel';
//The model doesn't need to exist, in its purest form, an MVC triad could just be a view
if (class_exists($modelName)) $rule['shareInstances'] = [$modelName];
//Add the rule to the DIC
$this->dice = $this->dice->addRule('$autoRoute_' . $className, $rule);
}
//Create the route object
return $this->dice->create('$autoRoute_' . $className);
}
//There is a manual route defined, use that
else return $this->dice->create($configName);
}
}
This is excellent! Here is a very simple yet very powerful Convention-Over-Configuration router. It's simple and follows the Single Responsibility Principle as well as keeping the idea of separation of concerns by not being concerned with the creation of the objects and delegating it to Dice.
Room for improvement
Of course, there's always room for improvement. How about making the router entirely extensible? Should the router itself really be concerned with looking at class names or the implementation of Dice? It can be simplified and made extensible:
namespace Router;
interface Rule {
public function find(array $route);
}
class Router {
private $rules = array();
public function addRule(Rule $rule) {
$this->rules[] = $rule;
}
public function getRoute(array $route) {
foreach ($this->rules as $rule) {
if ($found = $rule->find($route)) return $found;
}
throw new Exception('No matching route found');
}
}
Here is a very basic router that requires some third party rules are provided for it to work. The first thing you'll notice is that getRoute() now takes an array. This is because assuming that all routes are strings broken up by a '/' is a bad thing and will limit the scope of the router. Instead, the part of the code that actually accepts the route as a string can format it into an array for the router. The router doesn't care where the route comes from or whether the URL was broken on a '/' or a '\' or even a ':' or "@". In fact it may not have been a URL at all.
This router works on rules. You pass it rules and it processes them in order. At the moment the router will never match a single route. To add back the functionality that existed previously it needs to be moved to rules. Firstly the Configuration-based rule:
namespace Router\Rule;
class Configuration implements \Router\Rule {
private $dice;
public function __construct(\Dice\Dice $dice) {
$this->dice = $dice;
}
public function find(array $route) {
$name = '$route_' . $route[0] . '/' . $route[1];
//If there is no special rule set up for this $name, revert to the default
if ($this->dice->getRule($name) == $this->dice->getRule('*')) {
return false;
}
else return $this->dice->create($name);
}
}
and one for the Convention based approach:
namespace Router\Rule;
class Convention implements \Router\Rule {
private $dice;
public function __construct(\Dice\Dice $dice) {
$this->dice = $dice;
}
public function find(array $route) {
//The name of the class
$className = '\\' . array_shift($route) . '\\' . array_shift($route);
$viewName = $className . '\\View';
//If the view doesn't exist, the convention rule can't continue
if (!class_exists($viewName)) return false;
$controllerName = $className . '\\Controller';
//Auto-generate a rule for a route if it's not already been generated
if ($this->dice->getRule('$autoRoute_' . $className) == $this->dice->getRule('*')) {
$rule = [];
//Dice will be creating a Route object and pass it a specific view and controller
$rule['instanceOf'] = 'Route';
//The first parameter will be a view
$rule->['constructParams'] = [
[\Dice\Dice::INSTANCE => $viewName];
];
//Only pass the controller to the route object if the controller exists
$rule->['constructParams'][] = (class_exists($controllerName)) ? [\Dice\Dice::INSTANCE => $controllerName] : null;
//The model (or ViewModel) will need to be shared between the controller and view
$modelName = $className . '\\ViewModel';
//The model doesn't need to exist, in its purest form, an MVC triad could just be a view
if (class_exists($modelName)) $rule['shareInstances'] = [$modelName];
//Add the rule to the DIC
$this->dice = $this->dice->addRule('$autoRoute_' . $className, $rule);
}
//Create the route object
return $this->dice->create('$autoRoute_' . $className);
}
}
Finally, in case neither of those rules match a route, return the default rule:
namespace Router\Rule;
class Default implements \Router\Rule {
private $dice;
public function __construct(\Dice\Dice $dice) {
$this->dice = $dice;
}
public function find(array $route) {
return $this->dice->create('$route_default');
}
}
Why break them up? It allows flexibility in the application. The router can be a Convention based router or a Configuration based router or a Convention-over-configuration based router simply by adding the relevant rules to it. And the rules don't have to use Dice. Other rules could be used such as filename based routing. This frees up the router from any true dependencies making it more portable and easier to reuse. The initialisation code would look like this:
$dice = new \Dice\Dice;
$router = new \Router\Router;
$router->addRule(new \Router\Rule\Configuration($dice));
$router->addRule(new \Router\Rule\Convention($dice));
$router->addRule(new \Router\Rule\Default($dice));
$route = $router->getRoute(explode('/', $url));
As you can see, without much effort at all, a very powerful extensible router has been created with very little code.
Conclusion
You now have a very powerful router which is fully extensible and creates all your models, views and controllers! By using Dice you take a lot of the boilerplate code out of the framework entry point and simplify everything.
In the next article, I'll show you how to use the generated route with a dispatcher and you'll have a fully functional entry point for your own MVC framework!