post

Babel Node Express: moderne Syntax, altes HTTP

Babel übersetzt Syntax, kein Ausführungsmodell – wer das trennt, weiß, warum babel-node in die Entwicklungsschleife gehört und nicht in den Betrieb.

≈ 9 Min. Lesezeit

Diesen Beitrag anhören (13 Min.)

MP3 herunterladen
$ node src/server.js
/app/src/server.js:1
(function (exports, require, module, __filename, __dirname) { import express from 'express';
                                                               ^^^^^^
SyntaxError: Unexpected token import
    at Object.exports.runInThisContext (vm.js:76:16)
    at Module._compile (module.js:542:28)

So quittiert Node 6 eine Datei, die mit einem import beginnt. Die Meldung ist kurz, die Verwirrung dahinter verlässlich: Dieselbe Zeile steht in gefühlt jedem zweiten Tutorial dieses Jahres, sie läuft im Frontend-Build ohne Murren – nur die Laufzeitumgebung, die den Server am Ende tatsächlich ausführen soll, kennt das Schlüsselwort nicht. ES2015 ist seit anderthalb Jahren verabschiedet, V8 hat einen großen Teil davon eingebaut, und trotzdem scheitert der Start an der ersten Zeile der ersten Datei.

Der Anlass für diesen Beitrag ist ein kleines Repository, das ich Anfang Dezember zusammengesteckt habe: babel-node-expressjs, eine bewusst schlichte Express-App, durchgehend in ES2015+ geschrieben und über Babel lauffähig gemacht. Am selben Tag entstand ein zweites, babel-node-rx-ping-monitor, das RxJS durch dieselbe Toolchain schiebt – das hat aber ein eigenes Thema und verdient einen eigenen Text. Hier geht es um die Trennlinie, die beim Aufsetzen sichtbar wurde und die in Diskussionen erstaunlich oft verwischt: Babel verändert die Sprache, nicht das Ausführungsmodell. Die Syntax ist 2016 modern geworden. Das HTTP darunter ist dasselbe geblieben.

Was Node 6 und 7 schon von selbst können

Bevor Babel ins Spiel kommt, lohnt ein nüchterner Blick darauf, was die Plattform ohne jedes Werkzeug beherrscht. Node 6 ist seit Oktober die aktive LTS-Linie, Node 7 die aktuelle Entwicklungslinie, und beide bringen über V8 bereits einen erheblichen Teil von ES2015 nativ mit:

  • Klassen samt extends und super
  • const und let mit echtem Block-Scoping
  • Template-Literals, Arrow Functions, Default-Parameter
  • Destructuring für Objekte und Arrays
  • Promises und Generatoren

Wer nachsehen will, was die eigene Node-Version im Detail abdeckt, findet auf node.green eine Matrix pro Version und Feature. Die Liste ist länger, als viele annehmen – und genau deshalb ist die pauschale Ansage „für ES2015 brauchst du Babel“ inzwischen schief. Für den Großteil der Sprache braucht man es schlicht nicht mehr.

Zwei prominente Lücken bleiben. async/await existiert in Node 7 nur experimentell hinter der Flagge --harmony-async-await – für Code, der einen Server tragen soll, ist das kein Fundament, sondern ein Ausblick. Und import/export fehlt vollständig: Die Modul-Syntax ist zwar Teil der Spezifikation, aber wie ein Loader in Node aussehen soll und wie ES-Module mit dem gewachsenen CommonJS-Ökosystem zusammenspielen, ist ungeklärt. Ein natives ES-Modulsystem ist nicht in Sicht. Wer heute import im Backend schreibt, schreibt Syntax für eine Laufzeitumgebung, die es so noch gar nicht gibt – und braucht deshalb einen Übersetzer.

Die App: Express 4 in heutiger Schreibweise

Der Server im Repository tut absichtlich wenig – eine JSON-Route, eine Route für URL-encodierte Formulardaten, beides im README vorgeführt:

import express from 'express';
import bodyParser from 'body-parser';

const app = express();
const port = process.env.PORT || 3000;

app.use(bodyParser.json());
app.use(bodyParser.urlencoded({ extended: false }));

app.get('/', (req, res) => {
  res.json({ message: 'hello' });
});

app.post('/', (req, res) => {
  const { msg } = req.body;
  res.json({ received: msg });
});

app.listen(port, () => {
  console.log(`server listening on port ${port}`);
});

Interessant ist nicht, was dieser Code tut, sondern wie er aussieht: import statt require, Arrow Functions statt function, const statt var, Destructuring am Request-Body, Template-Literal im Log. Das ist die Schreibweise, die sich dieses Jahr im Frontend endgültig durchgesetzt hat, und es gibt keinen guten Grund, im Backend eine ältere Sprachstufe zu pflegen als im Rest des Projekts. Einheitliche Syntax senkt die Reibung, wenn dieselben Leute vormittags React-Komponenten und nachmittags Express-Routen anfassen.

Am Rand sieht man hier auch ein Stück Express-4-Geschichte: Das Body-Parsing ist keine eingebaute Funktion mehr, sondern kommt als eigenes Paket body-parser dazu und wird pro Format als Middleware eingehängt. Wer von Express 3 kommt, stolpert zuerst genau an dieser Stelle – und das hat mit Babel nichts zu tun, mit HTTP dafür umso mehr.

Auf der Leitung ändert sich dadurch exakt nichts. Ein POST auf http://localhost:3000 mit einem URL-encodierten Formularfeld – im README steht der passende Einzeiler – schickt dieselben Bytes wie gegen jede Express-App der letzten drei Jahre, und die Antwort ist derselbe HTTP-Response mit denselben Headern. Middleware-Kette, Body-Parsing, Statuscodes, Content-Negotiation – das komplette HTTP-Verhalten ist von der Syntaxfrage unberührt. Das klingt banal. Es ist aber der Kern des Themas, denn genau hier entstehen die Missverständnisse.

Was babel-node im Entwicklungsmodus tut

Damit die Datei überhaupt startet, braucht es zwei Dinge: eine Übersetzungsvorschrift und ein Werkzeug, das sie anwendet. Die Vorschrift steht in der .babelrc:

{
  "presets": ["es2015"]
}

Das Preset es2015 ist der etablierte Weg mit Babel 6. Ganz frisch gibt es daneben babel-preset-env, das über ein targets-Feld nur noch das transpiliert, was die Ziel-Node-Version tatsächlich nicht kann – dazu gleich mehr. Das Werkzeug für die Entwicklung heißt babel-node und liegt der babel-cli bei. Es startet einen normalen Node-Prozess, hängt sich in den require-Mechanismus ein und übersetzt jede geladene Datei im Speicher, unmittelbar bevor V8 sie sieht. Kein dist/-Ordner, kein Zwischenschritt, zusammen mit nodemon ergibt das eine angenehm kurze Schleife:

{
  "scripts": {
    "dev": "nodemon --exec babel-node src/server.js",
    "build": "babel src --out-dir dist --source-maps",
    "start": "node dist/server.js"
  }
}

Datei speichern, nodemon startet neu, babel-node übersetzt, der Server läuft. Für die Entwicklung ist das genau richtig, weil die Rückmeldung zählt und der Prozess ohnehin ständig neu startet. Wichtig ist nur, sich klarzumachen, was da passiert: Die Übersetzung ist in den Programmstart hineingezogen worden. Jeder Start bezahlt sie erneut.

Derselbe Mechanismus trägt übrigens auch die Tests: babel-register, in mocha eingehängt, übersetzt die Testdateien nach demselben Prinzip beim Laden. Auch das ist für die Entwicklungsschleife gedacht und dort völlig in Ordnung – ein Testlauf ist ein kurzlebiger Prozess, bei dem ein paar hundert Millisekunden Anlauf nicht ins Gewicht fallen. Die Grenze verläuft also nicht zwischen „mit Babel“ und „ohne Babel“, sondern zwischen kurzlebigen Werkzeugprozessen und dem einen Prozess, der lange laufen und nach einem Absturz schnell wieder da sein muss.

flowchart LR
  subgraph DEV[Entwicklung]
    S1[src/server.js<br/>ES2015+] --> BN[babel-node<br/>übersetzt im Speicher] --> P1[Node-Prozess]
  end
  subgraph PROD[Produktion]
    S2[src/server.js<br/>ES2015+] --> BC[npm run build<br/>babel-cli] --> D[dist/server.js<br/>ES5 + CommonJS] --> P2[node dist/server.js]
  end

Was der bequeme Weg in Produktion kostet

Die Versuchung liegt nahe, babel-node einfach auch in Produktion zu verwenden – ein Skript weniger, ein Ordner weniger, dieselbe Kommandozeile überall. Die Babel-Dokumentation rät selbst ausdrücklich davon ab, und die Gründe sind handfest:

  • Der Prozessstart wird spürbar langsamer, weil jede Datei beim Hochfahren erst durch den Transpiler muss – bei jedem Deployment, bei jedem Neustart nach einem Absturz, bei jedem Prozess im Cluster erneut.
  • Der Speicherverbrauch steigt, weil Babel samt Parser, Presets und Cache dauerhaft im Serverprozess mitläuft, obwohl er nach dem Start nichts mehr beiträgt.
  • Stacktraces zeigen auf übersetzten Code, den nie jemand geschrieben hat; ohne sauber verdrahtete Sourcemaps zeigt die Zeilennummer im Fehlerreport ins Leere.
  • Der Debugger arbeitet gegen den erzeugten Code, Breakpoints landen neben der gemeinten Zeile, und das Verhalten hängt an der installierten Babel-Version auf der Zielmaschine.

Mir ist der Punkt in einem Projekt dieses Jahr konkret begegnet: Ein Kollege hatte einen kleinen internen Service mit babel-node deployt, und wochenlang fiel das niemandem auf. Aufgefallen ist es erst, als der Prozess unter Last neu startete und der Supervisor ihn mehrfach hintereinander hochzog – jeder Neustart mehrere Sekunden Transpilierung, in denen der Port zwar offen, der Service aber nicht ansprechbar war. Der Fehler stand in keiner Zeile Anwendungscode. Er steckte in der Entscheidung, ein Entwicklungswerkzeug in den Betrieb mitzunehmen.

Erst bauen, dann starten

Die erwachsene Variante trennt die Übersetzung vom Start. npm run build ruft die babel-cli auf, übersetzt src/ einmal nach dist/ und legt Sourcemaps daneben. Was dort ankommt, ist unspektakuläres ES5 mit CommonJS – aus dem import am Dateianfang wird genau das, was Node seit Jahren versteht:

'use strict';

var _express = require('express');

var _express2 = _interopRequireDefault(_express);

function _interopRequireDefault(obj) {
  return obj && obj.__esModule ? obj : { default: obj };
}

Man muss diesen erzeugten Code nicht schön finden, aber man sollte ihn einmal gelesen haben. Er nimmt der Modul-Syntax jede Magie: import ist nach der Übersetzung ein synchrones require beim Programmstart, mit einer kleinen Verpackung für den Default-Export. Keine neue Ladelogik, kein anderes Auflösungsverhalten, kein Unterschied zur Laufzeit.

Das Artefakt in dist/ hat zwei angenehme Eigenschaften. Erstens braucht die Zielmaschine kein Babel mehr – node dist/server.js genügt, die devDependencies bleiben zu Hause. Zweitens ist der Startpfad wieder der langweilige, den Node seit Jahren geht: Prozess starten, Module laden, Port öffnen. Die Übersetzung passiert einmal pro Build statt einmal pro Start, und genau dort gehört sie hin. Dass zwischen „es läuft auf meiner Maschine per babel-node“ und „es läuft als gebautes Artefakt“ ein Unterschied besteht, merkt man sonst erst im unpassendsten Moment.

Praktisch folgt daraus auch, wo gebaut wird: in der Pipeline, nicht auf der Zielmaschine. Gebaut und getestet wird einmal, das Ergebnis wird ausgeliefert, und eine Installation ohne devDependencies lässt Babel samt Presets auf dem Server komplett weg. Die Reihenfolge im README des Repos – build, test, dann erst run – ist insofern keine Formsache, sondern die Kurzfassung dieses Betriebsmodells.

Sprache oben, Ausführungsmodell unten

Damit zur eigentlichen Trennlinie. Babel arbeitet ausschließlich auf der Ebene der Sprache: Es liest Syntax, die V8 noch nicht versteht, und schreibt sie in Syntax um, die V8 versteht. Alles darunter bleibt unangetastet – der Event Loop, das Modulsystem zur Laufzeit, Streams, Sockets, das komplette HTTP.

flowchart TB
  B[Babel] -->|verändert nur diese Ebene| L1
  L1[Sprache: import, Klassen,<br/>Arrow Functions, Destructuring] --> L2[Laufzeitumgebung: Event Loop,<br/>CommonJS-require, Streams]
  L2 --> L3[HTTP: Verbindungen, Header,<br/>Request und Response]

Diese Trennung klingt selbstverständlich, wird aber regelmäßig verwischt, sobald die neue Syntax nach mehr aussieht, als sie ist. Drei Beispiele aus Gesprächen der letzten Monate. Ein import oben in der Datei wirkt deklarativ und statisch – nach der Übersetzung ist es ein gewöhnliches require, das beim Start synchron ausgeführt wird, in der Reihenfolge der Zeilen. Eine Arrow Function in der Route wirkt leichtgewichtig – sie läuft auf demselben einen Thread wie alles andere, und eine teure JSON-Serialisierung darin blockiert den Event Loop exakt so wie 2013. Und die async-Vorboten aus Node 7 wirken wie Nebenläufigkeit – tatsächlich sind sie nur eine andere Schreibweise für Promise-Ketten, an Timeouts, Fehlerpfaden und Lastverhalten ändern sie nichts. Wer einen Express-Server mit moderner Syntax betreibt, betreibt denselben Server. Die Fragen, die im Betrieb zählen – wie lange darf ein Request dauern, was passiert bei einem abgebrochenen Body, wer beendet den Prozess sauber –, beantwortet kein Preset.

Umgekehrt hilft die Trennung auch beim Sparen. Weil Node 6 den Großteil von ES2015 nativ ausführt, muss Babel gar nicht mehr die ganze Sprache übersetzen. Genau da setzt das frische babel-preset-env an: Man nennt ihm die Zielumgebung, und es wählt nur die Transformationen aus, die dort wirklich fehlen.

{
  "presets": [
    ["env", { "targets": { "node": 6 } }]
  ]
}

Für Node 6 bleibt davon im Wesentlichen die Modul-Syntax übrig. Der erzeugte Code bleibt näher am Original, Klassen bleiben echte V8-Klassen statt ES5-Prototypen-Nachbauten, und der Build wird schneller. Je besser die Plattform wird, desto dünner darf die Übersetzungsschicht sein – bis sie eines Tages vielleicht ganz verschwindet. Darauf wetten würde ich beim Thema Module allerdings noch nicht.

Was ich daraus mitnehme

Die beiden kleinen Repos waren an einem Nachmittag zusammengebaut, aber die Trennlinie, die sie zeigen, trägt weiter als der Code. Babel ist ein Übersetzer für Syntax, kein neues Ausführungsmodell – und beide Richtungen dieser Aussage sind nützlich. In die eine Richtung: Die moderne Schreibweise kostet im Betrieb nichts, wenn man sie im Build bezahlt statt beim Prozessstart; babel-node gehört in die Entwicklungsschleife, node dist/ in die Produktion. In die andere Richtung: Die moderne Schreibweise löst auch nichts, was auf der HTTP-Ebene zu lösen wäre. Timeouts, Fehlerbehandlung, Body-Grenzen und Neustartverhalten sehen in ES2015 exakt so aus wie in ES5, nur hübscher.

Wenn im nächsten Jahr async/await ohne Flagge ankommt, wird sich dieselbe Frage neu stellen, mit denselben Antworten. Die Syntax wird wieder ein Stück moderner. Das HTTP darunter bleibt das alte – und das ist keine schlechte Nachricht, denn es bedeutet, dass sich zwanzig Jahre Wissen über Server nicht mit jedem Preset erneuern müssen.

Weiterführende Quellen

Kommentare