Omniplugin system
  1. 1. How it started
  2. 2. Benefits
  3. 3. Other applications
  4. 4. The contrary
  5. 5. Some practical examples
    1. 5.1. PHP
    2. 5.2. JavaScript
    3. 5.3. Delphi

Omniplugin is what I call a system that works as a one large host for plugins without functions that do some job instead of simply retrieving information (e.g. adding blog post vs. gettings its author by post date).

In a nutshell, an action is not performed, it is requested by firing an event – this totally differs from normal approach when you call PHPAddPost('subject''body', ...) to create a new post on forum.
Instead, something like PHPEvents::Fire('add post', array('subject''body', ...))is executed. What's the difference? We'll see this in a moment.

How it started

The idea of such system started to form near April 2010 when I started to write the second version of my Imageboard Search Engine. It was still very raw and its «pluggability» was limited to what I called NamedObjectGroup – actually a folder with scripts inside, each file being one «plugin» which could be called as «group/script_name» (e.g. «actions/search»).

It was different from simplly including a file as «group/script.php» because it supported redirections and hooks: for example, «actions/main» (that formed the main page) could be redirected to «actions/dashboard». Hooks allowed to connect other plugins when some actions were called – e.g. run post-hook after «actions/search» to cache the result and pre-hook – to see if this search was cached and output it if it was.
Hooks could be used to send confirmation e-mail to newlyregistered user (post-"action/signup")and many more things.

But this wasn't the main feature yet. Core thing that spiced NamedObjectGroup was that objects could be called in chain and shared the result of each member execution (sounds pretty obscure).
The concept is really simple: say, we need to format and output a page. What are typicalactions for this?

  1. Load some language pack – it's optional but required in most cases (unless we're outputting in XML or JSON for robots);
  2. Generate result in «raw» format (e.g. array of forum posts matching search query);
  3. Output it.

How this corresponds to omniplugin concept?

PHP
$userLang 'ru';
$actionName 'search';
$data = array('query' => 'tag:cat time:yesterday');
$outputFormat 'html';

NamedObjectGroup::ChainCallsOnData($data, array(array('languages'$userLang),
                                                array(
'actions'$actionName)));
                                                array(
'formats'$outputFormat)));

echo 
$data['result'];

And that's the only thing found in index.php – almost no more code, totally abstracted out of any particular page processing.

How it works? It calls 3 actions: one of languages, one of actions and one of formats. The first one loads a language into current chain – don't forget that context is shared among all members in the same chain. Then action takes over and generates some result which it then puts into the same context. Finally, format works on that generated result (for proper processing it must understand its format, of course – or hand it over to a template which will format it).

Then we come back and simply output it.

Benefits

Using this hook + chain approach we can separate logically different code with great deal of efficiency and uniformity – say, we need to add caching mechanism. For the starter it can be very simple – just saving the result baased on ETag generated as «language action format» plus hash of all parameters passed (e.g. as blunt as md5(serialize($data))).

We need to hook «pre-languages-*» and «post-languages-*» calls (i.e. calls of any object inside languages group). The rest is trivial: in pre-hook we calculate the ETag and see if we got a cache hit – if yes, fill $data with what was stored in cache and stop chain processing. In post-hook we simply serialize current context ($data) and put it in cache.

Then if we decide to turn caching off we can just delete hook file and this will automatically remove all trails of what previously was a caching mechanism.
On the contrary, if we decide to make it more sophisticate it will be much easier than usually– as we don't need to go over all the place and find pieces of code that deal with caching, sometimes forgetting to change a few of them and spending the evening hunting for bunnies.

Code is kept much more concise and about one topic – if you featch search results you don't have to worry about caching it right here – you just do your job and then, if necessary, someone else picks up and saves your efforts in cache. And someone else picks up before you run and saves your processing by fetching result back from cache.

Other applications

This approach is by no means limited to web development – it's can be made omnipresent. Say, you're writing a esktop application. When user hits a button standard approach looks like:

pascalif OpenDialog.Execute and OpenArchive(OpenDialog.FileName) then
  UpdateUI;

This is already good if it's like this instead of, say:

pascalif OpenDialog.Execute then
begin
  Arc := TFileStream.Create(OpenDialog.FileName, fmOpenRead);
  Arc.Read(Header, 4);
  if Header = 'ARCH' then
  begin
    Arc.Read(FileCount, 4);
    ...

    if AllOK then
    begin
      ArcFileNameLabel.Caption := OpenDialog.FileName;
      CloseButton.Enabled := True;

      FileList.Clear;
      for I := 0 to FileCount - 1 do
      begin
        FileList.Add(Files[I]);
        FileList.Items[I].OnClick := ShowFileInfo;
        ...

and so on – at least we've got the logics separated. However, it's nevertheless stiff: what if we decide to log all archives being opened? Simple – we'll need to go to OpenArchive() and put a call to Log(). But what if we need to log a dozen of actions? Sure, inserting just a dozen of calls – each call being one line – isn't a deal. Heck, even if it's fifty calls we can still make it – it's just one line that will stay there forever.
But what if we decide we need just one more parameter passed to Log?

Okay, let's say we coped with logging and are sure its calls are now carved in stone. What will happen if our program has evolved and we decide it's good to have macros support? This is a bit more tricky than just one logging call. Okay, macroses are out of the way. Now we need permission checks – say, out app is shareware and has trial mode in which certain operations are locked out. Can you guess what we're gonna do? Right, go over a dozen of functions and add just one more line – perhaps with one more if.

In the end we're risking to sit before functions which have common calls each one made of just one line inserted here and there.

«Sure but what's the problem?» – you can ask me. Well, there's little problem if you code is well-structured so this function:

pascalfunction OpenArchive(FileName: String): Boolean;
begin
  MacrosOperation(@OpenArchive, [FileName]);
  if NotInTrial then
    try
      Log('open archive', [FileName]);
      Arc := TArchive.Create(FileName);
      Arc.Read;
      Result := True;
    except
      Log('open archive problem', [FileName]);
      Result := False;
    end;
end;

…won't look like the one I piled up in the example above. However, won't it be better if all irrelevant calls will be moved to their own modules? Even if each module will consist of two lines of code!

Isn't in, in fact, only natural that a routine isn't called if it doesn't need to run? Why do we insert checks for cache, trial version, permissions, macros points, etc. in functions that don't explicitly deal with them? Say, checking for a loaded module that it uses or proper parameter is fine but it must be tightly connected with the method's purpose.

The contrary

So enough talk, let's think what we can do about all this. First off, make all actions (as opposite to functions retrieving information) firing events instead of performing actual actions.
Then make each event a simple array of callbacks which are called which the event is fired.Add some simple stuff like terminating the loop when one of them returns True, add some context object that will be shared – if you need one (web PHP apps might not need one if they're simple enough and global context created upon page request can be shared). If necessary, add chaining so that actions can be called on the same context in batches.

And – make use of it! Any developer can come up with his own omniplugin system.
This all is really, really simple to code – both event framework and handles – butI believe it will make your code much more clear and managable, understandable by others and easy to update.

Let me know what you think!

Some practical examples

So how do one implement this? Below I placed examples of really basic pluggable system that can nevertheless make your code feel different. You can use any code below (as well as above) without any restrictions.

If you can contribute some more examples and/or in a language that isn't here yet – I'll be thankful if you do so in the comments :)

PHP

  1. Return true from handler to stop calling other handlers for this event.
  2. Handlers can be hooked too – «hooked $HANDLER» event will be fired after calling a handler. Use HookHandler() to add set a hook on a handler.
PHP
class Event {
  
// 'event' => array(name => callable, name => ...); return true to stop chain.
  // if callable is an array its 1-2 indexes are class/method while others - arguments to pass.
  
static $list = array();

  static function 
Fire($event$args = array()) {
    
$handlers = &self::$list[$event];

    if (
$handlers) {
      foreach (
$handlers as $name => $callback) {
        
$callArgs array_merge($argsis_array($callback) ? array_splice($callback2) : array());
        
$doBreak call_user_func_array($callback$callArgs);

        
self::Fire("hooked $name"$args);

        if (
$doBreak) { break; }
      }
    }
  }

  static function 
Hook($event$hdlrName$callback) {
    
self::$list[$event][$hdlrName] = $callback;
  }

  static function 
HookHandler($name$thisHdlrName$callback) {
    
self::$list["hooked $name"][$thisHdlrName] = $callback;
  }
}

Usage example (really simplified):

PHP
Event::Hook('add post''update profile''UpdateUserProfile');

function 
UpdateUserProfile($userID) {
  
mysql_query('UPDATE `users` SET `post_count` = `post_count` + 1');
  ...
}

...

Event::Fire('add post', array('author' => $_REQUEST['userID'], ...));

JavaScript

  1. Return true from handler to stop calling other handlers for this event.
  2. Use InsertHandlerBefore() to put new handler before already registered one; if it wasn't registered new handler will be put at the end.
function CallAll(handlers) {
    var args = ArgumentsToArray(arguments);
        args.shift();
  $each(handlers, function (handler) { return handler.apply(this, args); });
}

function InsertHandlerBefore(beforeHandler, handlers, newHandler) {
  var index = handlers.indexOf(beforeHandler);
      index = index == -1 ? handlers.length : index;
  handlers.splice(index, 0, newHandler);
}

/* Functions used by above functions: */

function $each(iterableObject, callback) {
  if (typeof iterableObject == 'object' && iterableObject) {
    for (var i = 0; i < iterableObject.length; i++) {
      if (callback.call(this, iterableObject[i], i)) {
        break;
      }
    }
  }
}

function ArgumentsToArray(argsObject) {
  return Array.prototype.slice.call(argsObject);
}

Usage example:

var onLoad = [];
...
function MyHandler() {
  document.body.className += 'loaded';
}
onLoad.push(MyHandler);
...
window.onload = function () { CallAll(window.onLoad); }

Delphi

Implementing this in Delphi requires a bit more effort than in interpretable languages but it nevertheless possible.
Below is a basic implementation of a «omniplugin»-style system.

  1. Return true from handler to stop calling other handlers for this event.
  2. Any event handler can be assigned an extra argument to pass on call (generic Pointer).
pascalunit Events;

interface

type
  TEventHandler = function (Arg, Extra: Pointer): Boolean;
  TEventItem = record
    Func: TEventHandler;
    Arg: Pointer;
  end;
  TEvents = array of TEventItem;

procedure FireEvent(Events: TEvents; EventArg: Pointer);

implementation

procedure FireEvent(Events: TEvents; EventArg: Pointer);
var
  I: Integer;
begin
  for I := 0 to Length(Events) - 1 do
    if Events[I].Func(EventArg, Events[I].Arg) then
      Break;
end;

end.

Usage example – requires more type declarations as well:

pascaltype
  POnOpenArcArg = ^TOnOpenArcArg;
  TOnOpenArcArg = record
    FileName: WideString;
  end;

  PRegInfo = ^TRegInfo;
  TRegInfo = record
    IsRegistered: Boolean;
    Name, Serial: WideString;
  end;

...


function CheckTrial(Arc: POnOpenArcArg; RegInfo: PRegInfo): Boolean;
begin
  // return *True* to stop event handlers.
  Result := not RegInfo.IsRegistered;
  if Result then
    MessageBox(0, 'Trial version of this software doesn''t have this feature.', 'Unregistered', mb_IconStop);
end;

function OpenArc(Arc: POnOpenArcArg; Unused: Pointer): Boolean;
begin
  MessageBoxW(0, PWideChar('Successfully opened ' + Arc.FileName), 'Archive opened', mb_IconInformation);
end;

...

// setting up handlers:
var
  RegInfo: TRegInfo;
begin
  SetLength(OnOpenArc, 2);

  OnOpenArc[0].Func := @CheckTrial;
  RegInfo.IsRegistered := False;
  // Either alloc mem for it or declare in global scope as
  // the pointer will vanish once the function is done executing.
  OnOpenArc[0].Arg := @RegInfo;

  OnOpenArc[1].Func := @OpenArc;
  OnOpenArc[1].Arg := NIL;
end;

...

// calling:
var
  EventArg: TOnOpenArcArg;
begin
  EventArg.FileName := 'data1.arc';
  FireEvent(OnOpenArc, @EventArg);
end;

Of course, setting all handlers for OnOpenArc at once isn't natural – in real app you'd gradually do pascalSetLength(OnOpenArc, Length(OnOpenArc) + 1); and such (probably creating a helper func like pascalAddHandlerTo(var Events, ...)) – but if was traded for example simplicity.

You can download a Delphi 7 demo (just the code above wrapped in a project) here.