3 former av asynkron bearbetning i JavaScript, del 2
Den här artikeln tar vid direkt efter där förra veckans artikel slutade. Det sista vi tog upp förra veckan var begreppet call-back hell, som innebär att varje fortsättning i programexekveringen efter någon form av väntetid, utförs av en nästlad funktion. Även för ett tämligen enkelt program blir det svårt att både förstå och bygga vidare på ett dylikt program med många nästlade funktionsanrop.
För vanlig webbprogrammering var detta oftast inte ett stort problem, eftersom lyssnarfunktioner till olika typer av händelser, såsom mus- och tangentklick behövde vara korta hursomhelst. Det här kom emellertid att bli ett med tiden större problem för program i Node.js, eftersom dessa utförda långa sekvenser av operationer, vilka involverade väntetider. Som t.ex. programmet som kopierar en fil till en annan, jag visade i förra artikeln.
Under årens lopp har man i Node.js provat olika tekniker för att transformera nästlade funktionsanrop till kedjor/listor av funktioner att anropa. Ingen av dessa renderade någon större popularitet, utan call-back hell kom att segla upp som ett mörkt moln och kunde ha bromsat framgången för server-side JavaScript.
Begreppet Promise
Begreppet promise kom att bli det första steget i utvecklingen för att göra asynkron programmering enklare att programmera och förstå. I JavaScript världen dök det först upp som ett snarlikt begrepp i form av deferred objects in ramverket jQuery.
När Angular.js tog världen med storm under den första halvan av 2010-talet hanterades alla asynkrona operationer med promises, baserad på biblioteket Q av Kris Kowal. Till exempel kunde ett HTTP anrop i Angular.js se ut så här
var url = 'https://somewhere/something/whatever';
var promise = $http.get(url);
promise.then(function(response) { ... }, function(error) { ... });
Så populariteten för den första versionen av Angular banade väg för förståelsen och uppskattningen av begreppet promises i JavaScript. Detta resulterade sedan i att begreppet blev en del av standard biblioteket för JS i och med ES2015.
Det är ju så här att JavaScript (JS) formellt heter ECMAScript (ES), som ett resultat av legal tvister mellan Microsoft och Netscape under andra halvan av 1990-talet. Tack och lov så har Microsoft helt lagt om stil och numera är en vital aktör inom JS världen och bidrar med kolossalt många ting.
Promise & Future
Promises är ingalunda något specifikt för JS utan finns för de flesta språk. Själva begreppet kom till under andra halvan av 1970-talet och benämndes på ett flertal olika sätt som; eventual, deferred, future och promise.
I Java (från Java 8) heter det Future (java.util.concurrent.Future<T>
) till exempel. Så här kan det se ut om man registrerar en asynkron uppgift.
var pool = Executors.newCachedThreadPool();
var future = pool.submit( () -> { ...do stuff... });
. . .do something else for a while. . .
var result = future.get();
Emellertid, heter det både promise och future i Modern C++ (från C++11). Så här kan det se ut
auto promise = std::promise<int>;
auto future = promise.get_future();
auto task = std::thread( [&promise](){ ...do stuff...promise.set_value(42); });
. . .do something else for a while. . .
auto result = future.get();
task.join();
I exemplen ovan tillämpar jag automatisk typ-inference, vilket är ett finare uttryck för att låta kompilatorn klura ut typen för en variabel. Språket C++ fick auto
redan 2011 med C++11, medan man i Java fick vänta på användning av var
tills 2018 och version 10 av språket.
När det gäller auto
i C++, så är det ett av de äldsta reserverade orden i språket och daterar sig tillbaka till språket B från mitten av 1960-talet. Språket B var förlagan till språket C (lite grann som; B with type support), vilket med tiden evolverade vidare till C++ (lite grann som; C with datatype design support)
Hur används en Promise?
Av programfragmenten ovan kan man se att en promise, utgör ett löfte om ett framtida värde. Ett promise objekt i JavaScript, kan antingen sakna värde (unbounded) eller vara bundet till ett värde (resolved). Om operationen, som promise objektet är involverat med, misslyckas så blir objektet bundet till ett felvärde i stället (rejected).
Här nedan visar jag ett enkelt exempel, där en funktion returnerar ett promise objekt, som efter en stund antingen lyckas (mindre än 50) eller misslyckas (större än 50), beroende på en slumptals dragning. Som du kan se så tar konstruktorn till klassen Promise en call-back. I denna call-back sätts ett fördröjt anrop till en annan call-back, vilken gör en slumptals dragning och sen, beroende på utfall, anropar en av de två call-backs som skickades med som parametrar till Promise konstruktorn.
function delayedValue() {
return new Promise((onSuccess, onFailure) => {
setTimeout(() => {
const value = Math.floor(100 * Math.random());
if (value < 50) {
onSuccess(value);
} else {
onFailure(value);
}
}, 1000);
});
}
console.time('Elapsed');
console.log('Before invocation')
const promise = delayedValue();
promise
.then(ok => {
console.log('Success: %d', ok);
})
.catch(err => {
console.log('Failure: %d', err);
})
.finally(() => {
console.timeEnd('Elapsed');
});
console.log('After invocation');
Det returnerade Promise objektet anropas sedan med tre kedjade funktioner; then()
, catch()
respektive finally()
. Den här tekniken med kedjade funktioner kallas kaskad-anrop och är vanligt förekommande i programspråk som har objekt. Tekniskt sett, så returneras objektet från respektive funktion, ungefär som exemplet nedan visar.
const p = funcThatReturnPromise();
const t = p.then( onSuccessFn );
const c = t.catch( onFailureFn );
c.finally( cleanupFn );
Ska man vara riktigt petig, så kan man skicka in båda call-back funktionerna till then()
och strunta i catch()
, men det är det ingen som gör nu för tiden, eftersom det näppeligen ökar läsbarheten. Så här kan två exekveringar se ut, med respektive utfall:
$ node delayed-value.js
Before invocation
After invocation
Success: 30
Elapsed: 1.013s
$ node delayed-value.js
Before invocation
After invocation
Failure: 74
Elapsed: 1.007s
Kopiera en fil med Promise objekt
Börja med att kika på förra veckans kod för funktionen copyfile()
, som kopierar innehållet i en textfil till en ny fil och gör om texten till versaler. Denna version använder sig av nästlade call-back funktioner och illustrerade också det som brukar kallas call-back hell.
I stället för nästlade call-back funktioner, så bygger man en kedja av anrop, där varje then()
gren returnerar en ny promise, vilken man sen kan anropa then() på, o.s.v. Här följer den nya versionen av copyfile()
.
import fs from 'node:fs';
import {promisify} from 'node:util'
import {fileURLToPath} from 'node:url';
import {basename, dirname, extname, join} from 'node:path';
function die(err) { console.error(err); process.exit(1); }
function copyfile(fromFile, toFile) {
const open = promisify(fs.open);
const fstat = promisify(fs.fstat);
const read = promisify(fs.read);
const write = promisify(fs.write);
const close = promisify(fs.close);
const state = {};
return open(fromFile, 'r')
.then(fd => {
state.fd = fd;
return fstat(fd);
})
.then(stats => {
const storage = Buffer.alloc(stats.size);
return read(state.fd, storage, 0, storage.length, null);
})
.then(response => {
console.log('Read %d bytes from %s',
response.bytesRead, basename(fromFile));
state.text = response.buffer.toString();
return close(state.fd);
})
.then(_ => {
return open(toFile, 'w');
})
.then(fd => {
state.fd = fd;
const payload = state.text.toString().toUpperCase();
return write(fd, payload);
})
.then(response => {
console.log('Wrote %d bytes to %s',
response.bytesWritten, basename(toFile));
return close( state.fd);
});
}
const scriptFilePath = process.argv[2] || fileURLToPath(import.meta.url);
const scriptFileName = basename(scriptFilePath, extname(scriptFilePath));
const outputFilePath = join(dirname(scriptFilePath), scriptFileName + '-copy.txt');
console.time('Elapsed');
copyfile(scriptFilePath, outputFilePath)
.then(_ => console.log('Done'))
.catch(err => console.error('*** %s', err))
.finally(() => console.timeEnd('Elapsed'));
I koden ovan använder jag en funktion i Node.js (util.promisfy
), som konverterar en funktion som tar en call-back till en funktion som returnerar en promise. Förenklat fungerar det på följande vis:
Input Function:
doit(args, (err, ok) => {
if (err) { console.error('crap', err); }
else { ...do stuff... }
})
Output Function:
doit_promise(args) {
return new Promise((onSuccess, onFailure) => {
doit(args, (ok, err) => {
if (err) onFailure(err);
else onSuccess(ok);
});
});
}
Så här kan det se det ut när man kör programmet:
$ node copyfile-promise.mjs
Read 1797 bytes from copyfile-promise.mjs
Wrote 1797 bytes to copyfile-promise-copy.txt
Done
Elapsed: 15.968ms
$ node copyfile-promise.mjs no-such-file.txt
*** [Error: ENOENT: no such file or directory, open 'P:\...\src\no-such-file.txt'] {
errno: -4058,
code: 'ENOENT',
syscall: 'open',
path: 'P:\\...\\src\\no-such-file.txt'
}
Elapsed: 17.231ms
Om man jämför förra veckans version av copyfile()
med denna veckas version, så kan man lättare följa programlogiken. Emellertid, är veckans kod längre med 32 rader jämfört med 24 rader. Dessutom kan man inte direkt kalla den elegant 🤔. Notera, hur jag använder ett JS objekt för att spara data (fd
, text
) till ett nedströms anrop av then()
.
I nästa veckas avsnitt, kommer jag fixa till detta med att gå igenom hur du använder await
på en promise-returnerade funktion, d.v.s. det som kallas async funktioner. Väl mött om en vecka.