Streams in JavaScript: ein vielseitiges Werkzeug

Keine Kommentare

Streams in Node.js sind ein wichtiges und vielseitiges Werkzeug. Unix Pipes standen als Vorbild. Ursprünglich geschaffen um interne Module von Node.js, insbesondere die Parser, effizient zu machen, repräsentierten Streams vor allem Bytesequenzen d.h. Binärdaten oder Zeichenketten. Doch eigentlich sind sie seit jeher Sequenzen von Nachrichten. Und das macht sie so vielseitig.

Was ist ein Stream?

Ein Stream ist eine Abstraktion einer Sequenz von Daten, verteilt in der Zeit statt im Raum. Gewöhnlich kennen wir Sequenzen als Arrays und Listen, welche die Werte im Speicher abbilden. Erst laden wir die komplette Sequenz in den Speicher, um sie dann zu verarbeiten. Streams erlauben es, Daten Stück für Stück zu verarbeiten, das Ergebnis weiterzureichen und zu vergessen. So brauchen wir deutlich weniger Speicher, im Idealfall gerade genug um eine Datenportion zu halten.

Streams haben zusätzlich dazu weitere Eigenschaften. Sie können Push- oder Pull-basiert sein. Hierbei geht es um die Frage, wie der Konsument an Daten kommt. Was passiert, wenn die Daten zu schnell kommen? Oder zu langsam? Muss ich selbst was puffern?

Ein Stream kann lesbar, schreibbar oder beides sein. In Node.js nennen wir sie Readable, Writable oder Duplex. Eine weitere Art sind die Transform-Streams. Diese sind besonders, weil deren Ausgabe direkt von der Eingabe abhängt. Transforms sind entscheidend für höhere Aufgaben.

Streams sind vielseitig

Wie kann man nun Streams einsetzen? Ganz klassisch werden sie am häufigsten dazu genutzt, den Speicherverbrauch auch bei großen Datenmengen gering zu halten. Das typische Beispiel eines solchen Einsatzes ist das Verarbeiten einer Datei von der Festplatte:

var fileStream = fs.createReadStream(filename)
fileStream.on('readable', function(){
  var chunk
 
  while( (chunk = fileStream.read() ) != null )
    doSomething(chunk)
})
 
fileStream.on('end', function(){
  console.log('Filestream ended!')
})

Hier wird die angeforderte Datei mit Hilfe eines Readable Streams paketweise gelesen und verarbeitet. Gelesen wird hier nach dem Pull-Prinzip. Der fileStream unterrichtet den Nutzercode über das 'readable' Event, dass Daten zum Lesen verfügbar sind. Daraufhin werden die Daten gelesen, bis der Puffer leer ist. .read() gibt in diesem Fall null zurück. Erst mit dem nächsten Event kann wieder gelesen werden. Oft ist es aber einfacher, den produzierenden Stream direkt in einen konsumierenden zu .pipe()-en

fileStream.pipe(response)

Hier lässt sich bereits eine weitere Stärke der Streams erkennen: die Kombinierbarkeit. Weil alle Streams dem gleichen Protokoll folgen, lassen sie sich automatisch mit der .pipe() Methode zusammenstecken. Streams als Interface eines Moduls erlauben es, die geringste Kopplung zwischen Modulen zu erreichen, so genanntes Message Coupling.

Da Streams Sequenzen sind, lassen sie sich auf ähnliche Weise handhaben wie Arrays und Listen. Transform ist eine abstrakte, generische Map-Funktion, mit der sich auch Filter, Flattener, Observer und weitere Funktionen implementieren lassen.

var devocalize = new stream.Transform()
 
devocalize._transform = function( chunk, encoding, callback ) {
  var devocalize = chunk.replace( /(a|e|i|o|u)/gi , '')
  this.push(devocalize)
  callback()
}
// Devocalize -> Dvclz
request.pipe(devocalize).pipe(response)

Man kann nun den Stream oder eine Factory Funktion exportieren, und schon kann unser Modul mit anderen Modulen zusammenarbeiten, ohne Details über diese wissen zu müssen. Die einzige Voraussetzung ist, dass die Datenstruktur, die die Streams durchleiten, passt. Ich schreibe bewusst Struktur, denn Typen haben in JavaScript hier zu wenig Aussagekraft. Im oberen Beispiel reicht es, dass das Datenobjekt die .replace() Methode unterstützt, ansonsten ist es egal, ob es ein String ist, eine Liste, oder ein sonst wie geartetes Objekt.

Spinnen wir die Gedanken weiter: Transforms, die Ausgabe direkt aus Eingabe produzieren, möglichst ohne Seiteneffekte. Klingt wie „pure functions“, oder? Ketten von Transforms sind kombinierte Transforms höherer Ordnung. Das kennen wir doch irgendwo her!

Streams und Functional Programming

In funktionaler Programmierung kann man Software eben als Transformation von Eingabe zu Ausgabe verstehen. In Haskell:

Int -> Int -> Int

beschreibt eine Funktion, die zwei Ints übergeben bekommt, und daraus einen neuen Int erzeugt, die Pfeile deuten eine Transformation an. Mathematisch ausgedrückt ist es eine Abbildung einer Menge von Int-Tupeln auf eine Menge einzelner Ints. Abbilden — im Programmierjargon sagen wir „mappen“. Transform-Streams sind Map-Funktionen. Auch als Streams höherer Ordnung. Ihr seht die Parallelen?

Die Bibliothek Highland.js treibt es auf die Spitze mit diesem Ansatz. Lazy Evaluation, Partial Application, higher-order Streams etc. Diese Bibliothek eignet sich sehr dazu, die Aufgaben der höheren Abstraktionsebenen zu lösen: Architekturimplementierung, Integration und Abstraktion der äußeren Grenzen der Anwendung. Die Anwendung selbst wird zu einem Stream höherer Ordnung, der aus kleineren Streams kombiniert ist.

Aber halt! Bedeutet funktional nicht auch frei von Seiteneffekten? Richtig. Aber auch jede Software, die etwas Sinnvolles tut, hat Seiteneffekte. Die Welt ist ein einziger veränderbarer Zustand, zumindest in unserer Wahrnehmung. Wir brauchen Seiteneffekte, um darin Wirkung zu haben. Davon sind auch die funktionalen Sprachen nicht befreit. Also werden Zustandsänderungen so weit weggeschoben, wie es nur geht, und dann gekapselt. In Haskell wird der Entwickler angehalten, den Zustand strikt von der Logik zu trennen. Auch wir in JavaScript sind gut beraten dies zu tun. Streams sind uns hier ein Mittel der Wahl, wie das Devocalize Beispiel zeigt.

Und weil Stream es uns erlauben funktionales Gedankengut weitestgehend anzuwenden, können wir mit ihnen den Verarbeitungsfluss der Daten zwischen Ein- und Ausgabe modellieren und implementieren. Durch modulares Vorgehen können solche Implementationen geradezu deklarativ aussehen. Ein simpler Fileserver, bei dem die eingehenden Anfragen in zwei Zweigen verarbeitet werden, im ersten das Speichern der Datei (POST-Anfragen), im zweiten das Lesen (GET-Anfragen):

var hl = require('highland')
 
// server ist ein Stream von req-res Tupeln
var server = hl('request', httpServer, ['req', 'res'])
//dieser Zweig verarbeitet nur POST-Anfragen
server.fork().filter(function(reqRes){ return reqRes.req.method === 'POST'})
  .map(function(reqResPosts){
    hl(reqRes.req).pipe(fs.createWriteStream(toFilename(reqRes.req.url)))
    return reqRes.res
  }).each(function(res){
    res.writeHead(201)
    res.end()
  })
// dieser nur GET
server.fork().filter(function(reqRes){ return reqRes.req.method === 'GET'})
  .each(function(reqRes){
    fs.createReadStream(toFilename(reqRes.req.url)).pipe(reqRes.res)
  })

oder noch modularer:

var hl = require('highland')
 
var server = hl('request', httpServer, ['req', 'res'])
 
server.fork()
  .filter(onlyPOST)
  .map(writeToFile)
  .each(respondOK)
 
server.fork()
  .filter(onlyGET)
  .each(respondWithFile)

Das Wichtigste an diesem Beispiel ist, dass sich die Applikation als eine Reihe funktionaler Transformationen abbilden lässt.

Streams und Tooling

Weil Streams Nachrichten übertragen, diese on-the-fly transformieren und veränderlichen Zustand kapseln können, sowie miteinander kombinierbar sind, sind Streams ein sehr vielseitiges Tool. Die Aufgaben, die sie übernehmen können, sind hier zusammengefasst:

  • Reduktion des Speicherbedarfs
  • Transformationen, sobald Daten verfügbar sind
  • einheitliche Schnittstellen
  • Kapselung von Zustand und Entkopplung von Modulen
  • Implementierung der Verarbeitungsflüsse, so genannt Flow Control

Es ist klar, dass das alles nicht von einer einzelnen Bibliothek in gleichem Maße geleistet werden kann. Daher möchte ich hier drei Bibliotheken kurz vorstellen.

Node.js Core Streams

Das Node.js Stream Modul ist eine der Kernkomponenten in Node.js, die für die Performance und geringen Speicherverbrauch mitverantwortlich sind. Sie stehen auch als Beispiel und Ursprung für die vielen spezialisierten Streams und streambasierten APIs in npm. Dieses Modul hat Performance bei low-level Operationen im Fokus, z.B. beim HTTP-Parser. Daher wurden hier viele Anstrengungen unternommen, um möglichst effizient mit Binärdaten und Zeichenketten zu arbeiten. Als zweite Priorität war es den Entwicklern wichtig, es möglichst einfach zu machen, Streams zu verwenden und selbst eigene zu schreiben. Unix Pipes waren hier Vorbild für die .pipe() Methode. Core Streams implementieren viele technische Details, wie Buffering und Behandlung von Backpressure. Dadurch können Entwickler sich auf ihre Applikationslogik konzentrieren. Dafür gibt es die erwähnten 4 abstrakten Konstruktoren, Readable, Writable, Duplex und Transform. Beispielhaft steht hier eine Implementierung eines Duplex Streams, _read und _write sind die Stellen für die Logik:

function Dup( ){
  Duplex.call(this)
}
util.inherits(Dup, Duplex)
 
Dup.prototype._write = function(chunk, enc, cb){
  consume(chunk, enc)
  cb()
}
 
Dup.prototype._read = function( size ){
  this.push( createChunk(size) )
}

Core Streams arbeiten standardmäßig mit Buffer und String Instanzen, für höhere Aufgaben sollte man den "objectMode" per Konstruktor-Parameter aktivieren. Wenn man die Core Streams im Browser nutzen möchte, kann man das korrespondierende readable-stream Modul in Verbindung mit Browserify und ähnlichen Tools bemühen.

Core Streams in Node unterstützen sowohl Push- als auch Pull-Mechanismen. Der Wechsel zwischen diesen beiden Modi wird ab der nächsten Version (Stream3 mit Node Version 0.11 bereits implementiert) durch .pause() und .resume() reguliert.

Das Beispiel zeigt, dass die Implementierung eigener Streams prototypen-basiert ist. Nur wenig zeugt von der funktionalen Natur von Streams. Dies ist vor allem der Art geschuldet, wie v8 (die JS-Engine in Node.js) Code optimiert. Um dies eleganter zu machen wurde das folgende Tool gebaut.

EventStream

Das EventStream Modul von Dominic Tarr ist zwar hauptsächlich eine Sammlung von Stream Modulen, allen voran through, doch es verknüpft die Streams zu einer Idee:

EventStream is like functional programming meets IO.

Die Stärke dieser Module ist Simplizität. EventStream bringt einige generische Streams mit, die sich erweitern lassen. through selbst, der Kern der Bibliothek, ist eine der simpelsten Methoden aus einer (oder zwei) Funktion(en) ein Stream zu bauen. through Streams sind push-basiert. Um den Fluss zu kontrollieren kann man den pause Stream nutzen, oder die .pause() Methode bemühen. merge und einige Terminatoren sind ebenfalls im Gepäck. Streams sind wie Arrays und man sollte damit genauso einfach arbeiten können. Es gibt eine Menge Streams auf npm, die nicht von EventStream referenziert werden, und doch von through abhängen.

Bezeichnend ist auch, dass through der Kern von EventStream ist, denn through ist eigentlich ein Transform. In Node.js Core, ist Transform ein Sonderfall von Duplex. In EventStream sind eher Readable und Writable Sonderfälle. Zum Schluss noch ein Beispiel für eine through Implementierung:

es.through(function transform(data) {
    this.emit('data', data)
    //this.pause()
  },
  function end () { //optional
    this.emit('end')
  })

Highland.js

Highland.js von Coalan McMahon verfolgt eine ähnliche Idee, treibt sie aber konsequent weiter und vereinigt funktionale Programmierung mit Streams. Es bringt umfangreiche Möglichkeiten Streams zu manipulieren, transformieren und mit Streams höherer Ordnung zu arbeiten, dazu Stream-Kombinationen, currying und lazy evaluation.

Highland kann vieles in einen Stream verwandeln, EventEmitter, Arrays, Funktionen, Promises und andere Streams. Highland Streams sind dabei kompatibel mit der Node.js API. Die Bibliothek verfolgt das Ziel, typische Flow Control Probleme in asynchronen Umgebungen mit Streams zu begegnen, aber auch die Verarbeitung von Sequenzen grundsätzlich neu zu denken. Dabei verschwimmt die Grenze zwischen synchronem und asynchronem Code zu einem kaum noch relevanten Detail. Das unten gezeigte Beispiel hat mit dem weiter oben funktional viel gemeinsam. Beide nehmen den HTTP-Request Body und schreiben ihn in eine Datei oder auf die Standardausgabe. Doch wie der Verarbeitungsprozess modelliert und implementiert wurde, ist unterschiedlich. Im ersten Beispiel ist der Seiteneffekt innerhalb der Map-Funktion implementiert. Im folgenden Beispiele ist der Prozess durch .sequence() verflacht worden. Das ist hier sinnvoll, denn der Seiteneffekt ist die Hauptverantwortlichkeit dieses Programms, und die Änderung des Zustandes ist ganz klar nach außen geschoben worden: .pipe(process.stdout)

var s = hl('request', httpServer, ['req', 'res'])
 
s.fork().pluck('req').map(hl).sequence().pipe(process.stdout)
s.fork().pluck('res').each(function(res){
  res.writeHead(200)
  res.end('Success!')
})

Meiner Meinung nach ist das die größte Stärke von Highland: Es erlaubt uns, unsere Applikation unterschiedlich zu denken und zu modellieren, ohne dabei viel Refactoring machen zu müssen. Nun noch zum Abschluss noch ein Beispiel für einen einfachen Stream in Highland:

var count = 0
 
var streamIntsTill100 = hl(function (push, next) {
  count += 1
  if (count <= 100)
    push(null, count)
  else
    push(new Error('Overflow!'))
  next()
})

Was nutze ich nun?

Das ist eine Frage der Prioritäten. Ist Ausführungsperformance das A und O, dann sind Core Streams sehr wahrscheinlich die erste Wahl, der Preis ist allerdings eine umfangreichere Codebasis in der eigenen Anwendung. EventStream und through-basierte Konsorten bringen viele Funktionen bereits mit und könnten so die Entwicklungszeit reduzieren. Highland.js bringt Möglichkeiten, die Applikation völlig neu zu denken und neue Funktionen aus Bestehenden abzuleiten.

Ich persönlich bin begeistert von Highland. Doch für diejenigen, die wenig mit Functional Programming anfangen können, könnte es leicht abschreckend wirken. EventStream ist ein guter Einstieg, um mit Streams zu spielen. Und es lohnt sich immer zu wissen, wie die Core Streams funktionieren.

Fazit

Man könnte Streams als eierlegende Wollmilchsau bezeichnen. Das sind sie nicht, aber nah dran. Sie sind ein vielseitiges Tool, das weitaus mehr kann, als zunächst erwartet. Auf unterschiedlichen Abstraktionsebenen eingesetzt, helfen sie bei der Performance, der Kombinierbarkeit und Kompatibilität, der Entkopplung und der Flusskontrolle. Mit weiteren Tools können Streams funktionale Konzepte realisieren und Programme deklarativer machen. Es lohnt sich immer, uns mit Streams zu beschäftigen und unsere Applikationen mit deren Hilfe zu gestalten und zu implementieren. Dabei muss man nicht aufs Ganze gehen, Streams lassen sich stufenweise einstreuen. Wir nutzen sie bereits in Node.js, oft unbewusst. Sie stehen nicht im Weg.

Mich würden eure Erfahrungen mit Streams interessieren. Habt ihr schon Streams exzessiv eingesetzt, wofür nutzt ihr sie?

Gregor Elke spezialisiert sich auf Themen rund um JavaScript-Entwicklung. Ob verteilte Services mit Node.js oder Web-Frontends mit Angular, React oder ganz ohne, JavaScript hält überall Einzug.
Er teilt seine Begeisterung für JavaScript gerne mit anderen, daher gibt er gerne Schulungen und Coachings in diesen Themenbereichen.
Gregor Elke organisiert auch die Hamburger Node.js User Group.

Share on FacebookGoogle+Share on LinkedInTweet about this on TwitterShare on RedditDigg thisShare on StumbleUpon

Kommentieren

Deine E-Mail-Adresse wird nicht veröffentlicht. Erforderliche Felder sind mit * markiert.