[AJAX] Applicatie onder controle houden

Pagina: 1
Acties:

  • Michali
  • Registratie: Juli 2002
  • Laatst online: 22-03 18:12
Zoals velen ben ik ook druk bezig met de grenzen van AJAX en JS te verkennen. Door de mogelijkheid van het doen van requests direct vanuit JS, zonder dat de pagina herladen wordt, is het mogelijk om veel meer verantwoordelijkheid naar JS (de client side) te schuiven. Voor veel webapplicaties die ik hiervoor maakte was vooral het model op de server zelf hetgeen wat centraal stond. JS had alleen de taak om wat zaken gebruiksvriendelijker te maken en te ondersteunen. Nu het geheel echt continue in het geheugen blijft, alle objecten die je aanmaakt en tijdens verschillende requests gebruikt, maakt dat dat het model ook in JS een veel grotere taak krijgt. Dat heeft veel voordelen, het geeft je veel kracht om een goede userinterface te maken en het ook erg gebruiksvriendelijk voor de gebruiker te maken. Ook heeft het nadelen, het maken zaken erg complex en moeilijk onderhoudbaar als je het niet onder controle houd.

Juist daar over wil ik het hier hebben, hoe je een redelijke AJAX applicatie goed onder controle houd. Ik probeer zelf de principes die ik geleerd heb, door veel te lezen en door ervaring, goed toe te passen. Ik maak daarbij dan redelijk veel gebruik van design en andere patterns. Ik wil daarom even een boekje open doen over hoe ik het oplos en hoop daar dan reacties op te krijgen om zaken te verbeteren. (En hopelijk andere mensen te inspireren en zelf inspiratie op te doen.)

Wat ik merk, door het toepassen van die principes, dat ze ook zeer goed in Javascript toe te passen zijn. Af en toe gaat het wat moeizaam, maar er zijn weinig beperkingen. Enkele patterns die ik succesvol toepas zijn Observer, Strategy, Command, (Abstract) Factory, Registry en Domain Model.

Ik maak in JS een volledig afgekoppeld (nergens van afhankelijk) domein model, de abstracties waarop de applicatie is gebaseerd. Voorbeeldjes zijn een Directory en File class. Van deze classes maak ik dan bijna altijd een Observable; dat doe ik door via een hulp object ObserverContainer, welke simpele functionaliteit geeft voor het registreren van observers en het sturen van een notificatie naar alle geregistreerde. Dat is een zo veel voorkomend iets, dat ik daar een hulp objectje voor heb gemaakt. Nog een mogelijkheid is om de class te laten inheriten van een base class die dat soort functionaliteit al levert, echter is dat niet altijd gewenst of mogelijk.

Voorbeeld code (niet compleet):

JavaScript:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
function Directory(id, name)
{
    this.id  = id;
    this.name = name;
    this.observers = new ObserverContainer();
}

Directory.prototype.getID = function()
{
    return this.id;
}

Directory.prototype.getName = function()
{
    return this.name;
}

Directory.prototype.setName = function(name)
{
    this.name = name;
    this.observers.notify();
}

Directory.prototype.register = function(observer)
{
    this.observers.register(observer);
}


Gebaseerd op het domein komt dan de presentatie. Die zijn vaak direct gerelateerd aan een domein class; DirectoryPresentation en FilePresentation bijvoorbeeld. Deze krijgen bij het instantieren het object mee als parameter waarvan ze de presentatie moeten voorstellen. Ze registreren zichzelf dan bij het domein object, en moeten een functie notify implementeren, zodat ze een notificatie kunnen ontvangen als een domein object wijzigd. Een soort impliciete interface zeg maar.

Ik scheid dat netjes, zoals je dat ook in een normale applicatie zou doen. Dat heeft veel voordelen, omdat je zo meerdere presentaties op hetzelfde object kunt baseren. Je kunt zelf in dialoog vensters gegevens bewerken en het dan in de andere pagina gelijk geupdate zien worden.

In een server side applicatie heb ik het Observer pattern eigenlijk nog nooit gebruikt. Dat omdat je alle gegevens die je nodig hebt ophaalt en die in een view stopt, zonder dat die gegevens door een andere oorzaak gewijzigd worden. In dit geval is dat anders, omdat de objecten langer in geheugen blijven en ook op verschillende plaatsen gebruikt kunnen worden. Alle presentatie code direct koppelen met het object zou het werken ermee niet makkelijk maken.

JavaScript:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
function DirectoryPresentation(directory)
{
    directory.register(this);
    this.directory = directory;
    
    this.nameElement = document.createElement('SPAN');
    document.body.appendChild(this.nameElement);
    
    this.syncWithDirectory();
}

DirectoryPresentation.prototype.notify = function()
{
    this.syncWithDirectory();
}

DirectoryPresentation.prototype.syncWithDirectory = function()
{
    while ( this.nameElement.hasChildNodes() ) this.nameElement.removeChild(this.nameElement.firstChild);
    this.nameElement.appendChild(document.createTextNode(this.directory.getName()));
}


Ik werk zelf veel met context en gewone menu's. Vanuit daar kunnen allerlei handelingen worden verricht. Om die acties allemaal gemakkelijk controleerbaar te krijgen pas ik het Command pattern toe. Iedere actie krijgt een eigen class die uitvoerbaar is. Het werkelijk uitvoeren ervan laat ik aan nog een ander object over. Een soort van command performer. Dat doe ik om daar nog meer controle over te krijgen.

In Firefox bijvoorbeeld, zit een bug die er voor zorgt dat een exception wordt gegooid als je een XMLHttpRequest probeert uit te voeren in het onclick event van een puur met JS gecreëerd element. Dat los ik nu op door in de command performer de uit te voeren command met 1 ms te vertragen via een timeout. Dan gebeurt dat niet. Dat soort dingen wil ik niet in de client code bakken, en toekomstige wijzigingen wil ik dan op 1 plek houden.

Die commands worden vaak uitgevoerd aan de hand van een XMLHttpRequest. Echter zijn de acties die uitvoerd moeten worden aan de hand van bepaalde response data of code niet altijd direct relevant voor de actie zelf. Om de afhandeling los te koppelen van de command gebruik een Strategy object. Daarvoor heb je dan weer een impliciete interface: enkele methods die aanwezig moeten zijn. Zo'n object noem ik meestal een handler, omdat hij de werkelijke acties afhandeld.

JavaScript:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
function LoginCommand(win, username, password, handler)
{
    this.win = win;
    this.username = username;
    this.password = password;
    this.handler = handler;
}

LoginCommand.prototype.perform = function()
{
    var command = this;
    var request = new POSTRequest();
    request.setTarget(LOGIN_TARGET);
    request.addParam('username', this.username);
    request.addParam('password', this.password);
    request.setStateChangeFunction(function()
    {
        if ( !request.isReady() ) return;
        switch ( request.getResponseText() )
        {
            case RESPONSE_CODE_SUCCESS:
                command.handler.success();  
                break;
            case RESPONSE_CODE_FAILED:
                command.handler.failed();
                break;
            default:
                command.win.alert(UNKNOWN_ERROR_MESSAGE);
        }
    });
    request.send();
}

Stukje client code:
JavaScript:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
function LoginHandler() { }

LoginHandler.prototype.success = function()
{
    buildUI(); // niet relevant voor LoginCommand
}

LoginHandler.prototype.failed = function()
{
    alert(LOGIN_FAILED_MESSAGE);
    showLoginDialog(); // ook niet relevant
}

var command = new LoginCommand(window, eenUsername, eenPassword, new LoginHandler());
commandPerformer.perform(command);

In de perform functie van LoginCommand maak ik gebruik van een leuke scope trick. De functie gegeven aan setStateChangeFunction heeft natuurlijk niet direct de beschikking tot alle gegeven van de LoginCommand instantie. Om dat toch voor elkaar te krijgen ken ik this aan de command variable toe. Dat is een lokale variabel (er staat namelijk var voor). Toch heeft de gecreërde daar wel toegang tot, omdat ze in dezelfde scope zijn gedefinieerd. Zo hoef ik geen globale variabel te gebruiken, dit werkt gewoon.

Ook maak ik veel gebruik van constanten (zoals LOGIN_FAILED_MESSAGE). Die staan allemaal in 1 centraal config.js bestandje, zodat ik alles gemakkelijk kan configureren en bepaalde waardes en magic numbers ook een duidelijke naam krijgen (en dus de intentie tonen).

Je zou kunnen beargumenteren dat je een command niet altijd via XMLHttpRequest gaat doen. Om alles te kunnen testen, zonder dat alle requests ook daadwerkelijk naar de server gaan zou je alle commands kunnen uitwisselen met Mocks. Zo kun je dus heel gemakkelijk testen of alles werkt, zonder dat je de server daar direct voor nodig hebt.

Hoe doe je dat? Middels een Abstract Factory. Ik zorg er voor dat een speciaal factory object alle command objecten aanmaakt. In plaats van dan direct command objecten aan te maken doe ik dat via een functie van het factory object. Hoe kom je dan aan de factory? Daarvoor maak ik weer gebruik van een Registry. Dat is een globaal toegankelijk object waar bij andere objecten geregistreerd en door anderen weer opgehaald kunnen worden. Bij het initialiseren van de app (de main()) bepaal ik dan van welke class een instantie wordt gemaakt en dus als factory gebruikt wordt. Dan krijg je dus iets als dit:

De factory:
JavaScript:
1
2
3
4
5
6
7
8
9
10
11
12
13
/*
interface CommandFactory
{
    function newLoginCommand(win, username, password, handler);
}
*/

function ServerCommandFactory() { } /* implements CommandFactory */

ServerCommandFactory.prototype.newLoginCommand = function(win, username, password, handler)
{
    return new LoginCommand(win, username, password, handler);
}


De registry:
JavaScript:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function Registry()
{
    this.commandFactory;
}

Registry.prototype.setCommandFactory = function(factory)
{
    this.commandFactory = factory;
}

Registry.prototype.getCommandFactory = function()
{
    return this.commandFactory;
}

var registry = new Registry();


De main:
JavaScript:
1
2
3
4
function main()
{
    registry.setCommandFactory(new ServerCommandFactory());
}


De client:
JavaScript:
1
var command = registry.getCommandFactory().newLoginCommand(window, eenUsername, eenPassword, new LoginHandler());


Dat spreekt wel redelijk voor zich denk ik.

De domein objecten worden vrijwel altijd aangemaakt op basis van verkregen XML data. Het is niet onwaarschijnlijk dat die objecten door meerdere commands (en vanuit andere plaatsen) moeten worden opgebouwd op basis van die XML gegevens. Het zou natuurlijk zonde (en veel of copy-paste werk) zijn om op iedere plek weer de gegevens er uit te trekken en het object te instantieren. Zelf heb ik dat verplaatst naar een aparte Factory: een XMLDirectoryFactory bijvoorbeeld. Die registreer ik ook bij de registry zodat ze globaal toegankelijk zijn.

Om het ophalen van gegevens/objecten los te koppelen van het object dat het nodig heeft, maak ik ook een DataSource class. Ook deze wordt bij de registry geregistreerd. Een DataSource kan bijvoorbeeld functies als getAllDirectories en getFilesForDirectory hebben. Een bepaalde implementatie zou dan de benodigde XML gegevens van de server kunnen vragen en op basis daarvan (ism. met de XML factories) worden dan de objecten gemaakt. Een probleem daarbij is dat de request asynchroon is en dus niets kan returnen. Vaak maak ik een functie aan op het vragende object waarmee de data door de datasource toegevoegt kan worden. Een directory zou bijvoorbeeld een method setFiles(files) hebben en de datasource op de volgende manier kunnen aanroepen:

JavaScript:
1
dataSource.getFilesForDirectory(this);


De datasource zou dan bijvoorbeeld dit kunnen doen:
JavaScript:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
DataSource.prototype.getFilesForDirectory = function(directory)
{
    var request = new GETRequest();
    request.setTarget(GET_FILES_TARGET);
    request.addParam('directory', directory.getID());
    request.setStateChangeFunction(function()
    {
        if ( !request.isReady() ) return;
        var rootElement = request.getRootElement();
        if ( !rootElement )
        {
            alert(FILES_RETRIEVAL_ERROR_MESSAGE);
            return;
        }
        var files = new Array();
        for ( var i = 0; i < rootElement.childNodes.length; i++ )
        {
            var element = rootElement.childNodes[i];
            if ( !( element.tagName && element.tagName == 'file' ) ) continue;
            files.push(registry.getXMLFileFactory().createFile(element, directory));
        }
        directory.setFiles(files);
    });
    request.send();
}


Natuurlijk kan de datasource zelf ook uitgewisseld worden met een stub, puur om te testen. Werkt erg makkelijk.

Ik ben ook erg benieuwd naar hoe andere mensen werken. Commentaar op dingen die ik moeilijk of vreemd oplos zijn natuurlijk welkom. Ik vind ik het vooral leuk en leerzaam om een stukje te schrijven over wat ik geleerd heb, dus als niemand er wat op te zeggen heeft vind ik het ook best ;)

[ Voor 3% gewijzigd door Michali op 29-01-2006 22:02 ]

Noushka's Magnificent Dream | Unity