More often than not, we take things for granted. If something is working as expected, we don't bother about the inner workings of it to understand the underlying mechanism. Or to put it another way, we don't dig into something until we're in some sort of trouble!
Similarly, I was always wondering about a couple of concepts in OpenCart that were used in the underlying framework, and one of them was the Proxy class. It took me a while to understand it, and I thought it's great stuff to share with you as it's always to fun to know something new.
What Is a Proxy Class?
Although you'll find various materials online that define the term proxy, the definition from Wikipedia is striking and easy to understand.
A proxy, in its most general form, is a class functioning as an interface to something else.
So the proxy delegates the control to the object it intends to use, and thus acts on behalf of the actual class that's instantiated. In fact, the proxy design pattern is a very popular pattern that's used by popular frameworks as needed. Considering the fact that a discussion of the proxy method in itself is such a wide topic and out of the scope of this article, I'll quickly summarize what it's used for most of the time:
- Act as a wrapper to provide additional functionality.
- Delay the instantiation of expensive objects, also referred as lazy loading.
In the context of OpenCart, we could say that the proxy pattern is used to add functionality to the base proxy class. Having said that, the base proxy class itself doesn't provide anything except a couple of magic methods! As we'll see in the next section, the proxy class is enriched at run-time by attaching properties to it.
Before we move into the next section, let's have a quick look at the proxy class. It resides in system/engine/proxy.php
.
<?php class Proxy { public function __get($key) { return $this->{$key}; } public function __set($key, $value) { $this->{$key} = $value; } public function __call($key, $args) { $arg_data = array(); $args = func_get_args(); foreach ($args as $arg) { if ($arg instanceof Ref) { $arg_data[] =& $arg->getRef(); } else { $arg_data[] =& $arg; } } if (isset($this->{$key})) { return call_user_func_array($this->{$key}, $arg_data); } else { $trace = debug_backtrace(); exit('<b>Notice</b>: Undefined property: Proxy::' . $key . ' in <b>' . $trace[1]['file'] . '</b> on line <b>' . $trace[1]['line'] . '</b>'); } } }
As you can see, it implements three magic methods: __get()
, __set()
, and __call()
. Among them, the __call()
method implementation is an important one, and we'll get back to it pretty soon.
How the Proxy Class Works With the Model
In this section, I'll explain how exactly a call like $this->model_catalog_category->getCategory($category_id)
works out of the box.
In fact, the story begins with the following statement.
$this->load->model('catalog/category');
During bootstrapping, the OpenCart framework stores all generic objects to the Registry
object so that they can be fetched at will. As a result of that, the $this->load
call returns the Loader
object from the Registry.
The Loader
class provides various methods to load different components, but what we're interested in here is the model method. Let's quickly pull the snippet of the model
method from system/engine/loader.php
.
public function model($route) { // Sanitize the call $route = preg_replace('/[^a-zA-Z0-9_\/]/', '', (string)$route); // Trigger the pre events $this->registry->get('event')->trigger('model/' . $route . '/before', array(&$route)); if (!$this->registry->has('model_' . str_replace(array('/', '-', '.'), array('_', '', ''), $route))) { $file = DIR_APPLICATION . 'model/' . $route . '.php'; $class = 'Model' . preg_replace('/[^a-zA-Z0-9]/', '', $route); if (is_file($file)) { include_once($file); $proxy = new Proxy(); foreach (get_class_methods($class) as $method) { $proxy->{$method} = $this->callback($this->registry, $route . '/' . $method); } $this->registry->set('model_' . str_replace(array('/', '-', '.'), array('_', '', ''), (string)$route), $proxy); } else { throw new \Exception('Error: Could not load model ' . $route . '!'); } } // Trigger the post events $this->registry->get('event')->trigger('model/' . $route . '/after', array(&$route)); }
Considering the aforementioned example, the value of the $route
argument is catalog/category
. First, the value of the $route
variable is sanitized, and following that it triggers the before
event to allow other module listeners to change the value of the $route
variable.
Next, it checks the existence of the requested model object in the Registry. If the Registry holds the requested object, no further processing is needed.
If the object is not found, it follows an interesting procedure to load it, and it's the snippet that we're looking for in the context of this article.
To start with, it prepares the file path of the requested model and loads it if it exists.
... $file = DIR_APPLICATION . 'model/' . $route . '.php'; $class = 'Model' . preg_replace('/[^a-zA-Z0-9]/', '', $route); if (is_file($file)) { include_once($file); ... } ...
Following that, it instantiates the Proxy
object.
$proxy = new Proxy();
Now, pay attention to the next for
loop—it does a lot more than it seems.
foreach (get_class_methods($class) as $method) { $proxy->{$method} = $this->callback($this->registry, $route . '/' . $method); }
In our case, the value of $class
should be ModelCatalogCategory
. The get_class_methods($class)
snippet loads all methods of the ModelCatalogCategory
class and loops through it. What does it do in the loop? Let's look closely.
In the loop, it calls the callback
method of same class. It's interesting to note that the callback method returns the function callable that's assigned to the $proxy
object with the key as the method name. Of course, the proxy object doesn't have any such properties; it'll be created on the fly using the __set()
magic method!
Next, the $proxy
object is added to the Registry so that it can be fetched later when needed. Have a close look at the key component of the set
method. In our case, it should be model_catalog_category
.
$this->registry->set('model_' . str_replace(array('/', '-', '.'), array('_', '', ''), (string)$route), $proxy);
At the end, it'll call the after
event to allow other module listeners to change the value of the $route
variable.
That's one part of the story.
Let's go through what happens exactly when you use the following in your controller.
$this->model_catalog_category->getCategory($category_id);
The $this->model_catalog_category
snippet tries to find a match for the model_catalog_category
key in the Registry. If you're wondering how, just look into the Controller
class definition in the system/engine/controller.php
file—it provides the __get()
magic method that does it.
As we've just discussed, that should return the $proxy
object that's assigned to that particular key. Next, it tries to call the getCategory
method on that object. But the Proxy class doesn't implement such a method, so how is that going to work?
The __call()
magic method comes to the rescue! Whenever you call a method that doesn't exist in the class, the control is transferred to the __call()
magic method.
Let's explore it in detail to understand what's going on. Open the Proxy class file and pay attention to that method.
The $key
contains the name of the function that's being called—getCategory
. On the other hand, $args
contains arguments passed to the method, and it should be an array of one element holding the category id that's being passed.
Next, there's an array $arg_data
that stores references of arguments. Frankly, I'm not sure if the code $arg instanceof Ref
makes any sense or not there. If anyone knows why it's there, I would be happy to learn.
Further, it tries to check the existence of the $key
property in the $proxy
object, and it results in something like this.
if (isset($this->getCategory)) {
Recall that earlier we assigned all the methods of ModelCatalogCategory
class as properties of the $proxy
object using a for
loop. For your convenience, I'll paste that code again.
... foreach (get_class_methods($class) as $method) { $proxy->{$method} = $this->callback($this->registry, $route . '/' . $method); } ...
So it should be there, and it should also return us the function callable! And finally, it calls that function callable using the call_user_func_array
function by passing the function callable itself and the method arguments.
Now, let's divert our attention to the function callable definition itself. I'll grab the snippet from the callback
method defined in system/engine/loader.php
.
... function($args) use($registry, &$route) { static $model = array(); $output = null; // Trigger the pre events $result = $registry->get('event')->trigger('model/' . $route . '/before', array(&$route, &$args, &$output)); if ($result) { return $result; } // Store the model object if (!isset($model[$route])) { $file = DIR_APPLICATION . 'model/' . substr($route, 0, strrpos($route, '/')) . '.php'; $class = 'Model' . preg_replace('/[^a-zA-Z0-9]/', '', substr($route, 0, strrpos($route, '/'))); if (is_file($file)) { include_once($file); $model[$route] = new $class($registry); } else { throw new \Exception('Error: Could not load model ' . substr($route, 0, strrpos($route, '/')) . '!'); } } $method = substr($route, strrpos($route, '/') + 1); $callable = array($model[$route], $method); if (is_callable($callable)) { $output = call_user_func_array($callable, $args); } else { throw new \Exception('Error: Could not call model/' . $route . '!'); } // Trigger the post events $result = $registry->get('event')->trigger('model/' . $route . '/after', array(&$route, &$args, &$output)); if ($result) { return $result; } return $output; }; ...
As it's an anonymous function, it has preserved the values in the form of $registry
and $route
variables that were passed earlier to the callback method. In this case, the value of the $route
variable should be catalog/category/getCategory
.
Apart from that, if we look at the important snippet in that function, it instantiates the ModelCatalogCategory
object and stores it in a static $model
array.
... // Store the model object if (!isset($model[$route])) { $file = DIR_APPLICATION . 'model/' . substr($route, 0, strrpos($route, '/')) . '.php'; $class = 'Model' . preg_replace('/[^a-zA-Z0-9]/', '', substr($route, 0, strrpos($route, '/'))); if (is_file($file)) { include_once($file); $model[$route] = new $class($registry); } else { throw new \Exception('Error: Could not load model ' . substr($route, 0, strrpos($route, '/')) . '!'); } } ...
And here's a snippet that grabs the method name that needs to be called using the $route
variable.
$method = substr($route, strrpos($route, '/') + 1);
So we have an object reference and a method name that allows us to call it using the call_user_func_array
function. The following snippet does just that!
... if (is_callable($callable)) { $output = call_user_func_array($callable, $args); } else { throw new \Exception('Error: Could not call model/' . $route . '!'); } ...
At the end of the method, the resulting outcome is returned via the $output
variable. And yes, that's another part of the story!
I've intentionally ignored the pre and post events code that allows you to override methods of the core OpenCart classes. Using that, you could override any core class method and tweak it according to your need. But let's keep that for some other day, as it'll be too much to fit into a single article!
So that's how it works altogether. I hope that you should be more confident about those shorthand OpenCart calls and about their inner workings.
Conclusion
What we've just discussed today is one of the interesting and ambiguous concepts in OpenCart: the use of the Proxy method in a framework to support shorthand conventions to call model methods. I hope the article was interesting enough and enriched your OpenCart framework knowledge.
I would love to know your feedback on this, and if you feel I should cover such topics in my upcoming articles, don't hesitate to drop a line about it!
No comments:
Post a Comment