Now that we’ve shared our general goals for 3.0, we’d like to begin sharing a series of conversations about planned changes. Your input here in the forum on these issues is valued and we look forward to it.
Here is the first such proposal. It concerns Apostrophe’s “callAll” and “super” patterns.
What callAll does, and what’s good and bad about it
We have been thinking about the challenges people encounter with the “super” pattern, mainly in simple cases where they only want to add behavior, not change or override existing behavior. This comes up a lot with callAll
methods like pageBeforeSend
.
Some background:
If any module invokes apos.callAll('addYourNotes')
, Apostrophe looks for an addYourNotes
method in every module. If it exists, it gets called. This is a powerful and useful feature. Many developers have implemented at least one docBeforeSave
or pageBeforeSend
method.
This is great. But when your module extends another module, it is very difficult to be sure if the parent module already has an implementation. And therefore very hard to tell if you need to use the super
pattern.
Here is an example of the super
pattern, which we regularly use when we want to extend a method but still call the original version:
var superAddYourNotes = self.addYourNotes;
self.addYourNotes = function(notes) {
superAddYourNotes(notes);
notes.push(['my note']);
};
This is great, but if you don’t know if there is already an addYourNotes
method, it becomes more verbose:
var superAddYourNotes = self.addYourNotes || function(notes) {};
self.addYourNotes = function(notes) {
superAddYourNotes(notes);
notes.push(['my note']);
};
And in some cases much more complex than this, for instance if addYourNotes
takes a callback.
This is confusing and developers make mistakes.
What about events?
Other systems address this by using hooks and events instead. Apostrophe supports events too:
apos.emit(‘addYourNotes’, notes);
However, here are some problems with events:
- It’s hard to track where they are being handled, and hard or impossible to override behavior of other event handlers if you do want to do that. This is a big deal for us; we extend and override
callAll
methods on purpose all the time. - Another objection used to be no support for async. However a modern implementation of events could support returning promises from event handlers, and resolving them before the next handler is run.
Recently we worked out a strategy to solve the first issue. This is significant because we’ve been puzzling over whether we can do better for a long time now and the richer Apostrophe becomes, the more these conflicts occur.
What if callAll
was replaced by emitting an event from the module that’s invoking it, and when implementing any module, you could register event handlers like this:
// in apostrophe-pages, let's emit a "beforeSend" event before sending a page to the user
self.emit('beforeSend', req).then(function() { all the handlers have finished now... });
// in another module, let's listen to that and load some candy, a custom thing for this project
self.on('apostrophe-pages:beforeSend', 'loadTheCandy');
self.loadTheCandy = function(req) {
return request('url/to/some/candy').then(function(candy) {
req.data.candy = candy;
});
});
Note that loadTheCandy
is named for the specific work it does for this module’s needs. It’s NOT the same name as the event name. That is on purpose. In fact an error is thrown if you try to make the method name the same as the event name.
Note also that we give the method name to self.on
, we don’t pass self.loadTheCandy
directly.
This way we get the following benefits:
-
Anybody can write a new event handler without worrying about the
super
pattern, if all they want to do is add new behavior. You just name the handler method distinctively to indicate its distinct purpose. In fact it throws an error if the method name is the same as the event name. This is a common case and it should be easy. -
If you DO want to modify behavior, you CAN use the super pattern… you just do it to the specific event handler you wanted to modify, not the event has a whole (that is, you use the
super
method to extend self.loadTheCandy). -
If you need to do async work, cool. Just return a promise. The event emitter will wait before running the next handler.
-
Event handlers aren’t hard to find or hard to intentionally override, because they are registered in a consistent way, and they are always methods of the module that registered them (you can’t pass an inline function, for instance).
-
Since you call
self.on
on your own module, but you can use a namespaced event name to listen to events from another module, you don’t have to worry about whether a module is initialized by Apostrophe before or after yours (if it were “self.apos.pages.on”, you might have a chicken and egg problem there).
A bonus feature: running “before” another module’s handler(s)
One feature we’ve found very useful in 2.x is the ability to set an expressMiddleware
method to run before
the method of another module:
self.expressMiddleware = {
before: 'apostrophe-pages',
middleware: function(req, res, next) { ... }
};
We’d like to offer something similar with the new events:
self.on('apostrophe-pages:beforeSend', 'loadTheCandy', { before: 'other-module-name' });
This would often be a simpler alternative to using the super
pattern with the specific event handler of other-module-name
that you want your code to precede.
Async: the super pattern with promises
We mentioned that promises would be in play. If you do want to alter the behavior of an existing event handler that you inherited by altering its arguments, it’s nice if you don’t have to be directly aware whether it’s async or not.
Here’s an example of code that would do that correctly:
var superLoadTheCandy = self.loadTheCandy;
self.loadTheCandy = function() {
// modify req.query in some way perhaps, then...
return Promise.try(function() {
return superLoadTheCandy(req);
}).then(function() {
// do even more work
});
};
This works’ because Bluebird’s Promise.try
runs the function much like then
runs a function: if that function returns a promise, great; if it returns an ordinary value, it wraps that value as a promise. Either way, our code does the right thing.
OK, that’s enough for one post!
A penny for your thoughts, everyone?
Thanks! Excited to hear from the community on this issue.