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.
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.
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.
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.
Notre mise en œuvre du proxy inverse suit ces principes de conception clés :
├── 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 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.
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>;
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; }
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 :
Processus maître :
Processus de travail :
├── 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.
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 :
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 :
Les travailleurs sélectionnent les serveurs en amont par :
Le mécanisme de transfert des requêtes :
Pour exécuter le serveur, suivez ces étapes :
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; }
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}); }); }
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)); });
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 !
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.
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.
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
L'architecture multi-processus offre plusieurs avantages en termes de performances :
Bien que fonctionnelle, la mise en œuvre actuelle pourrait être améliorée avec :
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!