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.
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?
How this corresponds to omniplugin concept?
$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.
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.
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.
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.
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 :)
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($args, is_array($callback) ? array_splice($callback, 2) : 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):
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'], ...));
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); }
var onLoad = []; ... function MyHandler() { document.body.className += 'loaded'; } onLoad.push(MyHandler); ... window.onload = function () { CallAll(window.onLoad); }
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.
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.