Introduction
The Masonite Python framework is a way more modern approach to Python frameworks than currently anything on the market. A bold statement but once you start using Masonite, you'll be amazed at how powerful it can be just by using a few simple design decisions that other Python frameworks don't have like a dependency injection IOC container, the Mediator (or Manager) pattern which decouples a lot of the framework to keep it fluid and expandable and many more design decisions.
When learning Masonite, you will also learn many of these patterns which are predominant in the software development world.
I'll be writing a few of these articles to really give the background knowledge on how Masonite works. The more you understand your tools, the better you can utilize them to the max.
In this first part of the series I'll go in depth at how Masonite handles what it calls "Auto Resolving Dependency Injection"
If you enjoy this article and enjoy writing applications with Masonite, consider joining the Slack channel: http://slack.masoniteproject.com
What is it?
So dependency injection is a $10 word for a $1 definition. Simply put it just means that we pass something into an object that it needs.
Take this simple example:
from app import Model
def get_user_id(self, model):
return model.find(1).id
user = get_user_id(Model)
At this most basic example -- model is a dependency for this function and we have just performed dependency injection. We "injected" (or passed) the class into this function object.
A Step Futher
Let's take it one more step further and let's say, for the purposes of this demonstration, that we added a dictionary (that we will call a container) above the function. This dictionary will contain all of our classes we need for our application.
NOTE: This would be a bad way to implement this but just follow along for now :) This is just the basics of understanding how it works.
from static_assets import AmazonUpload
container = {'upload': AmazonUpload}
def upload_image(self, upload):
return upload.store('some-file.png')
So notice here that we conveniently have a parameter called upload
and a dictionary key called upload
. This is the most basic foundation of "auto-resolving" dependency injection.
The idea here is that we can use this container to pull in all the dependencies we need for us and we don't have to worry about requirements changing. The container is acting as sort of a Mediator between our concrete code implementation and the fluidness of a container.
If we need to change functionality, we just swap the class in the container.
Another Step Futher
So you might be asking yourself ".. ok? So why do I need this mediator between my objects and these classes? Why wouldn't I just import the driver class that I need and just use that?"
The idea behind the container for Masonite is to "loosely couple" your application (typically concrete implementations) to your features.
Using the upload_image
function above, what would happen if the boss came to you and said "HEY! We need to start uploading everything to the file system instead, Amazon has gotten too expensive for our company" .. then what do you do? You'd probably go through your code and see something like this:
from static_assets import AmazonUpload
def upload_image(self):
return AmazonUpload.store('some-file.png')
def rename_image(self):
return AmazonUpload.rename('some-file.png', 'another-file.png')
def delete_image(self):
return AmazonUpload.delete('some-file.png')
def move_image(self):
return AmazonUpload.move('some-file.png', 'another-location.png')
def copy_image(self):
return AmazonUpload.copy('some-file.png', 'another-file.png')
Now I have 5 places where I have coded the AmazonUpload dependency and now have 5 concrete implementations that now needs a major refactor.
Let's take another look but let's sneakily put back our container so the parameter name matches the dictionary key again:
from static_assets import AmazonUpload
container = {'upload': AmazonUpload}
def upload_image(self, upload):
return upload.store('some-file.png')
def rename_image(self, upload):
return upload.rename('some-file.png', 'another-file.png')
def delete_image(self, upload):
return upload.delete('some-file.png')
def move_image(self, upload):
return upload.move('some-file.png', 'another-location.png')
def copy_image(self, upload):
return upload.copy('some-file.png', 'another-file.png')
The Automatic Part
Ok so now that you understand the concept of "this parameter has the same name as the dictionary key" we can take yet again, another step further.
Replacing the actual functionality of how Masonite handles dependency injection and using pseudo code, the automatic part might look something like:
NOTE: This is pseudo code and not working Python code.
class Container
providers = {'upload', AmazonUpload}
def resolve(self, object_to_resolve):
build_parameter_list = []
# Get all of the objects parameters
paramaters = inspect(object_to_resolve).parameters
# returns ['upload']
for paramater in paramaters:
if paramater in self.providers:
build_parameter_list.append(parameter)
return object_to_resolve(*build_parameter_list)
That's really it. To explain it in human terms, we are simply using Python inspection to get a list of all the parameters. For example upload_image
function above would give us 'upload'
because that is the parameter name.
Auto-resolving Dependency Injection
OK! So we have made it to the good stuff. Let's start auto resolving our dependencies! Let's take the upload_image
example above:
from container import Container
def upload_image(self, upload):
return upload.store('some-file.png')
uploaded_image = Container().resolve(upload_image)
# Looks into the container for the 'upload' key and injects it into the class.
We have completely removed the concrete implementation for the upload_image. It is now completely "service agnostic." It doesn't matter if it's an AmazonUpload
class or an AzureUpload
class and the dependency is only in 1 place. We can now swap functionality throughout out entire application at will.
Masonite Specific
The reason that containers are so powerful is there is a complete Mediator between our features and our application. In addition to this, all features are agnostic of all other features so this creates an extremely pluggable system.
Masonite uses a much more advanced way of finding the objects to resolve.
In Masonite we use this functionality like this:
class WelcomeController:
def show(self, Request):
Request.user().id
'Request' is a key in the container. This is a little too basic to let's take it, yet again, another step further and show how Masonite handles Function annotations.
Python Function Annotations
Python annotations have always been sort of quirky. It is basically a comment inline of a parameter. Seems odd. When they added this to Python I'm not sure it was suppose to serve a purpose at all to the core of Python. In fact it says this in the PEP:
The only way that annotations take on meaning is when they are interpreted by third-party libraries. These annotation consumers can do anything they want with a function's annotations.
So what Masonite did was that in addition to looping through the parameter list and resolving the dependencies, we also go through the annotations and resolve them too. Instead of annotating normally like this:
def upload_image(upload: "pass in the upload class"):
pass
We can take it a step further and pass in an entire class:
from static_files import AmazonUpload
def upload_image(upload: AmazonUpload):
pass
We are essentially annotating the parameter with a class instead of with a string. We can now use that annotation type (a specific class) to find in the container. This is more of a "get by dictionary value" instead of the previous examples which was "get by dictionary key."
In Masonite there are several parts of the application that are auto-resolved like the controllers, drivers and middleware. Auto resolving in Masonite will look like:
from masonite.view import View
from masonite.request import Request
class WelcomeController:
''' Controller For Welcoming The User '''
def show(self, view: View, request: Request):
''' Show Welcome Template '''
return view.render('welcome', {'request': request})
Subclasses (Advanced)
If you have understood everything above then we can get a little bit more advanced
In addition to fetching the class, we can also fetch a subclass of the class which is even more powerful. The container is a way to keep your application loosely coupled but if we go with annotations, we can't swap out classes from the container at will. We are now "tightly" coupled to the "concrete" implementation. In other words, we can only use 1 class.
Take this for example:
from masonite.drivers import UploadS3Driver
class UploadController:
''' Controller For Uploading Images '''
def show(self, upload: UploadS3Driver):
''' Show Welcome Template '''
return upload.store('some-file.png')
We can take this a step further by adding a parent class. The UploadS3Driver
looks like this on the backend:
...
from masonite.contracts import UploadContract
...
class UploadS3Driver(BaseDriver, UploadContract):
...
So with this, we can get the same class by just using the UploadContract
:
It will be good to know that the UploadContract
is not in the container
from masonite.contracts import UploadContract
class UploadController:
''' Controller For Uploading Images '''
def show(self, upload: UploadContract):
''' Show Welcome Template '''
return upload.store('some-file.png')
# returns an upload driver
How that works
This gets me the UploadS3Driver
class because it is a subclass of what we are looking for. Masonite handles this logic on the backend on which it should get.
There is a bit of a hierarchy on what Masonite should resolve.
The basics of it is that it looks for the class itself but if it can't find it (because the contracts are not in the container) then it will resolve a subclass of what you are looking for. If there is no match in the container then it will throw an exception.
Testing
What could be the best part of this structure is that it is extremely testable. Now we can import the controller and mock the parameters.
Here is an example on how we could mock this controller:
from app.http.controllers.WelcomeController import WelcomeController
from masonite.request import Request
from masonite.testsuite.TestSuite import generate_wsgi
class MockView:
def render(self, template, dictionary):
pass
def test_welcome_controller():
assert WelcomeController().show(MockView, Request(generate_wsgi()))
If you keep your controllers short and keep many of your dependencies in the container then you can easily create quick tests and mock objects very easily.
I hope you enjoyed the article! I'll be coming out with more like this if you are interested. You could give it a start on Github or join the Slack channel!
Top comments (0)