Netpack bedeutet Konkurrenz für esbuild und webpack

Webprojekte bauen mit .NET und C#

Webprojekte bauen mit .NET und C#

Netpack bedeutet Konkurrenz für esbuild und webpack

Webprojekte bauen mit .NET und C#


In den letzten Jahren hat sich die Tooling-Struktur für Webentwicklung stark verändert. Wurde vorher jedes Werkzeug direkt in JavaScript geschrieben und plattformunabhängig über Node.js ausgeführt, sind nun native Tools zum Standard geworden. Einer der Gründe hierfür ist die Performance. Mit einer sicheren, systemnahen Sprache wie bspw. Rust lassen sich pfeilschnelle Programme schreiben, die Node.js-Pendants wie langsame Schildkröten aussehen lassen. Aber muss es immer Rust sein? Kann .NET mithalten und auch eine wichtige Rolle spielen? Mit dem Beispielprojekt Netpack [1] können diese Fragen aufschlussreich beantwortet werden.

Die Entwicklung von Webanwendungen hat in den letzten Jahrzehnten eine rasante Evolution durchlaufen. Vom einfachen HTML- und CSS-gestützten Webdesign bis hin zu hochkomplexen, dynamischen Applikationen hat sich das Tooling kontinuierlich weiterentwickelt. Ein zentraler Aspekt dieser Entwicklung ist der Einfluss von JavaScript und Node.js, die sich als dominierendes Fundament für Webentwicklungstools etabliert haben. In den letzten Jahren zeigt sich jedoch ein wachsender Trend hin zu nativen, oft in Rust oder Go geschriebenen Alternativen.

Ein entscheidender Wendepunkt für die Webentwicklung war die Einführung von Node.js im Jahr 2009. Node.js ermöglichte es, JavaScript nicht nur im Browser, sondern auch serverseitig auszuführen. Das war die Geburtsstunde eines neuen Ökosystems: Entwickler:innen konnten nun dieselbe Sprache für Frontend und Backend verwenden.

Mit Node.js entstanden leistungsfähige Tools wie npm (Node Package Manager), webpack, Gulp und Babel, die den Entwicklungsprozess drastisch verbesserten. Diese Werkzeuge adressierten zentrale Herausforderungen:

  • Modularisierung: Durch das npm-Ökosystem können Entwickler:innen problemlos Drittanbieterbibliotheken einbinden.

  • Performanceoptimierung: Tools wie webpack ermöglichen Code-Splitting, Tree Shaking und andere Optimierungen.

  • Cross-Platform-Entwicklung: Mit Node.js unter der Haube können JavaScript-basierte Werkzeuge auf jedem Betriebssystem ausgeführt werden.

Die Popularität von JavaScript-Tools war nicht nur auf ihre Funktionalität zurückzuführen, sondern auch auf die große Community und die einfache Lernkurve für Webentwickler:innen.

Die Grenzen von JavaScript

Trotz aller Vorteile gab es auch Herausforderungen bei der Nutzung von JavaScript und Node.js-basierten Tools:

  • Performanceprobleme: JavaScript ist eine interpretierte Sprache, was oft zu einem erhöhten Speicherverbrauch und geringerer Effizienz führt.

  • Komplexität des Ökosystems: Mit der Vielzahl an Bibliotheken und Tools kann es schwierig sein, eine konsistente und wartbare Umgebung zu schaffen.

  • Sicherheitsrisiken: Die Abhängigkeit von Drittanbieterpaketen erhöht das Risiko von Supply-Chain-Angriffen.

Insbesondere das Thema Performance spielt hierbei eine zentrale Rolle. Mittlerweile sind Webprojekte so groß geworden, dass die in JavaScript geschriebenen Werkzeuge an ihre Grenzen kommen. Nicht nur die Ausführungsgeschwindigkeit leidet hierbei; der Speicherverbrauch ist auch enorm. Meistens reichen die standardmäßig allozierten 512 MB bzw. 1 GB nicht mehr, um eine große Webanwendung mit webpack zu bauen. Natürlich kann man dann per Umgebungsvariable mehr Speicher anfordern, an dem Verbrauch bzw. den damit verbundenen Performanceeinbußen ändert das allerdings nichts.

Native Alternativen im Aufschwung

In den letzten Jahren haben sich daher verstärkt native Alternativen durchgesetzt, insbesondere solche, die in Sprachen wie Rust und Go geschrieben wurden. Projekte wie esbuild (geschrieben in der Programmiersprache Go) oder SWC (geschrieben in Rust) haben sich als ultraschnelle Alternativen zu JavaScript-basierten Build-Tools etabliert. Die wichtigsten Vorteile dieser Technologien sind:

  • Höhere Performance: Kompilierte Sprachen sind deutlich schneller als interpretiertes bzw. JIT-kompiliertes JavaScript.

  • Effizienter Speicherverbrauch: Rust (direkte Speicherverwaltung) und Go (GC-basiert) bieten im Allgemeinen eine bessere Kontrolle über Speicherverwaltung und Optimierungen.

  • Sicherheit: Besonders Rust minimiert durch seine Speichersicherheitsgarantien viele potenzielle Sicherheitslücken.

Ein massiver Nachteil von Sprachen wie Rust ist jedoch die notwendige Komplexität und Lernkurve. Während JavaScript als Tooling-Sprache den Vorteil hatte, nahezu allen Webentwickler:innen bekannt zu sein, ist Rust hier sicherlich ein eher exotischer Skill, den nur wenige Webentwickler:innen vorzuweisen haben.

Warum nicht C#/.NET?

Neben Rust und Go bietet auch C#/.NET eine interessante Alternative zur Entwicklung von Tooling für Webanwendungen. Einer der größten Vorteile von .NET ist die hohe Performance, insbesondere in der Verwendung mit der Möglichkeit einer Ahead-of-Time(AoT)-Kompilierung. Dank AoT gibt es auch beim Kaltstart einer Applikation keine spürbare Verzögerung mehr. Eine Runtime muss in diesem Fall auch nicht auf dem Zielsystem installiert sein.

Mit Hilfe von AoT können sicherlich viele Argumente, die für Rust und Co. Sprechen, ausgeglichen werden. Allerdings sind dann auch verschiedene .NET-Fähigkeiten wie beispielsweise Reflection oder dynamische Codeausführung nicht mehr vollständig möglich.

Der größte Nachteil von .NET ist die starke Abhängigkeit von Microsoft. Zudem ist die Einstiegshürde für Entwickler:innen ohne vorherige Erfahrung mit dem .NET-Ökosystem höher als bei Go und teilweise sogar nicht geringer als bei Rust. Auch wenn die Performance von .NET im Allgemeinen sehr gut ist, bleibt sie in manchen Bereichen verglichen mit Rust auf der Strecke, insbesondere, wenn maximale Speicher- und Effizienzoptimierung erforderlich ist. Diesen Nachteil kann man zwar in gewissen Rahmen ausgleichen, allerdings nur zu Last der Lesbarkeit bzw. der Einsteigerfreundlichkeit des Programmcodes.

High-Performance C#/.NET

Die .NET-Plattform hat sich in den letzten Jahren stark weiterentwickelt und bietet mittlerweile erstklassige Werkzeuge zur Entwicklung von High-Performance-Anwendungen. Gerade im Bereich des Toolings, das oft effiziente Verarbeitung großer Datenmengen, schnelle I/O-Operationen und geringen Speicherverbrauch erfordert, stellt .NET eine leistungsfähige Alternative zu Sprachen wie Rust oder Go dar. Hierfür gibt es drei zentrale Techniken, die für hochperformante Web-Tooling-Anwendungen in .NET entscheidend sind: Ahead-of-Time-Kompilierung, speichereffiziente Algorithmen mit Span und Memory sowie optimierte Dateisystemzugriffe.

Startzeitoptimierung

Traditionell werden .NET-Anwendungen mit einer Just-in-Time-(JIT-)Kompilierung ausgeführt, die den Code zur Laufzeit in nativen Maschinencode umwandelt. Während das Flexibilität bietet, kann es zu Performancenachteilen führen, insbesondere beim Start von Anwendungen oder auf Plattformen, die keine JIT-Kompilierung unterstützen (z. B. iOS oder Embedded-Systeme).

Mit Native AoT (Ahead-of-Time-Kompilierung), das in .NET 7 und 8 eingeführt wurde, kann der gesamte Code im Voraus kompiliert werden, wodurch sich mehrere Vorteile ergeben:

  • Schnellerer Start: Da der Code bereits in nativer Form vorliegt, entfallen die initialen JIT-Overheads, was besonders bei Kommandozeilentools wichtig ist.

  • Geringerer Speicherverbrauch: Nicht verwendete Laufzeitkomponenten können entfernt werden, wodurch das Endprodukt kompakter wird.

  • Bessere Plattformkompatibilität: AoT erlaubt es, .NET-Anwendungen als eigenständige, native Binärdateien auszuliefern.

Ein Beispiel für die Nutzung von AoT mit .NET:

# Erstellung einer nativen AoT-Binärdatei für Linux
dotnet publish -c Release -r linux-x64 --self-contained true -p:PublishAot=true

Damit wird die Applikation direkt für die gegebene Plattform (im Listing x64-basiertes Linux) gebaut. Eine zusätzliche Runtime ist damit nicht mehr notwendig. Diese Technik eignet sich besonders für CLI-Tools, bei denen Startzeit und Speicherverbrauch entscheidend sind.

Speicherzugriffsoptimierung

Speicherverwaltung spielt eine zentrale Rolle in der Performanceoptimierung von .NET-Anwendungen. Während traditionelle Ansätze oft mit Arrays oder Listen arbeiten, hat .NET mit Span und Memory effiziente Alternativen eingeführt, die die Speicherzuweisung reduzieren und die Garbage Collection (GC) entlasten.

  • Span: Ermöglicht die Arbeit mit Speicherbereichen (z. B. Arrays, Puffer, Strings), ohne zusätzliche Kopien zu erzeugen

  • Memory: Ähnlich wie Span, aber mit Unterstützung für asynchrone Operationen und Heap-Daten

Ein Beispiel für einen effizienten Stringparser mit Span<char> finden Sie in Listing 1.

Listing 1

public static int ParseInt(ReadOnlySpan<char> input)
{
  var result = 0;
  foreach (char c in input)
  {
    if (c < '0' || c > '9')
      throw new FormatException("Invalid character in input.");
    result = result * 10 + (c - '0');
  }
  return result;
}

Hier wird eine Zeichenfolge analysiert, ohne zusätzliche Speicherzuweisungen oder Stringobjekte zu erzeugen. Das reduziert den GC-Druck erheblich und verbessert die Verarbeitungsgeschwindigkeit.

Dateizugriffsoptimierung

Viele Tooling-Anwendungen müssen große Mengen an Dateien lesen oder schreiben. Standardmäßige File-I/O-Methoden können jedoch Performancerngpässe verursachen, insbesondere bei vielen kleinen Dateien oder großen Dateiströmen. Optimierungsmöglichkeiten für File-I/O in .NET:

  • Buffered Streams: Reduziert teure Systemaufrufe

  • Asynchrones I/O: FileStream und StreamReader mit async/await zur Verbesserung der Parallelverarbeitung

  • Memory-Mapped Files: Ermöglichen den direkten Zugriff auf große Dateien ohne große Speicherallokationen

Listing 2 ist ein Beispiel für asynchrones Lesen einer Datei mit Memory<T>. Hier wird die Datei mit minimalen Speicherzuweisungen gelesen und die Verarbeitung durch den Einsatz von Memory<byte> optimiert. Jetzt ist es an der Zeit diese Annahmen in einem realistischen Projekt zu bestätigen.

Listing 2

public async Task<byte[]> ReadFileEfficientlyAsync(string filePath)
{
  await using var stream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.Read, bufferSize: 4096, useAsync: true);
  var buffer = new byte[stream.Length];
  await stream.ReadAsync(buffer.AsMemory());
  return buffer;
}

Wir bauen einen Bundler

Ein Bundler ist ein Werkzeug, das dazu dient, die verschiedenen Ressourcen eines Webprojekts – wie JavaScript-Dateien, CSS, Bilder und andere Assets – zu bündeln und zu optimieren. Webprojekte bestehen oft aus vielen modularen Dateien, die zur besseren Wartbarkeit und Wiederverwendbarkeit in kleinere Einheiten aufgeteilt sind. Ein Bundler fasst diese Dateien zusammen, entfernt ungenutzten Code (sogenanntes Tree Shaking) und minimiert die Dateigröße, mit dem Ziel die Ladezeiten und die Performance der Webanwendung zu verbessern. Die grundlegende Architektur eines Bundlers umfasst mehrere Stufen:

  • Parsing: Der Quellcode wird in eine interne Struktur (z. B. einen AST – Abstract Syntax Tree) umgewandelt.

  • Dependency Resolution: Abhängigkeiten zwischen Modulen werden erkannt und aufgelöst.

  • Optimierung: Überflüssiger Code wird entfernt und die Inhalte werden soweit wie möglich minimiert.

  • Ausgabe: Die optimierten Inhalte werden in einer einzigen oder wenigen kompilierten Dateien ausgegeben, die direkt vom Browser geladen werden können.

Bekannte Beispiele für Bundler sind webpack, Rollup, Parcel und moderne Alternativen wie esbuild (Go) oder SWC (Rust). Diese Tools sind essenziell für moderne Webentwicklungsworkflows, da sie die Performance und Effizienz von Webanwendungen erheblich verbessern (Abb. 1). Daneben gibt es zusammengesetzte Bundler wie Vite, die unter der Haube verschiedene Bundler verwenden.

rappl_netpack_1

Abb. 1: Architektur eines Bundlers

Ein Bundler eignet sich besonders gut als Testprojekt, um zu überprüfen, ob .NET als Basis für Web-Tooling geeignet ist. Da ein Bundler rechenintensive Aufgaben wie Code-Parsing, Optimierung und Dateisystemzugriffe erfordert, kann eine Implementierung in .NET zeigen, wie leistungsfähig die Plattform für solche Anwendungen ist. Besonders die Nutzung von AoT-Kompilierung, Span/Memory für effiziente Datenverarbeitung und optimierte Dateizugriffe könnte einen .NET-basierten Bundler mit hoher Performance ermöglichen.

Ein weiterer Vorteil dieses Beispielprojekts ist die direkte Einsicht in das .NET-Ökosystem. Sollten bereits Projekte existieren, um einige Aufgaben zu übernehmen, werden diese in das Beispielprojekt integriert. So kann man beispielsweise AngleSharp zum Prozessieren von HTML-Quelltexten einsetzen. Für CSS-Inhalte wäre analog AngleSharp.Css eine sinnvolle Option. Auch Themen wie Bildoptimierung oder JavaScript bzw. TypeScript Parsing müssen abgedeckt werden.

Zielsetzung

Das Beispielprojekt muss in der Lage sein, eine Webapplikation auf Basis einer oder mehrerer Eingangsdateien zu bauen bzw. bundeln. Daher muss das Projekt nicht nur den Quellcode verstehen, sondern auch entsprechend transformieren können.

Um die Struktur der Webapplikationsmodule auszulesen, wird ein Graph modelliert. Hierbei stehen die Knotenpunkte für die verschiedenen Module. Zu jedem Knotenpunkt gibt es jede Menge Informationen, bspw. den Pfad im Dateisystem, die erkannte Syntax, die Dateigröße sowie die importierten und exportieren Knotenpunkte.

Die Zielsetzung von Netpack ist aus mehreren Entwicklungsdateien eine im Browser ausführbare Webapplikation zu bauen. Dabei sollen HTML, CSS und JavaScript im Fokus des Bauprozesses stehen. Um das Projekt so realistisch wie möglich zu machen, sind darauf aufbauende Technologien wie bspw. SASS (baut auf CSS auf) oder TypeScript (baut auf JavaScript auf) ebenfalls zu berücksichtigen.

Vorgehensweise

Zunächst musste geklärt werden, ob .NET überhaupt bzgl. Startgeschwindigkeit und Dateisystemoperationen mit Sprachen wie Go mithalten kann. Dafür wurde Netpack ohne spezifisches JavaScript- bzw. TypeScript-Wissen fertiggestellt. Stattdessen wurden reguläre Ausdrücke implementiert, die auf Basis des Quelltextes die importierten und exportierten Knoten detektieren konnten. Außerdem musste die aus Node.js bekannte Dateiauflösung implementiert werden.

Die aus Node.js bekannte Dateiauflösung funktioniert in etwa so: Wenn ein Modul foo z. B. über require("foo") importiert wird, sucht Node.js zunächst nach einem direkt in Node.js eingebauten Modul mit genau diesem Namen. Falls keines gefunden wird, durchsucht es rekursiv die node_modules-Verzeichnisse entlang des Dateisystempfads, beginnend beim aktuellen Verzeichnis und aufsteigend bis zur Root-Ebene (d. h. / auf Linux-Systemen). Wenn sich im gefundenen Modul eine Datei namens package.json mit einem main-Feld befindet, wird die dort angegebene Datei geladen, andernfalls sucht Node nach einer Datei index.js oder index.mjs.

In Node.js unterscheidet sich die Modulauflösung je nach Modultyp: CommonJS (.cjs) oder ECMAScript Module, kurz ESM (.mjs). Der Modultyp wird entweder durch die Dateiendung (.mjs für ESM, .cjs für CommonJS) oder durch das type-Feld in der package.json bestimmt ("type": "module" für ESM, "type": "commonjs" oder weglassen für CommonJS). Wenn require ("foo") in einem CommonJS-Modul verwendet wird, sucht Node.js nach einer package.json-Datei im foo-Verzeichnis. Falls sie existiert und das exports-Feld enthält, bestimmt dieses, welche Dateien exportiert werden dürfen. Ohne exports greift Node standardmäßig auf main zurück. In ESM funktioniert require nicht, stattdessen wird import verwendet. In C# lässt sich dieser Algorithmus beispielsweise wie in Listing 3 umsetzen.

Listing 3

private async Task<string?> ResolveFromNodeModules(string? currentDir, string packageName)
{
  while (currentDir is not null)
  {
    var nodeModulesPath = CombinePath(currentDir, "node_modules", packageName);

    if (Directory.Exists(nodeModulesPath))
    {
      var packageJsonPath = CombinePath(nodeModulesPath, "package.json");

      if (File.Exists(packageJsonPath))
      {
        var mainEntry = await GetMainEntryFromPackageJson(packageJsonPath);

        if (File.Exists(mainEntry))
        {
          return mainEntry;
        }
      }
      else
      {
        var defaultIndexPath = CombinePath(nodeModulesPath, "index.js");

        if (File.Exists(defaultIndexPath))
        {
          return defaultIndexPath;
        }
      }
    }
    else if (File.Exists(nodeModulesPath))
    {
      return nodeModulesPath;
    }
    else if (Directory.Exists(Path.GetDirectoryName(nodeModulesPath)))
    {
      var result = ResolveFromFileSystem(nodeModulesPath);

      if (result is not null)
      {
        return result;
      }
    }

    currentDir = Directory.GetParent(currentDir)?.FullName;
  }

  return null;
}

Nachdem der Kernalgorithmus implementiert und die Auflösung über reguläre Ausdrücke integriert wurde, konnte ein erster Testlauf gestartet werden. Dabei wurden für ca. 5 000 Module mit einigen Abhängigkeiten Zeiten unter 100 ms aufgezeichnet. Im Vergleich dazu benötigte ein sehr schneller Bundler wie esbuild knapp unter 2s (jedoch inkl. Quelltextaufnahme, Inspektion und Transformation) – also über eine Magnitude mehr. Natürlich war dieser Test noch nicht vollständig aussagefähig, aber er bestätigte, dass C#/.NET zumindest beim Durchlaufen und Auslesen des Dateisystems mithalten kann. Wäre die Zeit hier schlechter gewesen, hätte man zu diesem Zeitpunkt bereits abbrechen können.

Als nächstes wurden bekannte Bibliotheken integriert, um die entsprechenden Module genauer zu analysieren und eine Transformation der gegebenen Inhaltsdaten in die gewünschten Ausgabedateien zu ermöglichen. Folgende Pakete wurden dafür hinzugefügt:

  • Acornima [2]: C#-Port von Acorn.js zum Parsen von JavaScript

  • AngleSharp [3]: C#-Implementierung der HTML 5.1 Spezifikation zum Parsen von HTML

  • AngleSharp.Css [4]: C#-Implementierung der CSS 2.1-Spezifikation mit vielen Modulen (bspw. Color L5, Selectors L4, …) zum Parsen von CSS

  • SkiaSharp [5]: C#-Port von Googles Skia-Bibliothek zur Bildbearbeitung

Mit diesen Paketen ausgestattet ist es möglich, Informationen über den Quellcode zu sammeln und den Quellcode zu transformieren.

Listing 4 zeigt, wie der Inhalt einer CSS-Datei innerhalb des Bundlers prozessiert wird. Zunächst wird der Inhalt als Stream an die Bibliothek AngleSharp.Css weitergegeben. Innerhalb der Bibliothek übernimmt dann die CssParser-Klasse die Prozessierung des Inhalts.

Mit Hilfe des Visitor-Musters werden dann alle AST-Knoten gesucht, die Modulreferenzen repräsentieren. Dabei müssen Modulreferenzen nicht unbedingt JavaScript- oder CSS-Module referenzieren, sondern können auch beliebige Assets wie z. B. Bilder darstellen. Die Knoten werden alle zu einem CSS-Fragment zusammengebaut, das später in Form einer Datei ausgegeben wird.

Listing 4

private async Task ProcessStyleSheet(Node current, byte[] bytes, Bundle bundle)
{
  using var stream = new MemoryStream(bytes);
  var options = new CssParserOptions
  {
    IsIncludingUnknownRules = true,
    IsIncludingUnknownDeclarations = true,
    IsToleratingInvalidSelectors = true,
  };
  var parser = new CssParser(options, _browser);
  var sheet = await parser.ParseStyleSheetAsync(stream);
  var visitor = new CssVisitor(bundle, current, InnerProcess);
  var fragment = await visitor.FindChildren(sheet);
  _context.CssFragments.TryAdd(current, fragment);
}

Nachdem der Bundler alle Module gefunden und prozessiert hat, wird ein optimierter Modulgraph erstellt, der den ursprünglichen Graphen um die Ausgabedateien erweitert. Das Ziel dieser Phase ist es, zusammenhängende Module zu identifizieren und gemeinsam genutzte Module herauszuarbeiten. Am Ende werden dadurch die zu erstellenden Dateien bestimmt. Jede Datei ist hier ein sog. „Bundle“, das einen Satz von vorher identifizierten Fragmenten erhält.

Für CSS ist die Implementierung des Bundles in Listing 6 dargestellt. Für die konkrete Ausgabe werden noch Optionen berücksichtigt, über die sich u. a. einstellen lässt, ob die Ausgabe minifiziert, d. h. optimiert und verkleinert werden soll.

Zur Erstellung der Ausgaberepräsentation des CSS-Bundles kann wieder die Bibliothek AngleSharp.Css verwendet werden. Das ist in mehrfacher Hinsicht sinnvoll. Zum einen kann man somit den AST wiederverwenden und spart daher unnötige Arbeit ein. Zum anderen bietet AngleSharp.Css bereits sogenannte Formatter an, die aus einem bestehenden AST eine Stringrepräsentation mit Hilfe von Vorgabeparametern produzieren. In diesem Fall kann so der MinifyStyleFormatter genutzt werden, um eine minifizierte Variante zu erstellen (Listing 5).

Listing 5

public sealed class CssBundle(BundlerContext context, Node root, BundleFlags flags) : Bundle(context, root, flags)
{
  public override Task<Stream> CreateStream(OutputOptions options)
  {
    var src = new MemoryStream();
    Stringify(src, options);
    src.Position = 0;
    return Task.FromResult<Stream>(src);
  }

  private void Stringify(MemoryStream ms, OutputOptions options)
  {
    var fragments = _context.CssFragments;
    
    if (fragments.TryGetValue(Root, out var root))
    {
      var replacements = root.Replacements;
      var stylesheet = root.Stylesheet;

      foreach (var replacement in replacements)
      {
        var property = replacement.Key;
        var node = replacement.Value;
        var reference = GetReference(node);
        property.Value = Regex.Replace(property.Value, @"url\(.*\)", $"url('./{reference}')");
      }

      var formatter = options.IsOptimizing ? new MinifyStyleFormatter() : CssStyleFormatter.Instance;
      using var writer = new StreamWriter(ms, Encoding.UTF8, -1, true);
      stylesheet.ToCss(writer, formatter);
    }
  }
}

Obwohl alle grundlegenden Bereiche direkt (z. B. mit Hilfe von Bibliotheken wie Acornima) oder indirekt (über IPC-Kommunikation zu einem Node.js-Prozess, der eine Node.js-Bibliothek zur Umsetzung aufruft) integriert worden sind, besteht natürlich noch Optimierungspotenzial, um weitere Funktionalität oder bessere Performance zu erzielen. Nichtsdestotrotz ist der hier skizzierte Stand bereits für eine erste Evaluation aussagekräftig.

Ergebnisse

Zur Evaluation bzw. zum Vergleich der Ergebnisse werden vier Webprojekte gebaut. Eine kleine Bibliothek dient hauptsächlich der Startzeit- bzw. Micro-Benchmark-Feststellung. Eine kleine Anwendung verifiziert die Vollständigkeit der integrierten Technologien. Eine mittelgroße Anwendung dient für einen ernsten Benchmark, der für eine Vielzahl von Projekten durchaus aussagekräftig ist.

Die wichtigste Hürde ist jedoch eine künstlich großgemachte Applikation mit über 5 000 Modulen. Obwohl das sicherlich nicht die weltweit größte Webapplikation darstellt, kann man dieses Webprojekt sicherlich als den wichtigsten Benchmark bezeichnen.

In Tabelle 1 sind die Zeiten zum Bau der einzelnen Projekte dargestellt. Mit esbuild (geschrieben in Go), rspack (geschrieben in Rust), Vite (verwendet Rollup, welches auf Node.js basiert) und Netpack (unser Projekt – geschrieben in C#) sind vier Bundler vertreten (Tabelle 1). Das Ergebnis spricht dafür, dass native Programmiersprachen sicherlich performancetechnisch vorne liegen, hier jedoch nicht zwingend Rust oder Go verwendet werden muss. C#/.NET ist eine durchaus ernstzunehmende Alternative.

Test

esbuild

rspack

Vite

Netpack

Kleine Bibliothek

326 ms

611 ms

601 ms

359 ms

Kleine Anwendung

670 ms

912 ms

1 658 ms

418 ms

Mittelgroße Anwendung

1 931 ms

2 877 ms

10 601 ms

974 ms

Große Anwendung

2 189 ms

2 422 ms

13 710 ms

1 357 ms

Tabelle 1: Vergleich etablierter Bundler mit Netpack

Alle Benchmarks sind im selben System ausgeführt worden. Hardwaretechnisch wurde ein AMD Ryzen 7 3700X mit 8 Kernen und 32 GB Arbeitsspeicher eingesetzt. Als Betriebssystem wurde Ubuntu 22.04, laufend innerhalb einer WSL2 unter Windows 10 verwendet. Für die Benchmarks selbst war Hyperfine [6] zuständig.

Die Benchmarks haben Netpack in Version 0.0.1, rspack in Version 1.1.8, esbuild in Version 0.24.0 und Vite in Version 6.0.1 verwendet. Alle Einzelheiten sind im GitHub-Repository von Netpack zu finden [1].

Zusammenfassung

Die Frage ob man anstelle von Rust oder Go auch auf C#/.NET setzen kann, lässt sich direkt mit „ja“ beantworten. Durch AoT und moderne Algorithmen sowie Datenstrukturen lassen sich nahezu problemlos gut leserliche Webentwicklungswerkzeuge erstellen, die ihren in Rust und Co. geschriebenen Pendants in nichts nachstehen.

Was die Zukunft von Netpack bereithält, ist in diesem Moment noch offen. Aktuell handelt es sich hierbei nahezu ausschließlich um ein Forschungsprojekt. Bei entsprechendem Interesse der Community kann man hier noch mehr machen – bis hin zu einer ernstzunehmenden Alternative für die etablierten Bundler wie bspw. webpack. Das naheliegende Ziel bleibt daher, das Ökosystem von .NET bzgl. des Baus von Webentwicklungswerkzeugen voranzutreiben und dadurch indirekt Wissen zur Performanceoptimierung und direkt Algorithmen/Code zur Analyse bzw. Verarbeitung von Webprojekten zu erlangen.

Florian Rappl

Dr. Florian Rappl ist ein Solutions Architekt aus Deutschland, der sich auf die Erstellung von skalierbaren verteilten Webanwendungen spezialisiert hat. Heutzutage arbeitet er fast ausschließlich an Micro-Frontend-Lösungen. Er hat ein Buch zu diesem Thema geschrieben und gibt regelmäßig Workshops auf Konferenzen und direkt für Unternehmen. Florian ist ein langjähriger Microsoft MVP im Bereich Entwicklungswerkzeuge.


Weitere Artikel zu diesem Thema