erin-noe-payne / nject

nodejs dependency injector
31 stars 5 forks source link

Nject

Nject is a simple nodejs library for handling dependency tree resolution and injection, inspired by angularjs's DI system. It maps variable names to registered dependencies at time of injection. Here's how it looks...

var nject = require('nject');
var tree = new nject.Tree();

tree.constant('a', 7);
tree.register('b', function(){
    return 9;
});

tree.register('sum',
    /*
    variable names matter!
    a will be injected with nject's registered constant a
    b will be injected with constant b
    */
    function(a, b) {
        return a+b;
    });

tree.resolve('sum') == 16;

Api

new nject.Tree()

Constructs a new nject dependency tree.

tree.constant(key, value, [opts])

Registers a constant or constants with the given key name. If the key is a plain object it will be iterated over, using the key value pairs for registration.

A constant will not be resolved, and will be injected into factories as-is.

This function just passes through to register with the constant option set to true

tree.constant('a', 7);
tree.constant('a', 7, {aggregateOn: 'numbers'});
tree.constant({
    b: 8,
    c: 9
}, {aggregateOn: 'numbers'});

tree.register(key, value, [opts])

Registers a dependency or dependencies with the given key name. If the key is a plain object it will be iterated over, using the key value pairs for registration.

Unless specified as a constant in the opts, the registered dependency is assumed to be a factory - a function whose arguments (variable names) declare its dependencies. At time of resolution, the factory function will be invoked with its dependencies.

Nject will allow you to overwrite a registered dependency. Last write wins. If the dependency you are overwriting has already been resolved, it's resolved value is cleared from the cache, and its destroy event is emitted to allow for cleanup.

A few things to note:

tree.isRegistered(key)

tree.resolve([key])

Resolves the given key. If no key is provided, resolves all registered keys on the tree, and returns an object whose key / value pairs are each registered key and its resolved value.

When resolving a given value, the tree will resolve only those dependencies that are on the required path. All resolved values are cached - a factory function is never invoked more than once.

Resolve will throw an error if it encounters an unregistered dependency or circular dependencies on the resolution path.

tree.destroy([key])

Clears the resolved value for the provided key from the cache and emits the destroy event on the context of the factory function. If no key is provided, invokes destroy for all registered keys.

Think of this as the inverse of resolve. When a dependency is destroyed, any other resolved value that depended on it is also destroyed. This cascades, so that anything that had the provided key on its resolution path will be destroyed.

Events

Tree is an event emitter, and supports events for debugging and logging. All standard actions (registration, resolution, etc) are synchronous, and these methods will throw errors as needed.

'debug'

The tree will emit debug messages as it goes through the standard steps of registration and resolution. These messages can be useful in logging or diagnosing unexpected behavior especially in the resolution process.

'warn'

The tree will emit warning messages when actions are taken which are legal, but may result in unexpected behavior. In particular, the tree emits a warning when a user registers a new dependency with the same name as an already registered dependency.

Examples

Understanding resolution

Given the following tree...

var tree = new nject.Tree();

// Register a configuration object
tree.constant('config', {
    dbUrl : 'localhost:27017'
});

// Register a 3rd party lib on the tree for DI
tree.constant('database', function(config){
    // m
});

tree.register('User', function(database){
    var User = {}
    // do some stuff...

    return User;
});

tree.register('Account', function(database){
    var Account = {}
    // do some other stuff...

    return Account;
});

tree.resolve('User');

tree.resolve('Account');

var resolved = tree.resolve();

When we resolve User, the tree will also resolve database and config, which are dependencies on the resolution path, and their resolved values will be cached. The tree will NOT resolve Account, because it was not a dependency of User.

When we next resolve Account, the tree will again walk the resolution path. In this case, it will not try to invoke the database factory again, but just pull the cached value.

When we finally resolve the entire tree with tree.resolve(), all dependencies have been resolved, and the tree will just pull cached values. The returned object resolved will have keys for each of the registered dependencies.

Aggregation

You can use aggregation to group dependencies on a registration key so that they can be injected together.

tree.register('app', function(){return express()});

tree.register('AuthenticationController', function(){/*...*/}, {aggregateOn : 'controllers'});
tree.register('UserController', function(){/*...*/}, {aggregateOn : 'controllers'});
tree.register('PostController', function(){/*...*/}, {aggregateOn : 'controllers'});

tree.register('Router', function(app, controllers){
    _.each(controllers, function(ctrl){
        app.use(ctrl);
    });
});

In this example, we have several controllers, that we are aggregating on the controllers key. This allows to inject a single dependency which rolls up all of the aggregated keys, and can be iterated over. Of course, the Router could simple require each controller individually, or we could write a controllers dependency that manually injects each controller and returns the roll up object. By using aggregation we save some effort and maintenance cost as controllers may be added or removed from the tree.

Note that the aggregation key is a valid dependency key, and it can be resolved directly:

var controllers = tree.resolve('controllers');

In addition, dependencies may be aggregated on multiple aggregation keys using an array. Extending the previous example:

tree.register('app', function(){return express()});

tree.register('UserModel', function(){/*...*/}, {aggregateOn : ['models', 'User']});
tree.register('PostModel', function(){/*...*/}, {aggregateOn : ['models', 'Post']});

tree.register('AuthenticationController', function(){/*...*/}, {aggregateOn : 'controllers'});
tree.register('UserController', function(){/*...*/}, {aggregateOn : ['controllers', 'User']});
tree.register('PostController', function(){/*...*/}, {aggregateOn : ['controllers', 'Post']});

tree.register('Router', function(app, controllers){
    _.each(controllers, function(ctrl){
        app.use(ctrl);
    });
});

tree.resolve('controllers');
// resolves to the controllers objects

tree.resolve('User');
// resolves to the UserModel and UserController

Using a class

In previous examples we have looked at factory functions that return an explicit value. However, factory functions are invoked is a constructor, and if they do not return an explicit value then the constructed object will be used. This means you can use a javascript class as your dependency.

// explicit return
tree.register('UserCtrl', function(database){

    var User = {
        get : function(id){
            database.find(id)
        },
        create : function(instance){
            database.create(instance)
        },
        update : function(instance){
            database.update(instance)
        },
        destroy : function(instance){
            database.destroy(instance)
        },
    };

    return User;
});

// using a class
var UserCtrl = function(database){
    this.database = database;
};

UserCtrl.prototype.get = function(id){
    this.database.find(id);
};

UserCtrl.prototype.create = function(instance){
    this.database.create(instance);
};

UserCtrl.prototype.update = function(instance){
    this.database.update(instance);
};

UserCtrl.prototype.destroy = function(instance){
    this.database.destroy(instance);
};

tree.register('UserCtrl', UserCtrl);

Ultimately these achieve the same thing - it's just a matter of preference. Especially if you use cofeescript, are looking forward to the es6 spec, or are more comfortable with DI patterns coming from Java, you may prefer the class syntax.

Using destroy event for cleanup

Often you may find yourself writing a dependency that starts some persistent process or captures variables in closure - such as opening a database connection or setting an interval. These can be dangerous because they can cause your process to leak memory.

Especially during unit testing, where you may create and and destroy many instances of dependency, it is important that you cleanup correctly or you risk unexpected behavior.

That's what the destroy event is for!

// ex1
tree.register('logger', function(){
    var interval = setInterval(function(){
        console.log('Im still alive!');
    });

    this.on('destroy', function(){
        clearInterval(interval);
    });
});

// ex2
var logger2 = function(){
    this.interval = setInterval(function(){
        console.log('Im still alive!');
    });

    this.on('destroy', function(){
        this.cleanup();
    });
};

logger2.prototype.cleanup = function(){
    clearInterval(this.interval);
};

tree.register('logger2', logger2);

// ex3
tree.register('dbConnectionPool', function(db, config){
    var connections = db.createConnections(config.dbUrl);

    this.on('destroy', function(){
        db.closeConnections(connections);
    });
});

Here we have 3 examples of registered dependencies that need to do some sort of cleanup. Imagine if we were unit testing our logger:


beforeEach(function(){
    tree.resolve('logger');
});

afterEach(function(){
    tree.destroy('logger');
});

If we do not destroy the logger in the afterEach block, or if the logger did not listen for the destroy event and clear its interval, then each successive test would leak another interval. After running 20 tests we would have 20 different intervals spamming the console.

Obviously the stakes are low with console.log. But if you are doing something more meaningful (and less obvious) in that interval, your tests can easily start to fail in unexpected ways.

The second example captures the same use case, but using the class syntax. Notice that although the factory is newed up, it is already an instance of EventEmitter, and you can still register for the 'destroy' event.

How do you know when the destroy event will be triggered?

Changelog

2.0.1

2.0.0

1.3.1

1.3.0

1.2.0

1.1.0

1.0.0