09.092015

Callback-Hölle in Javascript vermeiden

Mit NodeJS haben nun nicht mehr nur Frotend-Entwickler mit dem  Callback-Klammer-Chaos tief-verschachtelter Funktionsaufrufe in Javascript zu kämpfen. Heute schauen wir uns Möglichkeiten an strukturierten und lesbaren Javascript Code (insbesondere in NodeJS)  zu schreiben.

Damit wir auch etwas Anschauungsmaterial haben nehmen wir an, wir haben eine NodeJS-Anwendung geschrieben, die auf Anforderung überprüft, ob ein Update verfügbar ist. Wurde ein Update gefunden, so wird versucht dieses herunterzuladen und zu installieren. Da entsprechende Funktionalitäten aufwendig sind, begnügen wir uns mit einigen Mockup-Funktionen:

var semver = require('semver');

function logError(err) {
  console.error(err);
  console.error(err.stack.split("\n"));
  return false;
}

function getRemoteVersion(cb) {
  // Hier könnte beispielsweise "request"-Magie passieren
  setTimeout(cb.bind(null, null, "1.0.3"), 1000);
}

function getLocalVersion(cb) {
  // Hier könnten Dateien geparst oder der lokal laufende Dienst gefragt werden
  setTimeout(cb.bind(null, null, "1.0.1"), 100);
}

function isNewer(localV, remoteV) {
  return semver.lt(localV, remoteV);
}

function isUpdatable(localV, remoteV) {
  var localClean = semver.clean(localV);
  return semver.satisfies(remoteV, '^' + localClean);
}

function downloadNextVersion(version, cb) {
  // Noch mehr "request"-Magie
 console.info('Downloading version', version);
 setTimeout(cb, 3000);
}

function installNextVersion(version, cb) {
  // Hier könnten wir den lokalen Dienst beenden,
  // Dateien kopieren und den Dienst wieder starten
  console.info('Installing version', version);
  setTimeout(cb, 2000);
}

Wir wollen nun den folgenden Ablauf nachstellen:

  1. Wenn ein Fehler in den folgenden Schritten passiert, brich das Update ab
  2. Frage die lokale Versionsnummer ab
  3. Frage die momentan aktuellste Versionsnummer ab
  4. Wenn die lokale Version gleich oder neuer der aktuellsten ist, beende das Update
  5. Wenn die lokale Version älter ist, aber ein automatisches Update nicht möglich ist, beende das Update
  6. Wenn die Version aktualisiert werden kann lade das Update runter
  7. Wenn das Update runtergeladen wurde installiere es

Eine naive Umsetzung mit den oben genannten Funktionen könnte etwa so aussehen:

function callbackHell() {
  getLocalVersion(function(err, localV) {
    if (err) {
      return logError(err);
    }
    console.info('Local version:', localV);
    getRemoteVersion(function(err, remoteV) {
      if (err) {
        return logError(err);
      }
      console.info('Remote version:', remoteV);
      if (isUpdatable(localV, remoteV)) {
        downloadNextVersion(remoteV, function(err) {
          if (err) {
            return logError(err);
          }
          installNextVersion(remoteV, function(err) {
            if (err) {
              return logError(err);
            }
            console.info('Successfully Updated!');
          });
        });
      } else {
        if (isNewer(localV, remoteV)) {
          console.warn('A newer version is available. Please install it manually');
        } else {
          console.info('Local version is up to date');
        }
      }
    });
  });
}

callbackHell();

Ganz schön chaotisch oder? Hier sollte jeder, die Seite noch nicht kennt einen kurzen Abstecher auf  http://callbackhell.com/ unternehmen. Wir folgen nun dem Tipp Funktionen zu benennen und tiefe Verschachtelung zu vermeiden. So könnte beispielsweise die nächste Fassung weniger wie eine Pyramide aussehen:

function aBitBetter() {
  var localV;
  var remoteV;

  getLocalVersion(_localVersionHandler);

  function _localVersionHandler(err, local) {
    if (err) {
      return logError(err);
    }
    console.info('Local version:', local);
    localV = local;
    getRemoteVersion(_remoteVersionHandler);
  }

  function _remoteVersionHandler(err, remote) {
    if (err) {
      return logError(err);
    }
    console.info('Remote version:', remote);
    remoteV = remote;

    if (isUpdatable(localV, remoteV)) {
      downloadNextVersion(remoteV, _downloadCompleteHandler);
    } else {
      if (isNewer(localV, remoteV)) {
        console.warn('A newer version is available. Please install it manually');
      } else {
        console.info('Local version is up to date');
      }
    }
  }

  function _downloadCompleteHandler(err) {
    if (err) {
      return logError(err);
    }
    installNextVersion(remoteV, _installCompleteHandler);
  }

  function _installCompleteHandler(err) {
    if (err) {
      return logError(err);
    }
    console.info('Successfully updated!');
  }
}

aBitBetter();

Nun erhalten wir in Stacktraces keine anonymen-Funktionen und der Quellcode wandert weniger zum rechten Bildschirmrand ab. Dennoch würde ich nicht sagen, dass er an Lesbarkeit gewonnen hat. Wir können jetzt beginnen den Code in kleine Module wegzukapseln, dennoch werden bestimmte Probleme nicht grundlegend gelöst:

  • Die Fehlerbehandlung ist nicht Zentral
  • Fehler in den Callback-Handlern werden nicht gefangen
  • Die Versionsnummern werden nicht parallel geladen

Hier möchte ich nun kurz bluebird vorstellen. Eine Library, die Promise-Objekte für den Browser und NodeJS zu Verfügung stellt. Hier bei ist ein Promise ein Versprechen auf einen Ergebnis, dass in Zukunft bereitstehen wird. Mehr hier zu findet ihr auf der Entwicklerseite. Der Code könnte dann folgendermaßen gelöst werden:

var Promise = require('bluebird');

// Erweiterte Stracktraces aktivieren
Promise.longStackTraces();

// Unsere bestehenden Funktionen erweitern, so dass diese Promises zurückgeben
var getLocalVersionP = Promise.promisify(getLocalVersion);
var getRemoteVersionP = Promise.promisify(getRemoteVersion);
var downloadNextVersionP = Promise.promisify(downloadNextVersion);
var installNextVersionP = Promise.promisify(installNextVersion);

function best() {
  Promise.join(getLocalVersionP().tap(function _localVersionHandler(localV) {
    console.info('Local version:', localV);
  }), getRemoteVersionP().tap(function _remoteVersionHandler(remoteV) {
    console.info('Remote version:', remoteV);
  }), function(localV, remoteV) {
    if (isUpdatable(localV, remoteV)) {
      return downloadNextVersionP(remoteV)
      .then(function _downloadCompleteHandler() {
        return installNextVersionP(remoteV);
      })
      .then(function _installCompleteHandler() {
        console.info('Successfully updated!');
      });
    } else {
      if (isNewer(localV, remoteV)) {
        console.warn('A newer version is available. Please install it manually');
      } else {
        console.info('Local version is up to date');
      }
    }
  }).catch(function(err) {
    logError(err);
  });
}

best();

Um die Vorteile hervorzuheben bauen wir einen Fehler in das Programm ein:

function getRemoteVersion(cb) {
  // Ungültiger SemVer-String
  setTimeout(cb.bind(null, null, "zlgo"), 1000);
}

Führen wir nun aBitBetter(); aus erhalten wir

Local version: 1.0.1
Remote version: zlgo

TypeError: Invalid Version: zlgo
    at new SemVer ([...]/node_modules/semver/semver.js:293:11)
    at Range.test ([...]/node_modules/semver/semver.js:1047:15)
    at Function.satisfies ([...]/node_modules/semver/semver.js:1096:16)
    at isUpdatable ([...]/index.js:30:17)
    at _remoteVersionHandler ([...]/index.js:105:9)
    at Timer.listOnTimeout [as ontimeout] (timers.js:121:15)

Im Fall der Promise-Implementierung best(); erhalten wir:

Local version: 1.0.1
Remote version: zlgo
[TypeError: Invalid Version: zlgo]
[ 'TypeError: Invalid Version: zlgo',
  '    at new SemVer ([...]/node_modules/semver/semver.js:293:11)',
  '    at Range.test ([...]/node_modules/semver/semver.js:1047:15)',
  '    at Function.satisfies ([...]/node_modules/semver/semver.js:1096:16)',
  '    at isUpdatable ([...]/index.js:30:17)',
  '    at [...]/index.js:144:9',
  '    at process._tickCallback (node.js:448:13)',
  'From previous event:',
  '    at best ([...]/index.js:139:11)',
  '    at Object.<anonymous> ([...]/index.js:164:1)',
  '    at Module._compile (module.js:456:26)',
  '    at Object.Module._extensions..js (module.js:474:10)',
  '    at Module.load (module.js:356:32)',
  '    at Function.Module._load (module.js:312:12)',
  '    at Function.Module.runMain (module.js:497:10)',
  '    at startup (node.js:119:16)',
  '    at node.js:935:3' ]

Ein kurzer Vergleich zeigt, dass nur im zweiten Fall unsere Fehlerbehandlung logError() greift. Desweiteren haben wir das parallele Laden der Versionsnummern gelöst (hier nicht zu sehen) ! Besonders schick ist auch, dass wir die API-Funktionen nicht manuell ändern müssen, da bluebird für NodeJS bereits eine Funktion bereitstellt, die genau das für uns automatisch tut.

Ich hoffe ihr habt nun Lust bekommen euch weiter in die Materie einzulesen. Für Interessierte gibt es hier noch einige Argumente für Promises.

Viel Spaß beim Ausprobieren!

 

D̶̠̭̼͕͇̭͚̱̜̳͚̱͉̦͘͟͝o̷̧̖͔̭͈̟̻̘̲̮͎͉͘ ̶͙̫̺̭̞̖̪͍̮͈̘̕n̶͚̤̗͎͕͓̖̝̗͚͔̠͎̥̪͎ͅo̧҉̵̸̶̫̱͙̺̺̻̳͖̼͈̞͉t̡̗̞͔̮͖̦̺̲̖͟͡ ̨͎̬͉͇̱̲̟̰̱̖̯͙͠ͅṷ̸̮̖͙͕̟͎̱̰̳̼̩̩̰̫̼̦̩̯͜n̷͜͠҉̫͎̯͈̳l̶̛̗͙̦̝̰̪̣͖̩͉̳̘̲͞͝e̡͏̨͝͏̬̪̝̺̯̖̲̦̫a̷̡̤̹̬͉̭̠̜̪̩͕̫̦͔̳̣ͅͅs̶̡̢̬͈͖̼̻̖̝͝͞ḩ̷̷̺͕͖̻͙͕̮̀ ͢͏̶̸̛̺͉͙̹Ź̶̵̰͕̥̙̯͇͓̼͘͟à̵̹̭̱͉͇̣̠̭̫ļ̡̡̰̼̖͇̱͔̘͚̭̝͔̫̪͚͜g̷̷͚̺̣̙͉͉͎̥̙ͅͅo͏̧̛̤̦͇̫͉̱̰̮̝͡ ̛̼̦͓̩̖͓͈̗̭͉̟̬̠͠i̧̥̳̬̖̘̗͘̕͟n̵̛̩͔̘͕̝̥͖͟͡͝ ̸̶̬̲͕̗̖̪̣͓̝̘͟͡y̵̵̨͎̗͙̭ͅó̢͏̨̣̰̝̣̣͇̜̙̱̞̲͈̰̪͔̞̺͟ͅu̡̧̪̜̮̣̙̼̟̘̫̝̱̼̲͎̱r̴̢͇̩̤̗̖̣͞ ͏̰̙̟̣̥̘̹̫͓̰̼͈̺͔̥̗͜͡a̷̧̟̮͙̦̤͙̙͙͔̼̝̬͙͚̺̩͡ͅs̷͏̶̙̗̮̻y҉̖̭̯͓͖̺̲͞n̮̲̮͙̭͘͘͜ͅç̷͖̰͉̩͜h̦͍̖͓̝̳̟͎͙̮̫̞̲̙̠͘͢r̵̨͇̱̜̤̬̤̯͍̫̻͇o̷͇̹̩̤̠͓̣̼̘͍͢͢n̶̛̛̛҉͇͙͎͙o̡҉̶̗͖͈̩̺͙̬̣̝͇̘̼̭̥̭̦̦͉u͏̸͍͖͈̥̣͓͟ͅş̧́҉̲̦̙̩̯͚͠ ̴̴̷͔̜̻̟̥̯͔͖̲̣Á̶̢̢͔̳̠͎̺̘̟ͅP̮̭̝͖͈̗̹͚̘̖͍̪̗̩͎͔͎̩̠̀͠I͏̧͢҉͔͇̳͉̞͎̹̙̭̹̲̻ͅͅś̸̷͓̺̘̹̫̮̭͍̥̫͙̖͙͔͘ͅ ̷̨̨̗̻̠̭̻̠̳͓̯̺͍̟͔̰̱̼͇͘͝ͅo͏̸̢̱͍̭͍͈̗̳͇̭͚̤͙̙̥́͟r͡͏̷̘̹̪̺̤̟̞͇̟̪̜͖̲̪͍͟ͅ ̧͙͕̤̥̭̝̖̘͉̠̼̖͔̮̖͔͇̕͝͠H̀͜͞͏̦̜̤̻͓͇̰͔̜̬̲̩͓̫͓͖È͘͏͇̳̰͓̺̪͈̣̗̘͕̺̭̯̳̗̬͕̀ͅ ̧̛͉͇͕̝̙͓̞̯̝̼̪̗͖̗̗̘̩͙W̬̩̹̰̟̪̣͙͈̜̯̬̥̬̻̭̦̯͜I̡҉̷͕̩͙̬̟̯̯̞̻͙̫̤̻̻L͡͝͏̹͙̳͓͔̲Ĺ̴̶͎̮̝͚̯͕͍͉͎̜̖̞̯̜̮́͞ͅ ҉̵͏͖̱̜͈̜͔̰̗͎͕͍̳̜͉̳͈̲͈͎d̶̸̴͈͓̳̥̯̥̪̹̰̖͚̬̩̣̠͕ͅͅÉ̶̢̮͓̲͔͎̝̯̖̠̬̰̹͇͉̱̱̮V̸̷͕͚̮͇͍̞̬͇̙̲̬͖̗̲̥͚̥̺͟͡O̸̖͖͜͞͠͞ͅͅu̧̧̧̼̼̪̦͝r̵̩̩̼͘͜͝͞ ̶̢͟҉̤̜̗̬͈̻͔̀ͅY̢̙̩̲͕̮̫̰̪͍̦Ǫ̶̛͈̳̫̯̗̯͍̩̪͢͜Ư̸̧̝͎̳̮̭͎R̶̴̳͍̮͓̙͕̳̫̩̫͙̟̼͍̘̹̠͜ ҉̷҉͖̺̭̟̥̜͖̲̼͇͈͇̰̠͉̺͇͝͞s̖͇̮͖̤̺̬͇̘̣̘̭̠̫͎͞͞ớ̢͈̺̯̻̻̲͎̝͓̝̲͔̮͘U͜͞͏̤͎̞̣Ḻ̴̤͖͇̫̝̼̥̪͙́̕͞!̸҉̷͏͎͚̝̝͈̭͙͎̬͎̕