Dependency Injection¶
Introduction¶
Dependency injection is a design pattern that allows us to remove tightly coupled dependencies from our code, making it easier to maintain and expand upon as an application grows in size.
A hardcoded dependency
class MyClass(object):
def __init__(self):
self.some_dependency = SomeDependency()
my_class = MyClass()
Utilizing dependency injection
class MyClass(object):
def __init__(self, some_dependency):
self.some_dependency = some_dependency
my_class = MyClass(SomeDependency())
As you can see above, the latter removes the dependency from the class itself, creating a looser coupling between components of the application.
The lifecycle of a dependency¶
Dependencies within Watson go through two events prior to being retrieved from the container.
- watson.di.container.PRE_EVENT
- Triggered prior to instantiating the dependency
- watson.di.container.POST_EVENT
- Triggered after instantiating the dependency, by prior to being returned
These events are only triggered once per dependency, unless the dependency is defined as a ‘prototype’, in which case a new instance of the dependency is retrieved on each request.
Example Usage¶
Watson provides an easy to use IoC (Inversion of Control) container which allows these sorts of dependencies to be managed easily. Lets take a look at how we might instantiate a database connection without dependency injection (for a more complete example of this, check out watson-db)
app_name/db.py
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
some_engine = create_engine('postgresql://scott:tiger@localhost/')
Session = sessionmaker(bind=some_engine)
session = Session()
app_name/controllers/user.py
from watson.framework import controllers
from app_name import db
class Profile(controllers.Rest):
def GET(self):
return {
'users': db.session.query(User).all()
}
def POST(self):
user = User(name='user1')
db.session.add(user)
db.session.commit()
One thing to note here is that the configuration for the collection is stored within the code itself. While we could abstract this out to another module, there would still be some sort of dependency on retrieving the configuration from that module. We also introduce a hardcoded dependency by requiring the db module. By using the IocContainer, we can abstract both of these issues out keeping our codebase clean.
Using the IocContainer¶
First we’ll create code required to connect to the database, removing any hardcoded configuration details (note this is purely an example).
app_name/db.py
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
Session = sessionmaker()
def create_session(container, connection_string):
some_engine = create_engine(connection_string)
return Session(bind=some_engine)
Next we have to configure the dependency within the applications configuration settings. Learn more about the ways to configure your dependencies.
app_name/config/config.py
dependencies = {
'definitions': {
'db_read': {
'item': 'app_name.db.create_session',
'init': {
'connection_string': 'postgresql://read:access@localhost/'
}
},
'db_write': {
'item': 'app_name.db.create_session',
'init': {
'connection_string': 'postgresql://write:access@localhost/'
}
}
}
}
We now have two dependencies defined in the applications configuration settings. One of the additional benefits of using the IoC container is that subsequent requests for a dependency will return an already instantiated instance of the dependency (unless otherwise specified).
Now all that’s left is to retrieve the dependency from the container. We can do this by calling container.get(dependency_name). As controllers are retrieved from the container and extend ContainerAware, our container is automatically injected into them.
app_name/controllers/user.py
from watson.framework import controllers
class Profile(controllers.Rest):
def GET(self):
# we only want to read from a slave for some reason
db = self.get('db_read')
return {
'users': db.query(User).all()
}
def POST(self):
# we only want writes to go to a specific database
db = self.get('db_write')
user = User(name='user1')
db.add(user)
db.commit()
We can also take this a step further and remove the container itself so that we’re not utilizing it as a service locator (db = self.get(‘db_*’)). We do this by adding the controller itself to the dependency definitions, and injecting the dependency either as a property, setter, or through the constructor. We can get access to the container itself (for retrieving dependencies or configurtion) via lambdas, or just by the same name as the definition. Note that you can also omit the ‘item’ key if you are configuring a controller.
app_name/config/config.py
dependencies = {
'definitions': {
'db_read': {
'item': 'app_name.db.create_session',
'init': {
'connection_string': 'postgresql://read:access@localhost/'
}
},
'db_write': {
'item': 'app_name.db.create_session',
'init': {
'connection_string': 'postgresql://write:access@localhost/'
}
},
'app_name.controllers.user.Profile': {
'property': {
'db_read': 'db_read', # References the db_read definition
'db_write': 'db_write'
}
}
}
}
Now we simply modify our controller to suit the new definitions…
app_name/controllers/user.py
from watson.framework import controllers
class Profile(controllers.Rest):
db_read = None
db_write = None
def GET(self):
return {
'users': self.db_read.query(User).all()
}
def POST(self):
user = User(name='user1')
self.db_write.add(user)
self.db_write.commit()
Configuring the container¶
The container is defined within your applications configuration under the key ‘dependencies’ as seen below.
dependencies = {
'params': {
'param_name': 'value'
},
'definitions': {
'name': {
'item': 'package.module.object',
'type': 'singleton',
'init': {
'keyword': 'arg'
},
'property': {
'attribute': 'value'
},
'setter': {
'method_name': {
'keyword': 'arg'
}
}
}
}
}
Lets break this down into it’s different components:
params
'params': {
'param_name': 'value'
}
Params are arguments that can be inserted into dependencies via init, property or setter processors. Any argument that is being used in one of the above processor definitions will be evaluated against the params and replaced with it’s value. If a param value has the same name as a dependency, then that dependency itself will be injected.
An example dependency using params¶
dependencies = {
'params': {
'host': '127.0.0.1'
},
'definitions': {
'db': {
'item': 'app.db',
'init': {
'hostname': 'host'
}
}
}
}
When the above dependency is retrieved, the ‘host’ param will be injected into the objects constructor.