Apostrophe CMS Main Site Forum Home

An Apostrophe 3.0 design conversation: improving callAll and the process of overriding methods and events


#1

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:

  1. 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.

  2. 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).

  3. If you need to do async work, cool. Just return a promise. The event emitter will wait before running the next handler.

  4. 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).

  5. 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.