Bienvenue dans l’univers de PandApache3, votre serveur web de choix. Dans cet article, nous allons explorer le fonctionnement interne de ce serveur, en détaillant comment il gère les connexions, traite les requêtes HTTP et génère des réponses. Que vous soyez développeur ou simplement curieux, vous découvrirez les étapes clés qui rendent tout cela possible.
Dans le précédent article, nous avons vu comment le service PandApache3 démarre, de l’initialisation du logger jusqu’au démarrage du TcpListener. Maintenant, il est temps de plonger dans le cœur du système pour comprendre ce qui se passe lorsqu'une connexion est reçue. Prêt à découvrir les coulisses de PandApache3 ? Allons-y !
Comment PandApache3 Gère les Connexions Entrantes
Après la méthode StartServerAsync
, nous avons une seconde méthode qui est appelée : RunServerAsync
.
private static async Task RunServerAsync(ConnectionManager connectionManager)
{
TcpListener listener = connectionManager.Listener;
while (true)
{
if (listener.Pending())
{
ISocketWrapper client = new SocketWrapper(listener.AcceptSocket());
await connectionManager.AcceptConnectionsAsync(client);
}
}
}
Cette méthode est courte et voici ce qu’elle fait : Souvenez-vous que notre ConnectionManager
dispose d’un TcpListener
. Nous allons donc vérifier si une connexion est en attente sur ce listener. Si oui, nous acceptons la connexion afin que le ConnectionManager
la traite.
Étant donné que nous devons toujours vérifier si une nouvelle connexion est en attente, la méthode tourne en continu dans une boucle infinie.
Quand une connexion a été effectuée avec notre serveur, nous avons ce que nous appelons un client ou un socket.
Entre nous
A quoi sert le
ISocketWrapper
? L'encapsulation du socket dansISocketWrapper
abstrait les détails d'implémentation du socket brut, facilitant ainsi la gestion des connexions dans leConnectionManager
. Cela permet également une meilleure testabilité et maintenabilité du code.
Notre connexion avec le client va donc être gérée dans la fonction AcceptConnectionsAsync
.
public async Task AcceptConnectionsAsync(ISocketWrapper client)
{
if (_clients.Count < ServerConfiguration.Instance.MaxAllowedConnections)
{
Guid clientId = Guid.NewGuid();
_clients.TryAdd(clientId, client);
Logger.LogInfo($"Client connected");
Task.Run(() => HandleClientAsync(client, clientId));
}
else if (_clientsRejected.Count < ServerConfiguration.Instance.MaxRejectedConnections)
{
Guid clientId = Guid.NewGuid();
_clientsRejected.TryAdd(clientId, client);
Logger.LogWarning("Too many connections - rejecting with HTTP 500");
Task.Run(() => HandleClientRejectAsync(client, clientId));
}
else
{
Logger.LogError("Too many connection");
client.Dispose();
return;
}
}
Dans une situation normale, la connexion du client sera traitée dans une nouvelle tâche (un nouveau thread) afin de répondre à sa demande, ce qui permettra ainsi au thread actuel d’accepter une nouvelle connexion, et ainsi de suite.
En cas de surcharge du serveur, la connexion du client, bien que acceptée, renverra directement un message d’erreur (toujours dans un nouveau thread). Sa requête recevra donc une réponse, mais pas celle attendue.
Finalement, dans une situation de surcharge extrême, il est même possible que le serveur ferme simplement la connexion, sans renvoyer quoi que ce soit.
Entre nous
Comment décide-t-on si le serveur est surchargé ou non et donc si on répond correctement au client ? Imaginons que chaque connexion à notre serveur, et donc chaque thread créé pour réaliser sa tâche, prenne 2 Mo de mémoire, et que mon serveur dispose de 512 Mo (chiffre pris à titre d’exemple pour la démonstration). Je sais alors que mon serveur ne pourra pas supporter plus de 256 connexions, car la 257ème n’aura plus de ressources pour fonctionner. Cela peut mettre en danger tout mon service.
Analyse des Requêtes
Maintenant que nous avons accepté la requête et décidé de la traiter, il est temps de se souvenir de ce qui avait été expliqué dans le précédent article. Chaque gestionnaire de connexion dispose d’un pipeline composé de plusieurs middlewares qui vont permettre de traiter chaque requête.
Si vous vous souvenez bien, un des paramètres utilisés par chaque middleware était un contexte HTTP (HttpContext
). Voici l’interface IMiddleware
pour vous rafraîchir la mémoire :
public interface IMiddleware
{
Task InvokeAsync(HttpContext context);
}
HttpContext
est une classe assez simple qui se compose d’un objet Request
et HttpResponse
:
public class HttpContext
{
public Request Request { get; set; }
public HttpResponse Response { get; set; }
public HttpContext(Request request, HttpResponse response)
{
Request = request;
Response = response;
}
}
Notre nouveau thread, qui s’occupe de traiter la requête, va commencer en exécutant la fonction HandleClientAsync
:
private async Task HandleClientAsync(ISocketWrapper client, Guid clientId)
{
Request request = await ConnectionUtils.ParseRequestAsync(client);
if (request == null)
{
return;
}
HttpContext context = new HttpContext(request, null);
await _pipeline(context);
await ConnectionUtils.SendResponseAsync(client, context.Response);
_clients.TryRemove(clientId, out client);
client.Dispose();
Logger.LogInfo($"Client closed");
}
Dans un premier temps, la requête HTTP reçue par le serveur est analysée pour obtenir un objet Request
. On parle ici de parsing, c’est-à-dire qu’on récupère la première ligne, le chemin, le verbe HTTP, ce qui se trouve dans les en-têtes, les paramètres, le body… bref, tous les éléments qui composent une requête HTTP.
La réponse n’étant pas encore connue, elle est pour le moment null
dans le HttpContext
. Ensuite, le premier middleware du pipeline est exécuté. Dans notre cas, il n’y a vraiment qu’un middleware qui va faire quelque chose : le RoutingMiddleware
. Le premier LoggingMiddleware
sert juste à logger la requête entrante, et le troisième TerminalMiddleware
indique que nous sommes arrivés au dernier pipeline.
Le Cœur du Traitement des Requêtes
Voici la fonction de RoutingMiddleware
exécutée sur chaque requête :
public async Task InvokeAsync(HttpContext context)
{
Logger.LogDebug("Router Middleware");
if (context.Request.Verb.ToUpper().Equals("GET"))
{
Request request = context.Request;
string mainDirectory = ServerConfiguration.Instance.RootDirectory;
string filePath = Path.Combine(mainDirectory, Utils.GetFilePath(request.Path));
if (_FileManager.Exists(filePath))
{
string fileExtension = Path.GetExtension(filePath).Substring(1).ToLowerInvariant();
string mimeType = fileExtension switch
{
// Images
"jpg" or "jpeg" => "image/jpeg",
"png" => "image/png",
"gif" => "image/gif",
"svg" => "image/svg+xml",
"bmp" => "image/bmp",
"webp" => "image/webp",
"ico" => "image/x-icon",
// Documents textuels
"txt" => "text/plain",
"html" or "htm" => "text/html",
"css" => "text/css",
"js" => "application/javascript",
"json" => "application/json",
"xml" => "text/xml",
// Cas par défaut si l'extension n'est pas reconnue
_ => null
};
}
if (mimeType != null)
{
byte[] data = await File.ReadAllBytesAsync(filePath);
HttpResponse httpResponse = new HttpResponse(200)
{
Body = new MemoryStream(data)
};
httpResponse.AddHeader("Content-Type", mimeType);
httpResponse.AddHeader("Content-Length", httpResponse.Body.Length.ToString());
context.Response = httpResponse;
}
else
{
context.Response = new HttpResponse(404);
}
}
else
{
context.Response = new HttpResponse(404);
}
await _next(context);
}
Commençons par la première condition. Actuellement, nous souhaitons que notre serveur web ne puisse renvoyer que des ressources statiques, comme des fichiers HTML. Le seul verbe HTTP requis pour cela est le GET
. Donc, chaque requête utilisant un autre verbe HTTP doit renvoyer une erreur 404.
En revanche, si la requête est un GET
, cela signifie que le client souhaite obtenir une ressource présente sur notre serveur. Cette ressource est le chemin indiqué dans notre requête.
Par exemple, si j’envoie une requête GET
sur l’URL http://pandapache.com/index.html, mon chemin est ici index.html, et c’est donc cette ressource que mon serveur web doit trouver et renvoyer.
Mais où exactement chercher index.html ? PandApache3, comme la plupart des serveurs web, a ce qu’on appelle un dossier racine (root directory). Si sur votre ordinateur votre dossier racine est C:/
sur Windows et /
sur Linux, votre serveur web définit ce dernier par rapport à ses paramètres. Par défaut, le dossier racine de PandApache3 est C:/PandApache3/www/
sur Windows et /etc/PandApache3/www
sur Linux.
Entre nous
PandApache est écrit en .NET Core. Même s’il s’agit de C#, le code peut aussi bien tourner sur Windows que sous Linux sans aucune modification. Au démarrage, le système d’exploitation est détecté par PandApache3, et les paramètres par défaut concernant les chemins (configuration, logs, root directory) s’adaptent à la plateforme.
Nous avons le root directory et la ressource demandée. Si elle existe, nous pouvons donc la renvoyer :string mainDirectory = ServerConfiguration.Instance.RootDirectory; string filePath = Path.Combine(mainDirectory, Utils.GetFilePath(request.Path)); if (_FileManager.Exists(filePath)) { ... }
Construire une Réponse Correcte
Voyons maintenant comment une réponse correcte peut être générée. En plus de savoir si notre ressource existe, il faut aussi déterminer son type MIME. Rien de plus simple, le type MIME est déterminé par rapport à l’extension du fichier. Ici, nous allons seulement nous concentrer sur le type MIME des fichiers les plus courants pour les ressources web statiques : HTML, CSS, JavaScript, et les différents formats d’image.
Voici comment cela est fait dans le code :
Path.GetExtension(filePath).Substring(1).ToLowerInvariant();
string mimeType = fileExtension switch
{
// Images
"jpg" or "jpeg" => "image/jpeg",
"png" => "image/png",
"gif" => "image/gif",
"svg" => "image/svg+xml",
"bmp" => "image/bmp",
"webp" => "image/webp",
"ico" => "image/x-icon",
// Documents textuels
"txt" => "text/plain",
"html" or "htm" => "text/html",
"css" => "text/css",
"js" => "application/javascript",
"json" => "application/json",
"xml" => "text/xml",
// Cas par défaut si l'extension n'est pas reconnue
_ => null
};
Pour retourner notre ressource, notre réponse HTTP doit avoir les bons attributs :
byte[] data = await File.ReadAllBytesAsync(filePath);
HttpResponse httpResponse = new HttpResponse(200)
{
Body = new MemoryStream(data)
};
httpResponse.AddHeader("Content-Type", mimeType);
httpResponse.AddHeader("Content-Length", httpResponse.Body.Length.ToString());
context.Response = httpResponse;
Voici les attributs nécessaires pour une réponse HTTP correcte :
- Un code de retour : 200 pour signaler que la requête et la réponse sont correctes.
- Le corps (body) : qui contient le contenu du fichier.
- Le Content-Type : qui est le type MIME.
- Le Content-Length : la taille de la réponse, qui est tout simplement la taille du body.
Voilà, notre réponse est prête !
Entre nous
Pourquoi lire les fichiers en mode binaire ? Le mode texte fonctionnerait pour plusieurs types de fichiers, ceux que l’on peut ouvrir avec un éditeur de texte comme HTML, CSS... Mais avez-vous déjà tenté d’ouvrir une image avec Notepad ? C’est impossible. Donc, vu que pour certaines ressources, il faut renvoyer le flux en binaire, autant le faire pour toutes !
Toujours entre nous
Ici, le chemin de la requête correspond à un fichier physique sur le disque, mais le chemin pourrait juste correspondre à ce qu’on appelle un endpoint. Dans ce cas, le service web ne renvoie pas un fichier. À la place, le service peut effectuer une action ou construire une réponse dynamique.
Vous pouvez sur PandApache3 envoyer une requête GET sur l'endpoint /echo/ en y ajoutant un paramètre (http://pandapache3/echo/hello). La réponse obtenue sera alors "hello", idéal pour s’assurer que votre service tourne correctement !
Transmission de Réponses HTTP
Notre réponse (quelle qu'elle soit) est maintenant générée, mais elle se trouve encore sur le serveur. Il est donc temps de la renvoyer au client. Pour cela, la requête va continuer de parcourir nos middlewares dans un sens, puis dans l’autre. Une fois notre pipeline totalement exécuté, nous allons nous retrouver dans la suite de la fonction HandleClientAsync
du ConnectionManager
:
await ConnectionUtils.SendResponseAsync(client, context.Response);
Voici le corps de la fonction SendResponseAsync
:
byte[] msg = Encoding.UTF8.GetBytes(response.ToString());
await client.SendAsync(msg, SocketFlags.None);
if (response.Body != null)
{
response.Body.Position = 0; // Assurez-vous que le flux est au début
byte[] buffer = new byte[1024];
int bytesRead;
while ((bytesRead = response.Body.Read(buffer, 0, buffer.Length)) > 0)
{
await client.SendAsync(new ArraySegment<byte>(buffer, 0, bytesRead), SocketFlags.None);
}
}
Nous envoyons la réponse HTTP au client en deux temps : le premier envoi est pour l’en-tête, puis le corps est envoyé en paquets de 1024 octets.
Une fois l’envoi de la réponse effectué, nous pouvons dans le ConnectionManager retirer le client de notre dictionnaire, puis fermer la connexion.
Entre nous
Cette méthode de gestion des requêtes et réponses est simple, mais manque d’optimisation de performance. Si votre page index.html dispose par exemple de 10 images, le serveur va d'abord recevoir une requête pour la page HTML, puis 9 autres consécutives pour les images. C’est bien sûr très long sur des pages lourdes. Pour résoudre ce problème, on peut faire ce qu’on appelle du multiplexage afin d’envoyer plusieurs requêtes et réponses avec une seule connexion TCP. Ce n’est actuellement pas présent sur PandApache3.
Dans cet article, nous avons détaillé le fonctionnement du serveur web PandApache3, depuis l'initialisation et la gestion des connexions jusqu'à la génération et l'envoi des réponses aux clients. Nous avons exploré comment les requêtes sont acceptées et traitées, en passant par un pipeline de middlewares qui assure des tâches telles que le routage et la création des réponses HTTP.
Ce premier aperçu du fonctionnement interne de PandApache3 vous donne une base solide pour comprendre comment un serveur web traite les requêtes et génère des réponses. Dans les articles suivants, nous continuerons à explorer d'autres aspects et fonctionnalités de PandApache3, en approfondissant les techniques de sécurité, les stratégies de journalisation, et l'administration avancée.
Restez à l'écoute pour la suite de cette série, où nous continuerons à dévoiler les mécanismes qui font fonctionner PandApache3 de manière efficace et sécurisée.
Merci infiniment d'avoir exploré les coulisses de PandApache3 avec moi ! Vos réflexions et votre soutien sont essentiels pour faire évoluer ce projet. 🚀
N'hésitez pas à partager vos idées et impressions dans les commentaires ci-dessous. Je suis impatient d'échanger avec vous !
Suivez mes aventures sur Twitter @pykpyky pour rester à jour sur toutes les nouvelles.
Vous pouvez également découvrir le projet complet sur GitHub et me rejoindre lors de sessions de codage en direct sur Twitch pour des sessions passionnantes et interactives. À bientôt derrière l'écran !
Top comments (0)