Créer un serveur en PHP

Attention : ce document a changé d'adresse. L'ancienne adresse était http://www.e-t172.net/phpserv/, la nouvelle est http://articles.e-t172.net/phpserv/.

Introduction

PHP est un language de script conçu à l'origine pour être intégré à des pages web en vue de leur offrir un contenu dynamique, c'est à dire généré par le script côté serveur.

Cependant, avec la popularité grandissante de PHP, un nombre croissant de personnes ont désiré utiliser la simplicité, la grande puissance et la diversité des bibliothèques de PHP pour le faire fonctionner en dehors d'une page web. De là est né l'extension CLI apparue dans PHP 4.3.

Cette extension permet à tout un chacun d'éxécuter un script PHP de manière autonome, c'est à dire en dehors d'un serveur web. Le script PHP se comporte alors à la manière d'un script Bash, envoyant les données à la sortie standard.

Cela a permis la création de l'extension PHP-GTK, qui ajoute à PHP la possibilité de dessiner une GUI en utilisant la bibliothèque GTK.

De plus, une extension expérimentale offre un compilateur Bytecode pour PHP, qui offre la possibilité de créer des fichiers éxécutables à partir de scripts PHP afin de rendre le script totalement indépendant de l'éxécutable PHP, donc totalement autonome. Fort malheureusement, cette extension est pour le moment très difficile à utiliser (il faut notamment recompiler PHP avec des paramètres spéciaux pour l'inclure dans le fichier éxécutable final).

Bref, PHP est aujourd'hui devenu un moyen fiable de développer des programmes autonomes dont le principal avantage est de fournir un rapport puissance/polyvalence/temps de développement absolument imbattable, en contrepartie de performances nettement inférieures à celles d'un programme classique écrit en C/C++.

Par ailleurs, PHP inclut également une extension Sockets, qui est en fait une interface entre PHP et les fonctions sockets C bas niveau du système d'exploitation. Les habitués de la programmation Sockets en C reconnaîtront sûrement les fonctions dont les noms sont très similaires à leurs homologues dans ce langage.

Cette extension nous permet de programmer un client, mais l'intérêt est ici limité car l'utilisation de fsockopen() ou des fonctions de flux est bien plus simple et parfois même plus puissante (notamment en ce qui concerne le timeout de connexion).

En revanche, là où l'extension excelle, c'est lorsque l'on veut programmer un serveur en PHP. Cela peut à première vue paraître loufoque, mais pourquoi pas ? La simplicité relative de l'extension Sockets vous permet d'écouter sur un port en 3 lignes de code. Bien qu'un serveur écrit en PHP n'atteindra jamais la rapidité d'un éxécutable en C, la diversité des bibliothèques et la souplesse de PHP peut être très pratique pour développer des passerelles ou des serveurs exploitant différentes sources de données (MySQL par exemple) ou effectuant des opérations complexes (créations d'images avec GD par exemple).

Alors, envie de coder un serveur dans votre langage de script favori ? Lisez la suite !

NOTE : ce document traite uniquement de la programmation de serveurs utilisant le protocole TCP. La programmation de serveurs UDP suit les mêmes principes moyennant quelques subtiles différences dûes au fait que ce protocole est orienté sans connexion.

NOTE : le présent document considère que vous connaissez les notions de base des sockets, du protocole TCP et des modèles client-serveur. Si ce n'est pas le cas, certaines parties de ce document peuvent vous paraître obscures.

Avant de commencer

Executer PHP en ligne de commande

Le présent document va vous expliquer 3 méthodes dont vous pourrez vous servir afin de programmer votre serveur. Pour chacune de ces 3 méthodes, lancer PHP en ligne de commande n'est en théorie pas nécéssaire : vous pouvez éxécuter le script de manière classique sur un serveur Web. Cependant, c'est complètement inutile et très peu pratique, que ce soit pour le débogage que pour la mise en production.

Nous allons donc éxécuter notre script en mode autonome, c'est à dire en ligne de commande.

NOTE : pour des informations complémentaires sur l'éxécution de scripts autonomes, voyez la page dédiée de la documentation.

Sous Windows

Vous aurez besoin de l'éxécutable PHP (php.exe) disponible sur la page téléchargements du site PHP. Vous devez choisir le paquetage ZIP. Prenez soin de recopier le fichier php.ini-dist dans le répertoire de votre installation de Windows, puis de le renommer en php.ini.

Modifiez ensuite ce fichier php.ini pour activer l'extension "sockets". Attention à bien entrer le répertoire des extensions (nommé "ext" et placé dans le même répertoire que PHP) dans la variable extension_dir.

Pour lancer votre script, faites Démarrer/Exécuter..., tapez cmd et validez. Puis allez dans votre répertoire de php à l'aide de la commande cd et entrez la commande suivante :

php.exe c:\chemin\de\votre\script.php

Pour arrêter le script, pressez CTRL + C.

Sous Linux

Si ce n'est déjà fait, téléchargez et compilez PHP en passant l'option --with-sockets au script de configuration.

Pour lancer votre script, entrez cette commande sur le shell :

php /chemin/de/votre/script.php

Pour arrêter le script, pressez CTRL + C.

Testez votre serveur avec Netcat

Lorsque vous voudrez déboguer votre serveur, vous aurez besoin d'un client. Il vous permettra de vérifier si le comportement de votre serveur est bien celui attendu.

Netcat est un client générique similaire à Telnet en beaucoup plus puissant. Vous pouvez le télécharger en version Windows ou Linux.

La syntaxe de base est la suivante :

nc -v [adresse] [port]

L'extension sockets

Cette extension, comme son nom l'indique, va nous permettre de créer puis de manipuler les sockets utilisées par le serveur. Ses fonctions constitueront le coeur de notre script. Je vais présenter brièvement celles dont nous allons nous servir; pour plus de détails, voyez la documentation PHP sur les sockets.

Méthode procédurale : la plus simple

La méthode procédurale est la plus simple pour programmer un serveur opérationnel.

Elle consiste à faire tourner une boucle infinie dans laquelle les clients sont acceptés et traités l'un après l'autre.

Le code

<?php
// On crée la socket
$socket socket_create(AF_INETSOCK_STREAMSOL_TCP);

// On la lie, ici sur le port 10000
socket_bind($socket'0.0.0.0'10000);

// On la fait écouter
socket_listen($socket5);

// Boucle infinie
while (TRUE)
{
	// On attend qu'un client se connecte
	$client socket_accept($socket);
	
	// On affiche un message de bienvenue au client
	socket_write($client'Bienvenue sur mon serveur perso !'."\n");
	
	// On lit un paquet de réponse du client
	$response socket_read($client1000PHP_BINARY_READ);
	
	// On envoie des données au client
	socket_write($client'Vous avez dit "'.$response.'" - fermeture de la connexion'."\n");
	
	// On ferme la connexion avec le client
	socket_close($client);
}
?>

Ce code n'est pas parfait. En effet vous aurez besoin de gérer plusieurs paquets (sachant que leur taille peut varier de 1 octet à plusieurs milliers) et surtout, l'écriture sur la socket doit être mieux protégée contre le fait que tous les octets ne sont pas forcément transmis à chaque appel (la solution la plus propre étant de mettre en place une liste d'attente pour les données à envoyer).

Pour savoir si un client s'est déconnecté, socket_read() retournera FALSE et la socket se verra affecter un code d'erreur.

Tester

Utilisez Netcat pour vous connecter sur le port du serveur (ici, 10000). Vous devriez normalement voir le message de bienvenue envoyé par le serveur. Tapez du texte et validez : le serveur vous renverra votre message puis fermera la connexion.

Méthode multisockets : gérer plusieurs clients à la fois

La méthode procédurale pose un problème de taille : elle ne permet de gérer qu'un seul client à la fois. En effet, si vous ouvrez deux sessions de NetCat simultanées, vous constaterez que bien que la seconde paraît connectée, seule la première répond aux paquets que vous lui envoyez. La seconde ne réagira que lorsque vous fermerez la première session.

Pourquoi ce comportement ? La réponse est simple : La boucle principale ne traite qu'un seul client à chaque itération. Une fois que le client est servi, la boucle passe au deuxième. Ce qui signifie qu'un seul client ne peut interagir avec le serveur à un moment donné.

La solution consisterait donc à traiter plusieurs clients dans la même itération dans la boucle principale. Cela est possible grâce à la fonction socket_select(). Cette fonction très utile surveille plusieurs sockets à la fois et réagit dès que l'un d'eux reçoit des données. Elle place alors la socket concernée dans un tableau afin que le reste du script puisse l'exploiter.

Le code requis est nettement plus complexe que pour un serveur procédural, puisqu'on doit jongler avec plusieurs clients à la fois. La meilleure solution pour garder un code très propre et très lisible consiste à stocker les différentes informations sur l'état actuel d'un client (par exemple si il est authentifié, ou la requête que l'on est en train d'analyser, etc) dans un objet. Un objet représente un client. Les données étant encapsulées, il n'y a aucun risque de débordement et le code est bien plus clair. socket_select() ne retournant que des ressources socket, il nous faut une table de correspondance (un tableau) pour faire le lien entre une socket et l'objet client associé.

Le code

<?php
// On crée la socket
$socket socket_create(AF_INETSOCK_STREAMSOL_TCP);

// On la lie, ici sur le port 10000
socket_bind($socket'0.0.0.0'10000);

// On la fait écouter
socket_listen($socket5);

// On ajoute la socket du serveur dans la liste des sockets à surveiller (pour les nouvelles connexions)
$watchSockets = array($socket);

// On initialise un tableau contenant les objets représentant les clients connectés
$clients = array();

// Ainsi que le tableau maintenant la correspondance entre les clients et leurs sockets
$clientsSockets = array();

// Boucle infinie
while (TRUE)
{
	// Surveillance des sockets en attente de données à lire
	// Si pas de données à lire : on relance la boucle
	$toRead $watchSockets// Parce que socket_select() modifie le tableau passé en paramètre et que l'on veut garder notre variable $watchSockets
	if (!socket_select($toRead$write NULL$except NULL0)) continue;
	
	// Il y a des données à lire
	
	// Le socket d'écoute a-t-il reçu des données ?
	if (in_array($socket$toRead))
	{
		// Oui : il s'agit d'une nouvelle connexion
		
		// Acceptons-la
		$newConnection socket_accept($socket);
		
		// Créons un nouvel objet client
		$clients[] = new client;
		
		// Ajoutons l'entrée dans la table de correspondance objets <-> sockets
		$clientsSockets[] = $newConnection;
		
		// Ajoutons la aux sockets à surveiller
		$watchSockets[] = $newConnection;
		
		// Supprimons la socket d'écoute de la liste des sockets à lire
		// (pour éviter d'interférer avec la suite)
		$key array_search($socket$toRead);
		unset($toRead[$key]);
	}
	
	// Parcourons la liste des sockets clients ayant reçu des données
	foreach ($toRead as $oneSocket)
	{
		// Obtenons l'objet concerné à partir de la socket
		// (c'est à ça que la table de correspondance sert)
		$key array_search($oneSocket$clientsSockets);
		$oneClient =& $clients[$key];
		
		// Lisons la socket sur disons... 1024 octets
		// (un choix purement arbitraire, peut néanmoins influer sur les performances, pensez à faire des tests)
		$data socket_read($oneSocket1024);
		
		// Passons les données à l'objet
		$toWrite $oneClient->incomingData($data);
		
		// Notre client est-il toujours au bout du fil ?
		if (!strlen($data))
		{
			// Ben non, il s'est cassé ! Détruisons les données correspondantes
			unset($clients[$key]);
			unset($clientsSockets[$key]);
			
			$watchKey array_search($oneSocket$watchSockets);
			unset($watchSockets[$watchKey]);
		}
		elseif (strlen($toWrite))
		{
			// L'objet a retourné des données à écrire
			
			// Ecrivons ces données
			// for(nombre de données écrites = 0; nombre de données écrites == nombre de données à écrire; nombre de données écrites += retour de socket_write(données restantes));
			for ($writed 0$writed strlen($toWrite); $writed += socket_write($oneSocketsubstr($toWrite$writed)));
		}
		elseif ($toWrite === FALSE)
		{
			// L'objet a retourné FALSE : il demande que la connexion soit fermée
			
			socket_close($oneClient);
			
			// Détruisons les données correspondantes
			unset($clients[$key]);
			unset($clientsSockets[$key]);
			
			$watchKey array_search($oneSocket$watchSockets);
			unset($watchSockets[$watchKey]);
		}
		else
		{
			// L'objet n'a pas retourné de données à écrire, ni FALSE : on ne fait rien
		}
		
		var_dump($clients);
	}
}

// Classe client
class client
{
	function incomingData($data)
	{
		// On retourne les données à écrire
		return 'Vous avez dit: '.$data;
	}
}
?>

Ce code ajoute de plus un meilleur support du comportement de socket_write() qui n'écrit pas forcément toutes les données d'un seul coup. Il n'est néanmoins pas parfait - il manque notamment la possibilité d'envoyer un message à la connexion (message de bienvenue). Ceci en vue d'éviter d'alourdir plus le code.

Tester

Pour tester ce script, n'ouvrez pas une, mais deux sessions Netcat sur le port 10000. Vous constaterez que les deux clients, connectés simultanément, peuvent interagir en même temps avec le serveur.

Méthode multithread : dans la cour des grands

La méthode multisockets nous permet de traiter plusieurs clients à la fois. A ce stade, on pourrait se demander qu'est ce que l'on pourrait faire de mieux. Eh bien figurez-vous que cette méthode est loin de pouvoir subvenir aux besoins de certains serveurs.

Prenons un exemple : vous programmez un serveur de gestion de fichiers à distance. Imaginez qu'un client envoie une requête entraînant la copie d'un fichier sur le disque dur du serveur d'une taille très conséquente (500 mo par exemple). Le problème est que un processus ne peut pas effectuer plusieurs opérations à la fois. Ici, lorsque la copie aura commencé, le serveur sera bloqué pour tous les clients, et pas uniquement celui qui a formulé la requête, pour un laps de temps considérable. Cet état de fait a aussi un impact sur les serveurs très fréquentés sur lesquels la méthode multisockets pourrait aboutir à une liste d'attente qui briderait énormément les performances.

Il n'y a qu'une seule solution pour éviter ce problème : il faut diviser le processus du serveur en plusieurs sous-processus appellés threads. Chacun d'eux s'occupera d'un client. Ainsi, la requête d'un client ne ralentira pas celles des autres clients, étant donné que plusieurs requêtes peuvent être satisfaites en même temps.

L'objectif est donc de séparer chaque client en leur affectant chacun un processus qui éxécutera un script PHP séparé. Le processus serveur tiendra le rôle de passerelle : il recevra les données des clients pour les envoyer aux threads, et recevra les données des threads pour les envoyer aux clients. C'est en quelque sorte le "point d'échange" dont le seul rôle est de s'occuper du socket extérieur et des communications interprocessus.

Ces communications interprocessus sont très importantes, et la méthode à choisir pour les implémenter est déterminante. Deux possibilités s'offrent à nous :

C'est donc la deuxième solution que nous implémenterons. A chaque connexion à un client, un appel à exec() lancera une instance du script thread via l'éxécutable PHP, tout en dirigeant la sortie vers l'arrière plan pour éviter que l'éxécution ne bloque le script. Pendant le chargement du thread, si des données sont reçues du client, celles-ci sont placées dans un tableau temporaire pour les remettre au thread dès que celui-ci est prêt. Une fois lancé, le thread va tenter de se connecter au serveur interne via les sockets Unix. Dès que cette connexion est effectuée, le thread "s'identifie" c'est à dire il indique à la passerelle à quel client il doit être affecté. Cette formalité remplie, la passerelle va transférer en vrac les données reçues du client au thread, et les données reçues du thread au client. Le thread, une fois identifié, utilise donc la socket de manière totalement transparente, exactement comme si il était connecté directement au client.

NOTE : cette méthode peut se révéler finalement moins performante que la méthode multisockets simple dans le cas de serveurs peu complexes et peu fréquentés. En effet le lancement d'un script PHP secondaire lors d'une connexion nécéssite un peu de temps, et l'ensemble consommera plus de mémoire.

Le code

multithread.php

<?php
// On se replace dans le répertoire où est stocké le script
chdir(dirname(__FILE__));

// On initialise un tableau contenant la liste des sockets à surveiller
$watchSockets = array();

// On initialise un tableau contenant les sockets clients
$clients = array();

// On initlialise un tableau contenant les sockets threads
$threads = array();

// On initialise un tableau contenant les données temporaires à envoyer à un nouveau thread
$threadsTemp = array();

// -----------------------------------

// On crée la socket "externe"
$socket socket_create(AF_INETSOCK_STREAMSOL_TCP);

// On la lie, ici sur le port 10000
socket_bind($socket'0.0.0.0'10000);

// On la fait écouter
socket_listen($socket5);

// On ajoute la socket du serveur dans la liste des sockets à surveiller (pour les nouvelles connexions)
$watchSockets[] = $socket;

// -----------------------------------

// On crée la socket "interne" pour la communication avec les threads
$threadSocket socket_create(AF_UNIXSOCK_STREAMNULL);

// On la lie, ici sur la socket /tmp/monserveur.socket
socket_bind($threadSocket'/tmp/monserveur.socket');

// On la fait écouter
socket_listen($threadSocket);

// On ajoute la socket du serveur interne dans la liste des sockets à surveiller (pour les nouveaux threads)
$watchSockets[] = $threadSocket;

// Boucle infinie
while (TRUE)
{
	// Surveillance des sockets en attente de données à lire
	// Si pas de données à lire : on relance la boucle
	$toRead $watchSockets// Parce que socket_select() modifie le tableau passé en paramètre et que l'on veut garder notre variable $watchSockets
	if (!@socket_select($toRead$write NULL$except NULL0)) continue;
	
	// Il y a des données à lire
	
	// Le socket d'écoute éxtérieur a-t-il reçu des données ?
	if (in_array($socket$toRead))
	{
		// Oui : il s'agit d'une nouvelle connexion
		
		// Acceptons-la
		$newConnection socket_accept($socket);
		
		// Ajoutons-la à la liste des clients
		$clients[] = $newConnection;
		
		// Ajoutons-la à la liste des sockets à surveiller
		$watchSockets[] = $newConnection;
		
		// Créons un thread qui s'occupera de ce client
		// ATTENTION : vérifiez que le chemin vers PHP est correct
		// On passe en outre l'ID du client dans le tableau de correspondance pour que l'on puisse identifier le thread lors de sa connexion plus tard
		// Les caractères étranges de la fin de la commande assurent l'éxécution en arrière-plan
		exec('/usr/local/bin/php thread.php '.strval(count($clients) - 1).' 2>/dev/null >&- <&- >/dev/null &');
	
		// Supprimons la socket d'écoute de la liste des sockets à lire
		// (pour éviter d'interférer avec la suite)
		$key array_search($socket$toRead);
		unset($toRead[$key]);
	}
	
	// Le socket d'écoute interne (threads) a-t-il reçu des données ?
	if (in_array($threadSocket$toRead))
	{
		// Oui : un thread est prêt et tente de se connecter
		
		// Acceptons la connexion
		$newConnection socket_accept($threadSocket);
		
		// Récupérons l'ID du thread
		$data socket_read($newConnection4PHP_BINARY_READ);
		var_dump($data);
		$id unpack('lid'$data); $id $id['id'];
		// NOTE : cette méthode n'est en théorie pas optimale au niveau perfs, socket_read() pouvant bloquer un court instant
		// Pour éviter cela, il faut récupérer l'ID en surveillant le socket (en attendant il faut placer le socket client dans une pile temporaire)
		// Cette dernière variante n'est pas utilisée ici pour ne pas compliquer le code mais ne présente pas de difficulté majeure
		
		// Ajoutons-la à la liste des threads
		$threads[$id] = $newConnection;
		
		// Ajoutons-la à la liste des sockets à surveiller
		$watchSockets[] = $newConnection;
		
		// A-t-on des données à lui passer, qui ont été stockées temporairement ?
		if (strlen($threadsTemp[$id]))
		{
			// Oui : passons les
			
			for ($writed 0$writed strlen($threadsTemp[$id]); $writed += socket_write($newConnectionsubstr($threadsTemp[$id], $writed)));
			
			unset($threadsTemp[$id]);
		}
		
		// Supprimons la socket d'écoute de la liste des sockets à lire
		// (pour éviter d'interférer avec la suite)
		$key array_search($threadSocket$toRead);
		unset($toRead[$key]);
	}
	
	// Parcourons la liste des sockets clients et threads ayant reçu des données
	foreach ($toRead as $oneSocket)
	{
		if (in_array($oneSocket$clients))
		{
			// Il s'agit d'un client externe
			
			// Client >> SCRIPT >> thread
			
			// Obtenons l'ID client correspondant
			$key array_search($oneSocket$clients);
			
			// Lisons la socket sur disons... 1024 octets
			// (un choix purement arbitraire, peut néanmoins influer sur les performances, pensez à faire des tests)
			$data socket_read($oneSocket1024);
			
			if (!strlen($data))
			{
				// La connexion avec le client s'est interrompue
				
				// Fermons le socket client
				socket_close($oneSocket);
				
				// Fermons le socket thread
				@socket_close($threads[$key]);
				
				// On coupe tout
				unset($clients[$key]);
				unset($threads[$key]);
				$watchKey array_search($oneSocket$watchSockets);
				unset($watchSockets[$watchKey]);
			}
			else
			{
				// Le thread associé est-il prêt et connecté ?
				if (isset($threads[$key]))
				{
					// Oui : passons lui les données
					
					for ($writed 0$writed strlen($data); $writed += socket_write($threads[$key], substr($data$writed)));
				}
				else
				{
					// Non : on les stocke temporairement
					
					$threadsTemp[$key] .= $data;
				}
			}
		}
		elseif (in_array($oneSocket$threads))
		{
			// Il s'agit d'un thread
			
			// Thread >> SCRIPT >> client
			
			// Obtenons l'ID du thread correspondant
			$key array_search($oneSocket$threads);
			
			// Lisons la socket sur disons... 1024 octets
			// (un choix purement arbitraire, peut néanmoins influer sur les performances, pensez à faire des tests)
			$data socket_read($oneSocket1024);
			
			if (!strlen($data))
			{
				// La connexion avec le thread s'est interrompue (autrement dit, il veut fermer la connexion avec le client)
				
				// Fermons le socket client
				socket_close($clients[$key]);
				
				// Fermons le socket thread
				socket_close($oneSocket);
				
				// On coupe tout
				unset($clients[$key]);
				unset($threads[$key]);
				$watchKey array_search($oneSocket$watchSockets);
				unset($watchSockets[$watchKey]);
			}
			else
			{
				// Passons les données au client correspondant
				for ($writed 0$writed strlen($data); $writed += socket_write($clients[$key], substr($data$writed)));
			}
		}
	}
}
?>

thread.php

<?php
// Script du thread
// Executé pour traiter chaque client connecté

// On prend l'ID de client passé dans la ligne de commande
$id $argv[1];

// On crée la socket
$socket socket_create(AF_UNIXSOCK_STREAMNULL);

// On se connecte à la "passerelle"
socket_connect($socket'/tmp/monserveur.socket');

// On "s'identifie" c'est à dire on donne l'ID avec lequel le thread a été appellé
socket_write($socketpack('l'$id));

// Boucle infinie
while (TRUE)
{
	// On attend des données
	$data socket_read($socket1024PHP_BINARY_READ);
	
	if (!strlen($data))
	{
		// Connexion terminée
		socket_close($socket);
		
		die();
	}
	
	// On envoie des données
	socket_write($socket'Vous avez écrit : '.$data);
}
?>

NOTE : assurez-vous que le fichier socket spécifié est accessible en lecture et écriture par la passerelle et le thread, et que la passerelle a des droits suffisants pour éxécuter le thread (droits de l'éxécutable PHP notamment).

Comme d'habitude, ce code peut être amélioré :

NOTE : la méthode multithreads partage le travail en autant de processus qu'il y a de clients. Cela lui permet de profiter pleinement des machines à plusieurs processeurs ou à processeurs à double coeur.

Tester

Pour tester ce script, connectez vous comme d'habitude sur votre serveur avec netcat, en ouvrant plusieurs sessions si ça vous chante.

Historique du document

L'auteur

e-t172

E-mail : e-t172 at e-t172 dot net

MSN Messenger : eti172@msn.com

Valide XHTML 1.0 Strict Valide CSS