Ashley
Ashley is a dependency injection container for JavaScript.
Install / Use
/learn @jiripospisil/AshleyREADME
Ashley
Ashley is a dependency injection container for JavaScript. Learn more about dependency injection or more generally about inversion of control on Wikipedia.
Installation
npm install ashley
Note that it makes a heavy use of async functions and thus requires a fairly
recent version of Node.js (7.x or newer). Depending on the version, you might
need to pass the --harmony-async-await flag.
Usage
A new instance of Ashley can be created simply by calling its constructor. Ashley instances do not share any state and there can be any number of them within the same application. If fact, it is sometimes beneficial to have more than one them as they can form hierarchies. More on that later.
const Ashley = require('ashley');
const ashley = new Ashley();
Note that the code samples will use the container directly for obtaining the configured objects. To take advantage of the dependency injection pattern in a real application, the container should be only used explicitly during the application's initialization process to set up the dependencies. Read more in the Recommendations section.
Binding instances
The most basic thing Ashley can bind is an instance of a class. A class in this
context is anything that needs to be instantiated with the new operator.
ashley.instance('Logger', ConsoleLogger);
const logger = await ashley.resolve('Logger')
// the same as
const logger = new ConsoleLogger();
The first argument of the instance method is a name. This name can be used
when resolving instances or declaring dependencies. The second argument can
either be the class itself or a path to a file which defines it. Finally, the
third argument is a list of dependencies.
ashley.instance('Logger', ConsoleLogger);
ashley.instance('OrderService', OrderService, ['Logger']);
const orderService = await ashley.resolve('OrderService')
// the same as
ashley.instance('Logger', 'src/console_logger');
ashley.instance('OrderService', 'src/order_service', ['Logger']);
const orderService = await ashley.resolve('OrderService')
// the same as
const logger = new ConsoleLogger();
const orderService = new Orderservice(logger);
Note that when a relative path is provided, Ashley needs to know the root path from which the relative paths should be resolved.
const ashley = new Ashley({
root: __dirname
});
There are objects within all applications which are meant to be used as singletons but making them actual singletons is problematic.
An alternative is to write regular classes but let Ashley worry about their life
time (scope) once they are instantiated. Ashley provides two scopes out of the
box - Singleton and Prototype. The Singleton scope is used by default and
will make Ashley to always return the same instance each time it's requested.
The Prototype scope, on the other hand, will make Ashley to always create new
instances when requested.
ashley.instance('DbConnection', 'src/rethink_db_connection', [], {
scope: 'Singleton' // default
});
ashley.instance('TimePoint', 'src/time_point', [], {
scope: 'Prototype'
});
For an object such as DbConnection to be useful, it needs to actually
establish an connection which will most likely be an asynchronous process. When
binding the object, it's possible to specify that an initialization method needs
to be called for the object to be fully ready. This is similar to a constructor
but allows the method to be asynchronous.
ashley.instance('DbConnection', 'src/rethink_db_connection', [], {
initialize: true
});
When set to true, Ashley will look for an async method called initialize
and will wait for it to finish before proceeding. It's possible to specify a
different initialize method by setting initialize to the name.
class RethinkDbConnection {
async init() {
this.connection = await r.connect();
}
}
ashley.instance('DbConnection', RethinkDbConnection, [], {
initialize: 'init'
});
The initialize method should either succeed or throw an error. It's important
to make sure that time outs are set and handled as well otherwise Ashley might
wait indefinitely.
The initialization method is the same for all instances of the object. Ashley
also provides a way to set up a specific instance by defining an setup
function when binding the instance.
ashley.instance('ErrorLogger', ConsoleLogger, [], {
setup: function(logger) {
logger.setBold(true);
logger.setColor(ConsoleLogger.COLOR_RED);
}
});
ashley.instance('Logger', ConsoleLogger);
The setup function will receive the instantiated object as its only
parameter. Note that the function will be called only once if the scope is set
to Singleton and may or may not be an async function.
There's also the option to deinitialize instances which works the same way
except it's invoked when the container is being shut down. It generally
depends on the scope used whether the method is supported or when it's
called.
The provided Singleton and Prototype scopes will call the method only when
the container is being shutdown. This means that the individual instances need
to be kept in memory until that happens. Consider calling the deinitialize
method manually on the objects in case they are short lived and keeping them in
memory until the container shuts down is problematic.
class RethinkDbConnection {
async initialize() {
this.connection = await r.connect();
}
async deinitialize() {
if (this.connection) {
await this.connection.close();
}
}
}
ashley.instance('DbConnection', RethinkDbConnection, [], {
initialize: true,
deinitialize: true
});
// ...
await ashley.shutdown();
Note that Ashley does NOT catch errors from these methods. It's up to the
developer to handle the failure scenarios themselves. It's especially important
for the deinitialize method as throwing within the method will halt
de-initialization of the remaining binds.
Binding plain objects
Not everything needs to be wrapped in a class and sometimes it's convenient to bind just plain objects.
ashley.object('Config', { port: 9001 });
ashley.object('Title', 'Zoo');
const title = await ashley.resolve('Title');
By default the bound objects are passed by reference and thus everyone will
receive the very same object and can possibly modified it. An alternative is to
specify the clone option which will create a deep copy of the object each time
it's requested.
ashley.object('FreshConfig', { port: 9001 }, {
clone: true // uses https://lodash.com/docs/4.15.0#cloneDeep
});
Since some objects require special care when deep copying, it's possible to specify the function that should be used for the purpose.
ashley.object('Config', { port: 9001 }, {
clone: function(obj) {
// ...
return copy;
}
});
Note that you cannot specify the target using a file path since a string is also a valid target in itself.
ashley.object('Config', 'src/config');
await ashley.resolve('Config'); // => 'src/config'
Binding functions
When integrating with 3rd party frameworks or libraries, it's sometimes necessary to register callbacks which will later be invoked with a given set of parameters. For example when using Koa.
const Koa = require('koa');
const app = new Koa();
const ConsoleLogger = require('src/console_logger');
const logger = new ConsoleLogger();
app.use(async function Index(ctx, next) {
logger.info(`Serving ${ctx.request.ip}`);
ctx.body = 'Hello world';
});
// or
app.use(require('src/index'));
The goal here is to invoke the callback with not only the parameters provided by
Koa (ctx and next) but also with configured dependencies, in this case an instance of
ConsoleLogger. It's possible to take advantage of the function method as
follows.
ashley.instance('Logger', 'src/console_logger');
ashley.function('Index', 'src/index', [Ashley._, Ashley._, 'Logger']);
app.use(await ashley.resolve('Index'));
Defining a function and passing it immediately afterwards is a very common
pattern and can be shortened to just a single line. It takes advantage of the
fact that binding a target returns an async function which resolves to the
target.
app.use(await ashley.function('Index', 'src/index', [Ashley._, Ashley._, 'Logger']));
Notice the use of the Ashley._ placeholder. When present, it will be replaced
with the parameter the callback was called with by the framework. In addition,
Ashley will resolve the dependencies and pass all of it to the user defined
function.
// src/index
module.exports = async function Index(ctx, next, logger) {
logger.info(`Serving ${ctx.request.ip}`);
ctx.body = 'Hello world';
}
It's possible to use the placeholder multiple times and in any order. If the
callback is called with fewer parameters than expected, the remaining
placeholders are passed in as undefined, followed by the declared
dependencies. When the number of parameters is greater than expected, only tho
