Maison >interface Web >js tutoriel >Créer un serveur proxy inverse évolutif comme Nginx avec Node.js et TypeScript

Créer un serveur proxy inverse évolutif comme Nginx avec Node.js et TypeScript

Susan Sarandon
Susan Sarandonoriginal
2025-01-05 08:48:41617parcourir

L'inspiration

Dans l'architecture de microservices actuelle, les proxys inverses jouent un rôle crucial dans la gestion et le routage des demandes entrantes vers divers services backend.

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

Un proxy inverse se place devant les serveurs web d'une application et intercepte les requêtes provenant des machines clientes. Cela présente de nombreux avantages tels que l'équilibrage de charge, les adresses IP des serveurs d'origine cachées conduisant à une meilleure sécurité, la mise en cache, la limitation de débit, etc.

Dans une architecture distribuée et microservice, un point d'entrée unique est nécessaire. Les serveurs proxy inverse comme Nginx sont utiles dans de tels scénarios. Si plusieurs instances de notre serveur sont en cours d'exécution, gérer et garantir un routage efficace des requêtes devient délicat. Un proxy inverse comme Nginx est une solution parfaite dans ce cas. Nous pouvons pointer notre domaine vers l'adresse IP du serveur Nginx et le Nginx acheminera la requête entrante selon la configuration vers l'une des instances tout en s'occupant de la charge gérée par chacune.

Comment Nginx fait-il si bien ?

Je recommanderai de lire cet article de Nginx qui explique en détail comment Nginx est capable de prendre en charge une grande échelle de requêtes avec une fiabilité et une rapidité exceptionnelles : Architecture Nginx

En bref, Nginx a un processus maître et un tas de processus de travail. Il dispose également de processus d'assistance tels que Cache Loader et Cache Manager. Le maître et le processus de travail font tout le gros travail.

  • Processus principal : gère la configuration et génère des processus enfants.
  • Cache Loader/Manager : gérez le chargement et l'élagage du cache avec un minimum de ressources.
  • Processus de travail : gérez les connexions, les E/S de disque et la communication en amont, en s'exécutant de manière non bloquante et indépendante.

Les processus de travail gèrent plusieurs connexions de manière non bloquante, réduisant ainsi les changements de contexte. Ils sont monothread, s'exécutent indépendamment et utilisent la mémoire partagée pour les ressources partagées telles que le cache et les données de session. Cette architecture aide Nginx à réduire le nombre de changements de contexte et à augmenter la vitesse plus rapidement qu'une architecture multi-processus bloquante.

En nous inspirant de cela, nous utiliserons le même concept de processus maître et de travail et implémenterons notre propre serveur proxy inverse basé sur les événements qui sera capable de gérer des milliers de connexions par processus de travail.

Architecture du projet

Notre mise en œuvre du proxy inverse suit ces principes de conception clés :

  1. Basé sur la configuration : tout le comportement du proxy est défini dans un fichier de configuration YAML, ce qui facilite la modification des règles de routage.
  2. Sécurité des types : les schémas TypeScript et Zod garantissent la validité de la configuration et la sécurité des types d'exécution.
  3. Évolutivité : le module de cluster Node.js permet d'utiliser plusieurs cœurs de processeur pour de meilleures performances.
  4. Modularité : séparation claire des préoccupations avec des modules distincts pour la configuration, la logique du serveur et la validation du schéma.

Structure du projet

├── 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

Composants clés

  1. config.yaml : définit la configuration du serveur, y compris le port, les processus de travail, les serveurs en amont, les en-têtes et les règles de routage.
  2. config-schema.ts : définit les schémas de validation à l'aide de la bibliothèque Zod pour garantir que la structure de configuration est correcte.
  3. server-schema.ts : Spécifie les formats de message échangés entre les processus maître et de travail.
  4. config.ts : fournit des fonctions d'analyse et de validation du fichier de configuration YAML.
  5. server.ts : implémente la logique du serveur proxy inverse, y compris la configuration du cluster, la gestion HTTP et le transfert des demandes.
  6. index.ts : sert de point d'entrée, analyse les options de ligne de commande et lance le serveur.

Gestion des configurations

Le système de configuration utilise YAML. Voici comment cela fonctionne :

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".

Les demandes entrantes sont évaluées par rapport aux règles. En fonction du chemin, le proxy inverse détermine à quel serveur en amont transmettre la demande.

Validation de la configuration (config-schema.ts)

Nous utilisons Zod pour définir des schémas stricts pour la validation de la configuration :

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>;

Analyse et validation des configurations (config.ts)

Le module config.ts fournit des fonctions utilitaires pour analyser et valider le fichier de configuration.

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;
}

Logique du serveur proxy inverse (server.ts)

Le serveur utilise le module de cluster Node.js pour l'évolutivité et le module http pour gérer les requêtes. Le processus maître distribue les requêtes aux processus de travail, qui les transmettent aux serveurs en amont. Explorons en détail le fichier server.ts, qui contient la logique de base de notre serveur proxy inverse. Nous décomposerons chaque composant et comprendrons comment ils fonctionnent ensemble pour créer un serveur proxy évolutif.

L'implémentation du serveur suit une architecture maître-travailleur utilisant le module cluster de Node.js. Cette conception nous permet de :

  • Utiliser plusieurs cœurs de processeur
  • Gérer les demandes simultanément
  • Maintenir une haute disponibilité
  • Isoler le traitement des demandes
  1. Processus maître :

    • Crée des processus de travail
    • Distribue les demandes entrantes entre les travailleurs
    • Gère le pool de travailleurs
    • Gère les plantages et les redémarrages des travailleurs
  2. Processus de travail :

    • Gérer les requêtes HTTP individuelles
    • Faire correspondre les requêtes aux règles de routage
    • Transférer les requêtes vers les serveurs en amont
    • Traiter les réponses et les renvoyer aux clients

Configuration du processus principal

├── 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

Le processus maître crée un pool de travailleurs et transmet la configuration à chaque travailleur via des variables d'environnement. Cela garantit que tous les travailleurs ont accès à la même configuration.

Demander la distribution

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".

Le processus maître utilise une stratégie de distribution aléatoire simple pour attribuer les demandes aux travailleurs. Bien qu'elle ne soit pas aussi sophistiquée que les algorithmes à tour de rôle ou les algorithmes de moindre connexion, cette approche fournit une répartition de charge décente pour la plupart des cas d'utilisation. La logique de distribution des requêtes :

  • Sélectionne au hasard un travailleur dans le pool
  • Crée une charge de travail équilibrée entre les travailleurs
  • Gère les cas extrêmes où les travailleurs peuvent être indisponibles

Logique de demande de processus de travail

Chaque travailleur écoute les messages, compare les demandes aux règles de routage et les transmet au serveur en amont approprié.

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>;

Le processus maître communique avec les travailleurs en construisant une charge utile de message standardisée, comprenant toutes les informations de demande nécessaires, à l'aide de Node.js IPC (Inter-Process Communication) et en validant la structure du message à l'aide des schémas Zod.

Les travailleurs gèrent le traitement réel des demandes et le proxy. Chaque travailleur :

  • Charge sa configuration à partir des variables d'environnement
  • Valide la configuration à l'aide des schémas Zod
  • Maintient sa propre copie de la configuration

Les travailleurs sélectionnent les serveurs en amont par :

  • Trouver l'ID en amont approprié à partir de la règle
  • Localisation de la configuration du serveur en amont
  • La validation du serveur amont existe

Le mécanisme de transfert des requêtes :

  • Crée une nouvelle requête HTTP vers le serveur en amont
  • Diffuse les données de réponse
  • Agrége le corps de la réponse
  • Renvoie la réponse au processus maître

Exécution du serveur

Pour exécuter le serveur, suivez ces étapes :

  1. Construire le projet :
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. Démarrez le serveur :
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. Mode développement :
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

Dans la capture d'écran ci-dessus, nous pouvons voir qu'il y a 1 nœud maître et 2 processus de travail en cours d'exécution. Notre serveur proxy inverse écoute sur le port 8080.
Dans le fichier config.yaml, nous décrivons deux serveurs en amont à savoir : jsonplaceholder et dummy. Si nous voulons que toutes les requêtes arrivant sur notre serveur soient acheminées vers jsonplaceholder, nous mettons la règle comme suit :

├── 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

De même, si nous voulons que notre requête au point de terminaison /test soit acheminée vers notre serveur factice en amont, nous mettons la règle comme suit :

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".

Testons ça !

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

Wow, c'est cool ! Nous naviguons vers localhost:8080 mais en réponse, nous pouvons voir que nous avons reçu la page d'accueil de jsonplaceholder.typicode.com. L'utilisateur final ne sait même pas que nous voyons une réponse provenant d'un serveur distinct. C'est pourquoi les serveurs Reverse Proxy sont importants. Si nous avons plusieurs serveurs exécutant le même code et que nous ne souhaitons pas exposer tous leurs ports aux utilisateurs finaux, utilisez un proxy inverse comme couche d'abstraction. Les utilisateurs accéderont au serveur proxy inverse, un serveur très robuste et rapide, et il déterminera vers quel serveur acheminer la demande.

Appuyons maintenant sur localhost:8080/todos et voyons ce qui se passe.

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

Notre demande a de nouveau été transmise par proxy inverse au serveur jsonplaceholder et a reçu une réponse JSON à partir de l'URL résolue : jsonplaceholder.typicode.com/todos.

Flux de communication

Visualisons le flux complet des demandes :

Le client envoie la demande → Processus principal
Processus principal → Travailleur sélectionné
Travailleur → Serveur en amont
Serveur en amont → Travailleur
Travailleur → Processus principal
Processus principal → Client

Considérations relatives aux performances

L'architecture multi-processus offre plusieurs avantages en termes de performances :

  1. Utilisation du processeur : les processus de travail peuvent s'exécuter sur différents cœurs de processeur, en utilisant les ressources matérielles disponibles.
  2. Isolement des processus : un accident chez un travailleur n'affecte pas les autres, ce qui améliore la fiabilité.
  3. Répartition de la charge : la répartition aléatoire des demandes permet d'éviter qu'un seul travailleur ne soit submergé.

Améliorations futures

Bien que fonctionnelle, la mise en œuvre actuelle pourrait être améliorée avec :

  1. Meilleur équilibrage de charge : implémentez des algorithmes plus sophistiqués comme le round-robin ou les moindres connexions.
  2. Vérifications de santé : ajoutez des vérifications de santé périodiques pour les serveurs en amont.
  3. Mise en cache : implémentez la mise en cache des réponses pour réduire la charge du serveur en amont.
  4. Métriques : ajoutez des métriques de style Prometheus pour la surveillance.
  5. Support WebSocket : étendez le proxy pour gérer les connexions WebSocket.
  6. Prise en charge HTTPS : ajoutez des fonctionnalités de terminaison SSL/TLS.

Conclusion

Construire un serveur proxy inverse à partir de zéro peut sembler intimidant au début, mais comme nous l'avons exploré, c'est une expérience enrichissante. En combinant les clusters Node.js, TypeScript et la gestion de configuration basée sur YAML, nous avons créé un système évolutif et efficace inspiré de Nginx.

Il est encore possible d'améliorer cette implémentation : un meilleur équilibrage de charge, une meilleure mise en cache ou une meilleure prise en charge de WebSocket ne sont que quelques idées à explorer. Mais la conception actuelle constitue une base solide pour expérimenter et évoluer davantage. Si vous avez suivi, vous êtes désormais équipé pour approfondir vos connaissances sur les proxys inverses ou même commencer à créer des solutions personnalisées adaptées à vos besoins.

Si vous souhaitez vous connecter ou voir plus de mon travail, consultez mon GitHub, LinkedIn.
Le référentiel de ce projet peut être trouvé ici.

J'aimerais entendre vos réflexions, commentaires ou idées d'amélioration. Merci d'avoir lu et bon codage ! ?

Ce qui précède est le contenu détaillé de. pour plus d'informations, suivez d'autres articles connexes sur le site Web de PHP en chinois!

Déclaration:
Le contenu de cet article est volontairement contribué par les internautes et les droits d'auteur appartiennent à l'auteur original. Ce site n'assume aucune responsabilité légale correspondante. Si vous trouvez un contenu suspecté de plagiat ou de contrefaçon, veuillez contacter admin@php.cn