Un serveur d'objets en PHP 5 - 2ème partie
Par Charlie, mercredi 30 avril 2008 à 18:03 :: PHP :: #51 :: rss
Remarque : Dans cet article, nous utiliserons le terme "service" pour parler d'une fonction présente sur un serveur distant, et par extension, nous utiliserons le terme "serveur de services" pour faire référence au serveur contenant ces services. De même, nous parlerons de "serveur client" pour désigner le serveur faisant appel à des services.
Factorisation du code
A partir du script de l'article précédent, nous allons créer une fonction nommée "sendRequest" sur le serveur client, dont l'objectif est d'appeler un service. Cette fonction prend 4 paramètres :
- L'URL du serveur de services
- La clef d'authentification
- Le nom du service
- Les paramètres du service
sendRequest.php
<?php
// Fonction permettant d'invoquer un service distant.
function sendRequest($url, $auth, $serviceName, $arguments = array())
{
// On accepte des URL en http ou https
assert('preg_match(\'`^https?://`i\', $url)');
// La clef d'authentification doit être une chaîne
assert('is_string($auth)');
// Le nom du service ne doit pas contenir autre chose que des lettres,
// chiffres, ":" et "_"
assert('preg_match("`^[\w:]+$`", $serviceName)');
// Les arguments doivent être placés dans un tableau.
assert('is_array($arguments)');
// On construit une chaîne contenant les paramêtres d'appel.
$data = 'auth=' . $auth;
$data .= '&serviceName=' . $serviceName;
// Après la linéarisation, il faut encoder le résultat pour placer ça en
// paramètres d'une URL
$data .= '&args=' . urlencode(serialize($arguments));
// On appelle le service distant en masquant les erreurs de chargement
$content = @file_get_contents($url . '?' . $data);
if (false === $content) {
// Si le contenu retourné contient "false" cela indique un problème de
// connexion au serveur
throw new Exception('Problème d\'accès au serveur distant');
} else {
// On délinéarise le contenu retourné
$result = unserialize($content);
if (false === $result) {
// Problème de délinéarisation du résultat (ou résultat contenant
// "false"). Cela arrive quand le script distant affiche une erreur.
throw new Exception('Problème de délinéarisation du résultat');
} elseif (! is_array($result) or
!(
array_key_exists('exception', $result) or
array_key_exists('return', $result)
)) {
// Si le contenu n'est pas un tableau, ou qu'il n'y a ni exception ni
// résultat, cela indique que le résultat linéarisé n'est pas
// conforme aux attentes (tableau contenant "exception" et "return")
throw new Exception('Résultat retourné non conforme');
}
}
if (! empty($result['exception'])) {
// Il y a une exception, alors on la lève
throw new Exception($result['exception']);
} else {
// On retourne le résultat s'il n'y a pas d'exception.
return $result['return'];
}
}
?>
Comme vous pouvez le remarquer, nous avons enrichi le processus d'appel distant pour y ajouter le nom du service et des paramètres. En effet, dans l'article précédent, nous invoquions un script ne remplissant d'une seule fonction à la fois sans paramètre en dehors de la clef d'authentification, ce qui limitait assez fortement sont utilisation ou nécessitait de modifier le script pour l'adapter à chaque type d'appel.
De même, nous avons ajouté en paramètre, l'URL et la clef d'authentification, afin de s'affranchir de l'utilisation de constantes qui par définition ne peuvent pas changer en cours de route. Effectivement, si au sein d'un même script, nous voulions accéder à un autre serveur avec une clef d'authentification différente, l'utilisation de constante nous aurait bloqués.
Remarque : Pour ne alourdir cette article déjà bien dense en scripts, les exceptions levées sont génériques (classe "Exception"). Prenez l'habitude de créer des classes dérivées plus significatives afin de faciliter leur traitement dans le code appelant.
Au passage, nous allons profiter de ce code, pour rappeler (ou introduire) le concept d'assertion :
Comme vous pouvez le voir, la fonction commence par 4 appels successifs à "assert". Cette technique permet de garantir qu'en phase de développement, les paramètres sont conformes à l'utilisation qui doit en être fait dans la fonction (c'est une chaîne, un tableau, ne contient pas caractères interdit, etc.). En effet, PHP étant un langage à typage faible, une variable peut contenir n'importe quel type au risque d'être converti à la volée d'un type vers un autre (exemple : l'addition d'un entier avec une chaîne). Ce transtypage peut engendrer des erreurs là où on ne les attend pas et vous faire perdre du temps en débogage, tout ça parce que vous n'avez pas utilisé le bon paramètre.
Mais dans ce cas, pourquoi utiliser "assert", vous demandez vous ? Ne serait-il pas plus simple de mettre un "if" avec un message explicite ?
En fait, l'assertion est une fonction désactivable dans le php.ini, ce qui veut dire que l'on peut supprimer ces contrôles lorsque l'on met son application en ligne et donc gagner en performance. Cette désactivation n'est envisageable que dans le cas ici présent, où les paramètres sont générés à l'origine par le développeur et non par l'utilisateur de l'application. Par exemple, si un paramètre provenait d'une donnée saisie dans un formulaire, il serait impératif de valider les données par un contrôle classique afin de bloquer toute utilisation malencontreuse ou malhonnête de notre application.
Enfin, vous pouvez noter que le code de validation est placé dans une chaîne de caractère. En effet, si le paramètre de "assert" est une chaîne de caractère, et si les assertions sont activés, celle-ci est interprétée. Dans le cas contraire, la chaîne n'étant pas interprétée, vous économisez du temps en n'exécutant pas le code.
Retournons maintenant à notre fonction "sendRequest". Les plus attentifs auront noté un changement dans le contrôle du résultat délinéarisé. Dans l'article précédent, nous utilisions "empty" alors que nous utilisons désormais la fonction "array_key_exists". En effet, le résultat d'un service pourrait parfaitement être vide, il fallait donc effectuer le contrôle différemment. Un "isset" n'était pas possible car si le résultat est "NULL", isset retourne "false". Il ne restait donc plus que le contrôle de présence de la clef avec array_key_exists.
Observons maintenant le script présent sur le serveur de services :
remote.php
<?php
// On définit la clef d'authentification qui doit être identique à celle
// présente sur le serveur client.
define('REQUEST_AUTHENTICATE', '657c080af2561edfea164e9f0c6af7bde673a3a310d78');
// Préfixe et suffixe pour le script contenant la fonction
define('REMOTE_SCRIPT_PREFIX', 'remote_');
define('REMOTE_SCRIPT_SUFFIX', '.php');
// on définit le tableau qui sera linéarisé à la fin du script
$result = array();
if (empty($_GET['auth']) or $_GET['auth'] != REQUEST_AUTHENTICATE) {
// Pas de clef authentification ou clef incorrecte.
$result['exception'] = 'L\'authentification a échoué';
} else {
if (empty($_GET['serviceName'])) {
// S'il n'y a pas de nom de service, on lève une erreur
$result['exception'] = 'Nom de service absent';
} else {
// Le nom du service ne doit contenir que des lettres, chiffres, "_" et
// ":". La chaîne "::" servant à séparer le nom du script du service
$serviceName = preg_replace('`[^\w:]`', '', $_GET['serviceName']);
// on sépare le nom du script, du nom du service
list($script, $functionName) = explode('::', $serviceName);
// S'il n'y a pas de nom de service, par défaut, le nom du script porte
// le nom du service.
if (empty($functionName)) $functionName = $script;
// Le script est préfixé de "remote" et porte l'extention ".php"
$scriptName = REMOTE_SCRIPT_PREFIX . $script . REMOTE_SCRIPT_SUFFIX;
if (file_exists($scriptName)) {
// Si le script existe, on le "charge"
require $scriptName;
if (function_exists($functionName)) {
// Si la fonction existe, on prépare l'appel.
// Si "args" n'existe pas, on considère qu'il n'y a pas d'arguments
// Sinon, on la délinéarise
if (empty($_GET['args'])) $args = array();
else $args = unserialize($_GET['args']);
// On attrape des exceptions levées dans la fonction
try {
// On appelle la fonction et on place le résultat dans la
// variable de résultat
$result['return'] = call_user_func_array($functionName, $args);
} catch(Exception $e) {
// En cas d'exception, on remonte le message dans le résultat.
$result['exception'] = $e->getMessage();
}
} else {
// La fonction n'existe pas dans le script appelé
$result['exception'] = 'Nom de service ' . $functionName . ' inexistant';
}
} else {
// Le script n'existe pas.
$result['exception'] = 'Script ' . $scriptName . ' introuvable';
}
}
}
// On linéarise le résultat pour l'envoyer à l'appelant.
echo serialize($result);
?>
Ce script a été rendu modulaire en permettant au développeur d'appeler une fonction en dehors du script "remote.php". Ainsi, si vous créez un script nommé "remote_xxxxx.php" contenant la fonction "yyyyyy()", vous utiliserez la chaîne "xxxxx::yyyyyy". De plus, si vous utilisez la chaîne "zzzzz", le script utilisé se nommera "remote_zzzzz.php" et la fonction appelée sera "zzzzz()".
Si vous désirez enregistrer tous vos scripts remote dans un répertoire spécifique, il vous suffit de modifier le préfixe "REMOTE_SCRIPT_PREFIX" avec, par exemple, la valeur "le_repertoire_remote/.
Vous constaterez que la variable "$_GET['serviceName']" est filtré par une expression rationnelle qui supprime tous les caractères qui ne sont pas des lettres, chiffres, "_" et ":". Pourquoi un tel filtre alors que dans le code appelant, nous avons placé une assertion interdisant déjà ce type d'écriture ? Tout simplement parce que ce paramètre provient d'une variable sale, c'est à dire une variable non maîtrisée à 100% par le développeur. En effet, on ne doit jamais faire confiance à une variable de type $_GET, $_POST, $_FILES, $_COOKIES, $_SERVER, etc. dont la source peut être extérieure au développement.
Voici le code de "remote_membre.php" :
remote_membre.php
<?php
function liste()
{
// On se connecte au serveur MySQL et on s'authentifie
$pdo = new PDO('mysql:host=sql.serveur-donnees.com;dbname=test',
'root', '');
// Indique à PDO de lever une exception en cas d'erreur
// Les exceptions seront attrapées par l'appelant.
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
// On définit la requête SQL en classant par nom, puis prénom.
$sql = 'SELECT nom, prenom from membres ORDER BY nom, prenom';
// On exécute la requête
$st = $pdo->query($sql);
// On place tous les enregistrements sous forme de tableau dans la
// variable de résultat
return $st->fetchAll(PDO::FETCH_ASSOC);
}
?>
Comme vous pouvez le remarquer, le script final reste simple et ne nécessite pas de code spécifique pour les échanges entre le serveur client et le serveur de services.
Voici enfin, le code d'appel sur le serveur client.
server-client-1.php
<?php
require 'sendRequest.php';
// On définit la clef d'authentification (peut être beaucoup plus complexe, plus
// longue avec minuscules, majuscules, caractères spéciaux, etc.)
define('REQUEST_AUTHENTICATE', '657c080af2561edfea164e9f0c6af7bde673a3a310d78');
// On définit l'URL du script distant.
define('REMOTE_CALL_URL', 'http://serveur-donnees.com/remote.php');
try {
// Envoie la requête
$result = sendRequest(REMOTE_CALL_URL, REQUEST_AUTHENTICATE, 'membre::liste');
// Valide que le retour est bien un tableau
assert('is_array($result)');
} catch(Exception $e) {
// On génère une trace dans les logs
error_log('l\'appel à la fonction distante "membre::liste" a généré l\'erreur'
. ' suivante : ' . $e->getMessage());
// On redirige l'utilisateur vers une page de maintenance.
header('location: http://www.site-web.com/system-error.php');
exit;
}
// Le tableau est vide, il n'y a donc pas de membre inscrit
if (empty($result)) echo '<p>Aucun membre inscrit</p>';
else {
// On construit la liste des membres.
echo '<ul>';
foreach($result as $membre) {
// On valide que chaque élément contient bien un nom et prénom
assert('isset($membre[\'nom\']) and isset($membre[\'prenom\'])');
printf('<li>%s %s</li>',
// Ne pas oublier de protéger les caractères spéciaux
htmlspecialchars($membre['nom']),
htmlspecialchars($membre['prenom'])
);
}
echo '</ul>';
}
?>
Encore une fois, nous profitons de ce code pour apporter des éléments sur les bonnes pratiques en terme de gestion d'erreurs : Arrêtons d'utiliser des "die", "echo", "exit", "var_dump", etc. pour afficher des messages d'erreurs systèmes. En dehors de l'aspect inesthétique d'un tel procédé, cela peut être une porte ouverte pour des personnes mal intentionnées. En effet, dans la plupart des cas, vos messages intègrent des données techniques, comme la requête SQL qui a générée l'erreur, le nom du script avec son chemin complet, une variable interne, etc., ce qui facilite le travail malsain des pirates pour identifier une faille de sécurité dans votre application.
Ici, nous utilisons "error_log" pour insérer dans les logs d'erreurs du serveur notre exception afin de faciliter le travail du développeur, et nous redirigeons l'utilisateur vers une page plus esthétique l'informant d'une anomalie technique.
Envoi des requêtes en POST
Jetons maintenant un oeil dans les traces "access_log" d'Apache (pour ceux qui utilisent ce serveur HTTP) sur le serveur de services. Vous constaterez que la requête HTTP, en provenance de votre serveur client, y est enregistré avec la clef d'authentification, le nom de la fonction et les arguments.
GET /remote.php?auth=657c080af2561edfea164e9f0c6af7bde673a3a310d78&serviceName=membre::liste&args=a%3A0%3A%7B%7D
Cette trace peut présenter une faille de sécurité si un pirate y a accès par une voie détournée (exemple : Page de statistiques).
De plus, il est important de noter que certains serveurs HTTP limite la taille des requêtes GET, et si le nombre d'arguments est trop important, ou qu'un argument contient une donnée volumineuse, il y a un risque pour que l'appel soit rejeté.
Pour résoudre ces deux problèmes en même temps, nous allons donc envoyer nos données, non pas avec la méthode GET, mais avec la méthode POST. Pour faire cela, nous allons utiliser une nouveauté de PHP 5 : Les contextes de flux.
Observez la modification de l'appel à file_get_contents :
sendRequest2.php
<?php
// ... code complet sur googlecode
$opts = array (
// C'est pour de la communication via HTTP
'http'=>array (
// La méthode sera "POST"
'method' => 'POST',
// L'entête HTTP contient :
'header' =>
// La clef d'authentification
"x-auth: $auth\r\n" .
// Le nom de la fonction à appeler
"x-service-name: $serviceName\r\n" .
// Les données sont urlencodées
"Content-type: application/x-www-form-urlencoded\r\n" .
// Ne pas oublier de préciser la taille des données
"Content-Length: " . strlen($data) . "\r\n",
// Les données à envoyer
'content' => $data
)
);
// On crée un context pour le passer en paramètre de file_get_contents
$context = stream_context_create($opts);
// On appelle la page distante en masquant les erreurs de chargement
$content = @file_get_contents($url, false, $context);
// ....
?>
Comme vous pouvez le voir, l'appel à "file_get_contents" contient un troisième argument (le deuxième prenant sa valeur par défaut) qui correspond au contexte de flux. En fait, avec PHP 5, vous pouvez modifier le comportement des requêtes HTTP en changeant la méthode, les entêtes, le contenu des données, les timeout, etc.
Ici, pour les besoins de la démonstration, nous avons placé la clef d'authentification et le nom du service, non pas dans les données de la requête mais dans l'entête HTTP.
Pour traiter cette requête, il est donc nécessaire de modifier le code du script remote-2.php en remplaçant :
$_GET['auth']par$_SERVER['HTTP_X_AUTH']$_GET['serviceName']par$_SERVER['HTTP_X_SERVICE_NAME']$_GET['args']par$_POST['args']
On remarquera, au passage, la façon dont PHP interprète le nom des entêtes HTTP :
- Le nom est préfixé par "
HTTP_" - Les tirets sont remplacés par des soulignés
- Les lettres sont mises en majuscules.
Dans le prochain article, nous allons voir comment implémenter une répartition de charge.

Commentaires
1. Le mercredi 8 octobre 2008 à 17:40, par Sun Location
Ajouter un commentaire