Heim >Web-Frontend >js-Tutorial >Erstellen eines skalierbaren Reverse-Proxy-Servers wie Nginx mit Node.js und TypeScript

Erstellen eines skalierbaren Reverse-Proxy-Servers wie Nginx mit Node.js und TypeScript

Susan Sarandon
Susan SarandonOriginal
2025-01-05 08:48:41644Durchsuche

Die Inspiration

In der heutigen Microservices-Architektur spielen Reverse-Proxys eine entscheidende Rolle bei der Verwaltung und Weiterleitung eingehender Anfragen an verschiedene Backend-Dienste.

Building a Scalable Reverse Proxy Server like Nginx with Node.js and TypeScript

Ein Reverse-Proxy sitzt vor den Webservern einer Anwendung und fängt die Anfragen ab, die von den Client-Rechnern kommen. Dies hat viele Vorteile wie Lastausgleich, versteckte IP-Adressen der Ursprungsserver, was zu besserer Sicherheit, Caching, Ratenbegrenzung usw. führt.

In einer verteilten Microservice-Architektur ist ein einziger Einstiegspunkt erforderlich. Reverse-Proxy-Server wie Nginx helfen in solchen Szenarien. Wenn mehrere Instanzen unseres Servers ausgeführt werden, wird die Verwaltung und Sicherstellung einer effizienten Anforderungsweiterleitung schwierig. Ein Reverse-Proxy wie Nginx ist in diesem Fall eine perfekte Lösung. Wir können unsere Domain auf die IP-Adresse des Nginx-Servers verweisen und Nginx leitet die eingehende Anfrage entsprechend der Konfiguration an eine der Instanzen weiter und kümmert sich dabei um die Last, die von jeder Instanz verarbeitet wird.

Warum macht Nginx das so gut?

Ich empfehle die Lektüre dieses Artikels von Nginx, der ausführlich erklärt, wie Nginx eine große Anzahl von Anfragen mit höchster Zuverlässigkeit und Geschwindigkeit unterstützen kann: Nginx-Architektur

Kurz gesagt, Nginx verfügt über einen Master-Prozess und eine Reihe von Worker-Prozessen. Es verfügt auch über Hilfsprozesse wie Cache Loader und Cache Manager. Der Master und der Worker-Prozess erledigen die ganze schwere Arbeit.

  • Master-Prozess: Verwaltet die Konfiguration und erzeugt untergeordnete Prozesse.
  • Cache Loader/Manager: Erledigt das Laden und Bereinigen des Caches mit minimalen Ressourcen.
  • Arbeitsprozesse: Verwalten Sie Verbindungen, Festplatten-E/A und Upstream-Kommunikation und laufen Sie nicht blockierend und unabhängig.

Arbeitsprozesse verarbeiten mehrere Verbindungen nicht blockierend und reduzieren so Kontextwechsel. Sie sind Single-Threaded, laufen unabhängig und nutzen gemeinsam genutzten Speicher für gemeinsam genutzte Ressourcen wie Cache und Sitzungsdaten. Diese Architektur hilft Nginx, die Anzahl der Kontextwechsel zu reduzieren und die Geschwindigkeit schneller zu erhöhen als eine blockierende Multiprozessarchitektur.

Inspiriert davon werden wir das gleiche Konzept von Master- und Worker-Prozessen verwenden und unseren eigenen ereignisgesteuerten Reverse-Proxy-Server implementieren, der Tausende von Verbindungen pro Worker-Prozess verarbeiten kann.

Projektarchitektur

Unsere Reverse-Proxy-Implementierung folgt diesen wichtigen Designprinzipien:

  1. Konfigurationsgesteuert: Das gesamte Proxy-Verhalten ist in einer YAML-Konfigurationsdatei definiert, sodass Routing-Regeln einfach geändert werden können.
  2. Typsicherheit: TypeScript- und Zod-Schemas gewährleisten Konfigurationsgültigkeit und Laufzeittypsicherheit.
  3. Skalierbarkeit: Das Node.js-Clustermodul ermöglicht die Nutzung mehrerer CPU-Kerne für eine bessere Leistung.
  4. Modularität: Klare Trennung der Belange mit unterschiedlichen Modulen für Konfiguration, Serverlogik und Schemavalidierung.

Projektstruktur

├── config.yaml           # Server configuration
├── src/
│   ├── config-schema.ts  # Configuration validation schemas
│   ├── config.ts         # Configuration parsing logic
│   ├── index.ts         # Application entry point
│   ├── server-schema.ts # Server message schemas
│   └── server.ts        # Core server implementation
└── tsconfig.json        # TypeScript configuration

Schlüsselkomponenten

  1. config.yaml: Definiert die Konfiguration des Servers, einschließlich Port, Arbeitsprozesse, Upstream-Server, Header und Routing-Regeln.
  2. config-schema.ts: Definiert Validierungsschemata mithilfe der Zod-Bibliothek, um sicherzustellen, dass die Konfigurationsstruktur korrekt ist.
  3. server-schema.ts: Gibt Nachrichtenformate an, die zwischen den Master- und Worker-Prozessen ausgetauscht werden.
  4. config.ts: Bietet Funktionen zum Parsen und Validieren der YAML-Konfigurationsdatei.
  5. server.ts: Implementiert die Reverse-Proxy-Server-Logik, einschließlich Cluster-Setup, HTTP-Verarbeitung und Anforderungsweiterleitung.
  6. index.ts: Dient als Einstiegspunkt, analysiert Befehlszeilenoptionen und initiiert den Server.

Konfigurationsmanagement

Das Konfigurationssystem verwendet YAML. So funktioniert es:

server:
    listen: 8080          # Port the server listens on.
    workers: 2            # Number of worker processes to handle requests.
    upstreams:            # Define upstream servers (backend targets).
        - id: jsonplaceholder
          url: jsonplaceholder.typicode.com
        - id: dummy
          url: dummyjson.com
    headers:              # Custom headers added to proxied requests.
        - key: x-forward-for
          value: $ip      # Adds the client IP to the forwarded request.
        - key: Authorization
          value: Bearer xyz  # Adds an authorization token to requests.
    rules:                # Define routing rules for incoming requests.
        - path: /test
          upstreams:
              - dummy     # Routes requests to "/test" to the "dummy" upstream.
        - path: /
          upstreams:
              - jsonplaceholder  # Routes all other requests to "jsonplaceholder".

Eingehende Anfragen werden anhand der Regeln bewertet. Basierend auf dem Pfad bestimmt der Reverse-Proxy, an welchen Upstream-Server die Anfrage weitergeleitet werden soll.

Konfigurationsvalidierung (config-schema.ts)

Wir verwenden Zod, um strenge Schemata für die Konfigurationsvalidierung zu definieren:

import { z } from "zod";

const upstreamSchema = z.object({
    id: z.string(),
    url: z.string(),
});

const headerSchema = z.object({
    key: z.string(),
    value: z.string(),
});

const ruleSchema = z.object({
    path: z.string(),
    upstreams: z.array(z.string()),
});

const serverSchema = z.object({
    listen: z.number(),
    workers: z.number().optional(),
    upstreams: z.array(upstreamSchema),
    headers: z.array(headerSchema).optional(),
    rules: z.array(ruleSchema),
});

export const rootConfigSchema = z.object({
    server: serverSchema,
});

export type ConfigSchemaType = z.infer<typeof rootConfigSchema>;

Parsen und Validieren von Konfigurationen (config.ts)

Das config.ts-Modul bietet Hilfsfunktionen zum Parsen und Validieren der Konfigurationsdatei.

import fs from "node:fs/promises";
import { parse } from "yaml";
import { rootConfigSchema } from "./config-schema";

export async function parseYAMLConfig(filepath: string) {
    const configFileContent = await fs.readFile(filepath, "utf8");
    const configParsed = parse(configFileContent);
    return JSON.stringify(configParsed);
}

export async function validateConfig(config: string) {
    const validatedConfig = await rootConfigSchema.parseAsync(
        JSON.parse(config)
    );
    return validatedConfig;
}

Reverse-Proxy-Server-Logik (server.ts)

Der Server nutzt das Node.js-Clustermodul für Skalierbarkeit und das http-Modul für die Bearbeitung von Anfragen. Der Master-Prozess verteilt Anfragen an Worker-Prozesse, die diese an Upstream-Server weiterleiten. Lassen Sie uns die Datei server.ts im Detail untersuchen, die die Kernlogik unseres Reverse-Proxy-Servers enthält. Wir werden jede Komponente aufschlüsseln und verstehen, wie sie zusammenarbeitet, um einen skalierbaren Proxyserver zu erstellen.

Die Serverimplementierung folgt einer Master-Worker-Architektur unter Verwendung des Node.js-Cluster-Moduls. Dieses Design ermöglicht uns:

  • Nutzen Sie mehrere CPU-Kerne
  • Anfragen gleichzeitig bearbeiten
  • Aufrechterhaltung einer hohen Verfügbarkeit
  • Anfrageverarbeitung isolieren
  1. Masterprozess:

    • Erstellt Arbeitsprozesse
    • Verteilt eingehende Anfragen an alle Mitarbeiter
    • Verwaltet den Worker-Pool
    • Behandelt Worker-Abstürze und Neustarts
  2. Arbeiterprozesse:

    • Einzelne HTTP-Anfragen bearbeiten
    • Anfragen anhand von Routing-Regeln abgleichen
    • Anfragen an Upstream-Server weiterleiten
    • Verarbeiten Sie Antworten und senden Sie sie an Kunden zurück

Master-Prozess-Setup

├── config.yaml           # Server configuration
├── src/
│   ├── config-schema.ts  # Configuration validation schemas
│   ├── config.ts         # Configuration parsing logic
│   ├── index.ts         # Application entry point
│   ├── server-schema.ts # Server message schemas
│   └── server.ts        # Core server implementation
└── tsconfig.json        # TypeScript configuration

Der Masterprozess erstellt einen Pool von Workern und übergibt die Konfiguration über Umgebungsvariablen an jeden Worker. Dadurch wird sichergestellt, dass alle Mitarbeiter Zugriff auf dieselbe Konfiguration haben.

Verteilung anfordern

server:
    listen: 8080          # Port the server listens on.
    workers: 2            # Number of worker processes to handle requests.
    upstreams:            # Define upstream servers (backend targets).
        - id: jsonplaceholder
          url: jsonplaceholder.typicode.com
        - id: dummy
          url: dummyjson.com
    headers:              # Custom headers added to proxied requests.
        - key: x-forward-for
          value: $ip      # Adds the client IP to the forwarded request.
        - key: Authorization
          value: Bearer xyz  # Adds an authorization token to requests.
    rules:                # Define routing rules for incoming requests.
        - path: /test
          upstreams:
              - dummy     # Routes requests to "/test" to the "dummy" upstream.
        - path: /
          upstreams:
              - jsonplaceholder  # Routes all other requests to "jsonplaceholder".

Der Masterprozess verwendet eine einfache Zufallsverteilungsstrategie, um Anfragen den Arbeitern zuzuweisen. Dieser Ansatz ist zwar nicht so ausgefeilt wie Round-Robin- oder Least-Connections-Algorithmen, bietet aber für die meisten Anwendungsfälle eine angemessene Lastverteilung. Die Anforderungsverteilungslogik:

  • Wählt zufällig einen Arbeiter aus dem Pool aus
  • Sorgt für eine ausgewogene Arbeitsbelastung aller Mitarbeiter
  • Behandelt Randfälle, bei denen Mitarbeiter möglicherweise nicht verfügbar sind

Worker-Prozessanforderungslogik

Jeder Worker lauscht auf Nachrichten, gleicht Anfragen mit Routing-Regeln ab und leitet sie an den entsprechenden Upstream-Server weiter.

import { z } from "zod";

const upstreamSchema = z.object({
    id: z.string(),
    url: z.string(),
});

const headerSchema = z.object({
    key: z.string(),
    value: z.string(),
});

const ruleSchema = z.object({
    path: z.string(),
    upstreams: z.array(z.string()),
});

const serverSchema = z.object({
    listen: z.number(),
    workers: z.number().optional(),
    upstreams: z.array(upstreamSchema),
    headers: z.array(headerSchema).optional(),
    rules: z.array(ruleSchema),
});

export const rootConfigSchema = z.object({
    server: serverSchema,
});

export type ConfigSchemaType = z.infer<typeof rootConfigSchema>;

Der Masterprozess kommuniziert mit den Arbeitern, indem er mithilfe von Node.js IPC (Inter-Process Communication) eine standardisierte Nachrichtennutzlast einschließlich aller erforderlichen Anforderungsinformationen erstellt und die Nachrichtenstruktur mithilfe von Zod-Schemas validiert.

Mitarbeiter kümmern sich um die eigentliche Anforderungsverarbeitung und Weiterleitung. Jeder Arbeiter:

  • Lädt seine Konfiguration aus Umgebungsvariablen
  • Validiert die Konfiguration mithilfe von Zod-Schemas
  • Behält eine eigene Kopie der Konfiguration bei

Mitarbeiter wählen Upstream-Server aus durch:

  • Suchen der entsprechenden Upstream-ID aus der Regel
  • Auffinden der Upstream-Serverkonfiguration
  • Überprüfung, dass der Upstream-Server vorhanden ist

Der Mechanismus zur Anforderungsweiterleitung:

  • Erstellt eine neue HTTP-Anfrage an den Upstream-Server
  • Streamet die Antwortdaten
  • Aggregiert den Antworttext
  • Sendet die Antwort zurück an den Masterprozess

Ausführen des Servers

Um den Server auszuführen, befolgen Sie diese Schritte:

  1. Erstellen Sie das Projekt:
import fs from "node:fs/promises";
import { parse } from "yaml";
import { rootConfigSchema } from "./config-schema";

export async function parseYAMLConfig(filepath: string) {
    const configFileContent = await fs.readFile(filepath, "utf8");
    const configParsed = parse(configFileContent);
    return JSON.stringify(configParsed);
}

export async function validateConfig(config: string) {
    const validatedConfig = await rootConfigSchema.parseAsync(
        JSON.parse(config)
    );
    return validatedConfig;
}
  1. Server starten:
if (cluster.isPrimary) {
    console.log("Master Process is up ?");
    for (let i = 0; i < workerCount; i++) {
        const w = cluster.fork({ config: JSON.stringify(config) });
        WORKER_POOL.push(w);
        console.log(Master Process: Worker Node spinned: ${i});
    }

    const server = http.createServer((req, res) => {
        const index = Math.floor(Math.random() * WORKER_POOL.length);
        const worker = WORKER_POOL.at(index);

        if (!worker) throw new Error("Worker not found.");

        const payload: WorkerMessageSchemaType = {
            requestType: "HTTP",
            headers: req.headers,
            body: null,
            url: ${req.url},
        };
        worker.send(JSON.stringify(payload));

        worker.once("message", async (workerReply: string) => {
            const reply = await workerMessageReplySchema.parseAsync(
                JSON.parse(workerReply)
            );

            if (reply.errorCode) {
                res.writeHead(parseInt(reply.errorCode));
                res.end(reply.error);
            } else {
                res.writeHead(200);
                res.end(reply.data);
            }
        });
    });

    server.listen(port, () => {
        console.log(Reverse Proxy listening on port: ${port});
    });
}
  1. Entwicklungsmodus:
const server = http.createServer(function (req, res) {
    const index = Math.floor(Math.random() * WORKER_POOL.length);
    const worker = WORKER_POOL.at(index);

    const payload: WorkerMessageSchemaType = {
        requestType: "HTTP",
        headers: req.headers,
        body: null,
        url: ${req.url},
    };
    worker.send(JSON.stringify(payload));
});

Building a Scalable Reverse Proxy Server like Nginx with Node.js and TypeScript

Im obigen Screenshot können wir sehen, dass 1 Master-Knoten und 2 Worker-Prozesse ausgeführt werden. Unser Reverse-Proxy-Server überwacht Port 8080.
In der Datei config.yaml beschreiben wir zwei Upstream-Server, nämlich: jsonplaceholder und dummy. Wenn wir möchten, dass alle an unseren Server eingehenden Anfragen an jsonplaceholder weitergeleitet werden, geben wir die Regel wie folgt ein:

├── config.yaml           # Server configuration
├── src/
│   ├── config-schema.ts  # Configuration validation schemas
│   ├── config.ts         # Configuration parsing logic
│   ├── index.ts         # Application entry point
│   ├── server-schema.ts # Server message schemas
│   └── server.ts        # Core server implementation
└── tsconfig.json        # TypeScript configuration

Wenn wir möchten, dass unsere Anfrage an den Endpunkt /test an unseren Dummy-Upstream-Server weitergeleitet wird, geben wir die Regel wie folgt ein:

server:
    listen: 8080          # Port the server listens on.
    workers: 2            # Number of worker processes to handle requests.
    upstreams:            # Define upstream servers (backend targets).
        - id: jsonplaceholder
          url: jsonplaceholder.typicode.com
        - id: dummy
          url: dummyjson.com
    headers:              # Custom headers added to proxied requests.
        - key: x-forward-for
          value: $ip      # Adds the client IP to the forwarded request.
        - key: Authorization
          value: Bearer xyz  # Adds an authorization token to requests.
    rules:                # Define routing rules for incoming requests.
        - path: /test
          upstreams:
              - dummy     # Routes requests to "/test" to the "dummy" upstream.
        - path: /
          upstreams:
              - jsonplaceholder  # Routes all other requests to "jsonplaceholder".

Lass uns das testen!

Building a Scalable Reverse Proxy Server like Nginx with Node.js and TypeScript

Wow, das ist cool! Wir navigieren zu „localhost:8080“, sehen aber als Antwort, dass wir die Homepage für „jsonplaceholder.typicode.com“ erhalten haben. Der Endbenutzer weiß nicht einmal, dass wir eine Antwort von einem separaten Server sehen. Deshalb sind Reverse-Proxy-Server wichtig. Wenn auf mehreren Servern derselbe Code ausgeführt wird und nicht alle Ports den Endbenutzern zugänglich gemacht werden sollen, verwenden Sie einen Reverse-Proxy als Abstraktionsschicht. Benutzer greifen auf den Reverse-Proxy-Server zu, einen sehr robusten und schnellen Server, und dieser bestimmt, an welchen Server die Anfrage weitergeleitet werden soll.

Lassen Sie uns jetzt „localhost:8080/todos“ aufrufen und sehen, was passiert.

Building a Scalable Reverse Proxy Server like Nginx with Node.js and TypeScript

Unsere Anfrage wurde erneut rückwärts an den JSONPlaceholder-Server weitergeleitet und erhielt eine JSON-Antwort von der aufgelösten URL: jsonplaceholder.typicode.com/todos.

Kommunikationsfluss

Lassen Sie uns den gesamten Anfrageablauf visualisieren:

Kunde sendet Anfrage → Masterprozess
Master-Prozess → Ausgewählter Arbeiter
Arbeiter → Upstream-Server
Upstream-Server → Worker
Arbeiter → Masterprozess
Masterprozess → Kunde

Leistungsüberlegungen

Die Multiprozessarchitektur bietet mehrere Leistungsvorteile:

  1. CPU-Auslastung: Arbeitsprozesse können auf verschiedenen CPU-Kernen ausgeführt werden und nutzen dabei verfügbare Hardwareressourcen.
  2. Prozessisolierung: Ein Absturz bei einem Mitarbeiter hat keine Auswirkungen auf andere, was die Zuverlässigkeit erhöht.
  3. Lastverteilung: Durch die zufällige Verteilung von Anfragen wird verhindert, dass ein einzelner Mitarbeiter überlastet wird.

Zukünftige Verbesserungen

Die aktuelle Implementierung ist zwar funktionsfähig, könnte jedoch um Folgendes erweitert werden:

  1. Bessere Lastverteilung: Implementieren Sie anspruchsvollere Algorithmen wie Round-Robin oder Least-Connections.
  2. Gesundheitsprüfungen: Fügen Sie regelmäßige Gesundheitsprüfungen für Upstream-Server hinzu.
  3. Caching: Implementieren Sie Antwort-Caching, um die Belastung des Upstream-Servers zu reduzieren.
  4. Metriken: Fügen Sie Metriken im Prometheus-Stil zur Überwachung hinzu.
  5. WebSocket-Unterstützung: Erweitern Sie den Proxy, um WebSocket-Verbindungen zu verarbeiten.
  6. HTTPS-Unterstützung: Fügen Sie SSL/TLS-Terminierungsfunktionen hinzu.

Zusammenfassung

Das Erstellen eines Reverse-Proxy-Servers von Grund auf mag auf den ersten Blick einschüchternd wirken, aber wie wir herausgefunden haben, ist es eine lohnende Erfahrung. Durch die Kombination von Node.js-Clustern, TypeScript und YAML-basiertem Konfigurationsmanagement haben wir ein skalierbares und effizientes System geschaffen, das von Nginx inspiriert ist.

Es gibt noch Raum für Verbesserungen dieser Implementierung – besserer Lastausgleich, Caching oder WebSocket-Unterstützung sind nur einige Ideen, die es zu erkunden gilt. Aber das aktuelle Design bildet eine solide Grundlage für Experimente und eine weitere Skalierung. Wenn Sie mitgemacht haben, sind Sie jetzt in der Lage, tiefer in Reverse-Proxys einzutauchen oder sogar mit der Entwicklung maßgeschneiderter Lösungen zu beginnen, die auf Ihre Bedürfnisse zugeschnitten sind.

Wenn Sie sich vernetzen oder mehr von meiner Arbeit sehen möchten, schauen Sie sich meinen GitHub und LinkedIn an.
Das Repository für dieses Projekt finden Sie hier.

Ich würde gerne Ihre Gedanken, Ihr Feedback oder Ihre Verbesserungsvorschläge hören. Vielen Dank fürs Lesen und viel Spaß beim Codieren! ?

Das obige ist der detaillierte Inhalt vonErstellen eines skalierbaren Reverse-Proxy-Servers wie Nginx mit Node.js und TypeScript. Für weitere Informationen folgen Sie bitte anderen verwandten Artikeln auf der PHP chinesischen Website!

Stellungnahme:
Der Inhalt dieses Artikels wird freiwillig von Internetnutzern beigesteuert und das Urheberrecht liegt beim ursprünglichen Autor. Diese Website übernimmt keine entsprechende rechtliche Verantwortung. Wenn Sie Inhalte finden, bei denen der Verdacht eines Plagiats oder einer Rechtsverletzung besteht, wenden Sie sich bitte an admin@php.cn