extensible
Create highly extensible software components.
Installation
npm install --save extensible
Introduction
This library simplifies modularization of cross-cutting concerns in
libraries/applications. It's a simple framework for doing aspect-oriented
programming in
javascript software.
Objects created by the exported function can be extended with methods(empty
prototypes) and middlewares that
implement aspects of the object's methods.
Usage
For maximum reuse, middlewares should be very small and keep their knowledge of
other installed middlewares to a minimum. In practice, they will know something
about middlewares installed in deeper layers.
The best way to understand is through an example that shows its features. We
will build a tiny database library based on leveldb(leveldown) which can be
extended via plugins. The following code defines the core API and the
innermost middleware/layer:
var leveldown = require('leveldown');
var extensible = require('extensible');
var levelup = extensible();
levelup.$defineMethod('open', 'location, cb');
levelup.$defineMethod('get', 'key, cb');
levelup.$defineMethod('put', 'key, value, cb');
levelup.$defineMethod('del', 'key, cb');
levelup.$use({
open: function(location, cb) {
this.db = leveldown(location);
this.db.open(cb);
},
get: function(key, cb) {
this.db.get(key, cb);
},
put: function(key, value, cb) {
this.db.put(key, value, cb);
},
del: function(key, cb) {
this.db.del(key, cb);
}
});
module.exports = function(options) {
return levelup.$fork();
};
This will result in a very simple but working database API:
var levelup = require('./levelup');
var db = levelup();
var k = new Buffer([1, 2, 3]);
var v = new Buffer([1, 2, 3, 4]);
db.open('./db-example', function(err) {
db.put(k, v, function(err) {
db.get(k, function(err, val) {
console.error(val);
});
});
});
The created module only supports buffers/strings as keys/values. Lets build a
plugin which adds support to using arbitrary objects. We will use 'msgpack-js'
for serializing values and 'bytewise' for serializing keys:
var bytewise = require('bytewise');
var msgpack = require('msgpack-js');
module.exports = {
get: function(key, cb, next) {
next(bytewise.encode(key), function(err, value) {
if (err) return cb(err);
cb(null, msgpack.decode(value));
});
},
put: function(key, value, cb, next) {
next(bytewise.encode(key), msgpack.encode(value), cb);
},
del: function(key, cb, next) {
next(bytewise.encode(key), cb);
}
};
Note that we havent altered the 'open' method. When a middleware doesn't
implement a method, it will automatically invoke next layer.
To use the new feature, install the middleware into the db object, which will
wrap it into another layer:
var levelup = require('./levelup');
var levelupPack = require('./levelup-pack');
var db = levelup();
db.$use(levelupPack);
var k = [1, 2, 3];
var v = {name: 'john doe'};
db.open('./db-example', function(err) {
db.put(k, v, function(err) {
db.get(k, function(err, val) {
console.error(val);
});
});
});
Middlewares can also be functions, which are called with the context set to the
object being extended. To illustrate lets build a plugin which converts our API
to return objects implementing the Promises/A+ spec through the 'rsvp' promise
library.
This example will also show how to perform instrospection and modify a method
signature while maintaining compatibility with previous layers:
var rsvp = require('rsvp');
module.exports = function() {
var _this = this;
var rv = {};
this.$eachMethodDescriptor(function(method) {
var newArgs = method.args.slice();
var lastArg = newArgs.pop();
if (lastArg !== 'cb')
return;
_this.$defineMethod(method.name, newArgs.join(','));
rv[method.name] = function() {
var next = arguments[arguments.length - 4];
var args = Array.prototype.slice.call(arguments, 0, arguments.length - 4);
return new rsvp.Promise(function(resolve, reject) {
args.push(function(err, result) {
if (err) return reject(err);
resolve(result);
});
next.apply(this, args);
});
};
});
return rv;
};
Now install on the db object to wrap it into another layer:
var levelup = require('./levelup');
var levelupPack = require('./levelup-pack');
var levelupPromise = require('./levelup-promise');
var db = levelup();
db.$use(levelupPack);
db.$use(levelupPromise);
var k1 = [1, 2, 3], k2 = [4, 5, 6];
var v1 = {name: 'foo'}, v2 = {name: 'bar'};
db.open('./db-example').then(function(err) {
return db.put(k1, v1);
}).then(function() {
return db.put(k2, v2);
}).then(function() {
return db.get(k2);
}).then(function(val) {
console.log(val);
return db.get(k1);
}).then(function(val) {
console.log(val);
}).catch(function(err) {
console.error(err);
});
API
extensible()
The extensible
constructor function returns a new, empty object which can
be the base of a new extensible component.
extensible#$defineMethod(name[, args[, descriptor]])
Defines a new empty method on the object. The object has no behavior until a
middleware implementing some aspect is installed with use()
. Its possible to
redefine an existing method with a different number of arguments/signature, in
which case the middleware must take care of adapting the arguments for the next
layer(which still uses the old signature).
name
is the method name and may be any valid javascript property name.
args
is a string with comma-separated parameter names which are used to
generate the middleware wrapper functions, so it must match the middleware's
method signature.
descriptor
is an object containing metadata that can be discovered and
introspected later, possibly by other middlewares/plugins. The object passed to
descriptor
is merged with an object with the {name(string), args(array)}
schema.
extensible#$use(middleware[, opts])
Extend object with middleware
, which will become the new top layer.
middleware
may implement any of the methods already defined with
defineMethod()
. Other methods are simply ignored, even if they are added
later.
The implemented methods must have the same number of arguments passed to the
last defineMethod()
call. It may can optionally use the next
,
layer
, state
and self
arguments described as follows:
-
next
: Helper function to call the next middleware layer. This function has
the same parameters declared with defineMethod()
and may also accept an
extra state
described below. This must not be called if the middleware was
the first added to the object(it is the bottom layer). If the method was
upgraded, next
will have the signature of the method defined in the
next middleware layer.
-
layer
: Object that wraps the current middleware and has a reference to the
next middleware through a 'next' property. This can be used to call another
method in a lower layer without passing through the whole middleware
pipeline.
-
state
: Argument which is passed implicitly through the middleware
pipeline and may be modified by any of the invoked middlewares. One use
case for this is to pass options to a non-adjacent lower middleware
separated by middlewares with 'incompatible signatures'.
-
self
: Reference to the extensible object. This is only used by the special
$call
method(See the '$fork' method below) for when the callable object is
called like a method(this
no longer points to the extensible object).
If middleware
is a function it will be treated as a factory and called with
the object being extended as context(this
) and opts
as argument.
extensible#$getMethodDescriptor(name)
Returns the descriptor
object for a previously defined method.
extensible#$eachMethodDescriptor(cb)
Invokes cb
for each method descriptor defined in the object. The iteration
order is not predictable.
extensible#$eachLayer(cb)
Invokes cb
for each layer(object wrapping a middleware) in the object. The
iteration order is bottom->top (middlewares installed first are visited first).
extensible#$fork([asCallable[, inheritProperties]])
Forks by creating a new object with all methods and layers from the current
object. It may be called with two optional arguments:
-
asCallable
: The forked object is callable, meaning that a function is
returned. To implement the function behavior, implement the $call method
(Read below). For most purposes, it may be used as a normal extensible()
that can be called like a function.
-
inheritProperties
: If true, the parent properties will be inherited
through the prototype chain instead of simply copied. If a falsy value
is passed(the default) the forked object will contain separate properties
for the layers and descriptors, so it may be extended independently of
the original object.
extensible#$instance()
For normal objects, this creates a child object linked through the prototype
chain(Unlike $fork, the child object cannot be extended independently).
If the object is callable, the properties/methods are simply copied(normal
javascript inheritance becomes broken for this object and its children).
This will call a $constructor
method if defined, forwarding any passed
arguments.
extensible#$instanceOf()
Replacement for instanceof
that works with callable objects.
Special methods
Extensible objects can implement the following special methods(in the same
middleware-based architecture of normal methods):
$call
: This implements the behavior of calling callable objects.$constructor
: Initializer called with arguments passed to $instance