Déboguer facilement un service Web en PHP et JavaScript

S'il vous en reste, le développement d'un service Web en JavaScript (côté client) et PHP (côté serveur) est une bonne occasion de vous arracher des cheveux. Le débogage est une opération délicate, quand vous ne pouvez pas vous appuyer sur un système tel que Xdebug.
C'est que dans une application Web traditionnelle, un appel de service consiste simplement à appeler un script PHP dont le contenu est retourné par le serveur. Dès lors, toute erreur survenant lors de l'exécution du service s'affiche nécessairement à l'écran.
Il en va tout autrement dans le cas d'une application Web moderne, de type Progressive Web Application. Ici, l'appel de service s'effectue par le truchement d'un objet XMLHttpRequest. Le résultat de l'exécution du script est toujours renvoyé par le serveur, mais il parvient au client via une des propriétés de cet objet.
Dans ces conditions, la callback fournie à l'objet XMLHttpRequest doit analyser ce résultat pour déterminer si une erreur ou non a été rencontrée, et si oui remonter au développeur toutes les informations utiles qu'il voyait s'afficher à l'écran dans le cas d'une application Web traditionnelle : chemin d'accès au script, ligne dans le script où l'erreur est survenue, description de l'erreur, voire plus.
Déboguer facilement un service en JavaScript et PHP
La première chose à faire avant de se lancer dans le développement d'un service Web consiste donc à mettre en place une système de remontée des résultats d'un appel de service efficace. On aurait tort de s'en passer, car il ne faut finalement guère de lignes de code.
Puisqu'il s'agit de permettre le débogage d'un service Web, commençons par mettre en place une petite architecture. Nous fonctionnerons à l'économie, c'est-à-dire à l'abri de tous les standards qui compliquent et ralentissent le développement d'un service Web. En particulier, fi de XML, SOAP et autres WSDL : nous utiliserons du JSON pour sérialiser les données échangées, point barre.

La base d'un service Web, côté client

Dans notre architecture, le client dispose d'un objet Request :
function Request (service, params, debug=false) {
	this.service = service;
	this.params = params;
	this.debug = debug;
}
Les propriétés sont les suivantes :
.service Le nom du service à invoquer
.params L'objet (quelconque, pourvu qu'il soit sérialisable) contenant les paramètres à transmettre au service
.debug Un drapeau à true pour indiquer que le client veut traiter la requête en mode débogage (pas utilisé)
Le client dispose d'une implémentation homologue de l'objet Response du serveur, qui sera décrit plus loin. La seule différence est dans la méthode. Pour les propriétés, ce sont les mêmes : .service.code, .data, .debug.
Un service est un objet héritant de l'objet Service :
function Service (script, service, debug=false) {
	this.request = null;
	this.script = script;
	this.service = service;
	this.debug = debug;
}

Service.prototype.run = function (params=null) {
	// ...
};

Service.prototype.callback = function (resolve, reject) {
	// Décrit plus loin
};

Service.prototype.onSuccess = function (resolve, reject) {
	// Décrit plus loin
};

Service.prototype.onFailure = function (resolve, reject) {
	// Décrit plus loin
};
Le constructeur du service appelle celui de Service pour lui fournir l'URL du script à appeler, et le nom du service à invoquer via ce script.
Pour présenter une requête au service sur le serveur, le client crée l'objet correspondant au Service et en appelle la méthode .run (), en lui transmettant un objet qui contient les paramètres de la requête, sérialisable.
Le reste est pris en charge par Service.prototype.run () :
  • elle crée un objet Request en lui fournissant le nom du service, et l'objet contenant les paramètres à transmettre à ce dernier ;
  • elle crée un objet XMLHttpRequest pour envoyer la requête au script sous la forme d'un objet FormData contenant le résultat de la sérialisation de l'objet Request ;
  • elle retourne un objet Promise permettant de spécifier comment traiter le résultat de la requête, selon que cette dernière a réussi ou échoué - disposer d'un tel objet permet de chaîner les requêtes.
La callback fournie à l'objet XMLHttpRequest est la méthode Service.prototype.callback (). Elle est donc appelée par le navigateur pour rendre compte de l'échange HTTP, dont l'éventuelle arrivée d'une réponse du serveur.
Lorsqu'une réponse arrive, elle se présente sous la forme d'un document JSON qui n'est autre que le résultat de la sérialisation d'une instance de la classe Response définie en PHP sur le serveur. Le client désérialise ce JSON pour créer un objet JavaScript Response homologue, puis il en traite le contenu. Il peut s'agir d'une réponse véritable, le service ayant pu traiter la requête, ou d'un message d'erreur rencontrée par PHP ou le service.

La base d'un service Web, côté serveur

Comme déjà mentionné, le serveur dispose donc d'une classe - pas d'un objet, car nous sommes en PHP, et non en JavaScript - homologue de l'objet Response du client :
class Response {
	public $service, $code, $data, $debug;

	function __construct ($service, $code, $data, $debug=false) {
		$this->service = $service;
		$this->code = $code;
		$this->data = $data;
		$this->debug = $debug;
	}

	function toJSON () {
		$response = array ('code' => $this->code, 'data' => $this->data, 'debug' => $this->debug);
		return (json_encode ($response));
	}
}
Les propriétés sont les suivantes :
.service Le nom du service à l'origine de la réponse
.code
Le code de la réponse :
0 Le service a réussi à traiter la requête
1 PHP a rencontré une erreur dans le script
2 Le service a rencontré une erreur
.data
Les données de la réponse. C'est un objet spécifique au service, sauf en cas d'erreur (.code à 1 ou 2), où c'est un objet qui renseigne sur l'erreur :
.file Le fichier du script dans lequel l'erreur est survenue
.line La ligne du script à laquelle l'erreur est survenue
.message La description de l'erreur
.debug Un drapeau à true pour indiquer que le serveur veut traiter la réponse en mode débogage (pas utilisé)
Le serveur dispose d'une implémentation homologue de l'objet Request du client. La seule différence est dans la méthode. Pour les propriétés, ce sont les mêmes : .service, .params et debug.
Un service est une classe dérivée de la classe Service :
class Service {
	public $name, $debug;

	function __construct ($name, $debug=false) {
		$this->name = $name;
		$this->debug = $debug;
	}

	function run ($params) {
	}

	function reply ($response) {
		header ('Content-Type: application/json');
		echo ($response);
		exit ();
	}
}
Lorsqu'il est appelé sur requête du client, le script désérialise le contenu du paramètre CGI request pour reconstruire l'homologue de l'objet Request du client sous la forme d'une instance de la classe Request. Il utilise Request::service pour former le nom de la classe du service à invoquer, instancie cette classe, et en appelle la méthode ::run () en lui transmettant l'objet Request::params :
$request = Request::fromJSON (getCGI ('request'));
if (!class_exists ($request->service))
	throw (new Exception("Service \"$request->service\" does not exist."));
$service = new $request->service;
$service->run ($request->params);
Le reste est pris en charge par la méthode ::run () du service :
  • elle traite véritablement la requête et produit un résultat sous la forme d'un objet sérialisable en JSON ;
  • elle instancie la classe Response en fournissant son nom, un code témoignant de la réussite ou de l'échec, et le résultat ;
  • elle appelle la méthode Service::reply () qui sérialise l'instance de Response et renvoie le résultat au client sous la forme d'un document JSON (type MIME application/json).

Un exemple de service : lister des tables

Ces bases étant posées, il est possible de créer un service. Par exemple, un service qui renvoie la liste des tables dans la base de données utilisée sur un serveur MySQL, sous la forme d'un tableau de chaînes de caractères.
Côté client, il suffit d'écrire le constructeur d'un objet héritant de l'objet Service :
function ServiceGetTables (debug=false) {
	Service.call (this, "services.php", "GetTables", debug);
}
ServiceGetTables.prototype = Object.create (Service.prototype);
Pour le reste, il suffit de créer l'objet et d'appeler la méthode .run () dont il hérite de l'objet Service, en transmettant l'objet contenant les paramètres s'il y en a :
service = new ServiceGetTables ();
service.run ({ database: database }).then (...);
Sur le serveur, il suffit d'écrire une classe dérivant de la classe Service. Par exemple :
class GetTables extends Service {
	function __construct ($debug=false) {
		parent::__construct ('GetTables', $debug);
	}

	function run ($params) {
		parent::run ($params);
		mysqli_select_db ($GLOBALS['link'], $params->database);
		$result = mysqli_query ($GLOBALS['link'], 'SHOW TABLES');
		$list = array ();
		for ($count = 0 ; $count != mysqli_num_rows ($result) ; $count++) {
			$record = mysqli_fetch_array ($result);
			array_push ($list, $record[0]);
		}
		$response = new Response ($this->name, RESPONSE_SUCCESS, $list);
		$this->reply ($response->toJSON ());
	}
}
La variable globale $GLOBALS['link'] est le produit d'un appel à la fonction mysqli_connect (). C'est typiquement le genre d'appel récurrent lors du traitement d'une requête, qui peut à ce titre être factorisé au niveau de la fonction Service::run () :
class Service {
	// ...
	function run ($params) {
		$GLOBALS['link'] = mysqli_connect (MYSQL_HOSTNAME, MYSQL_USER, MYSQL_PASSWORD);
	}
	// ...
}
Pour le reste, tout est dans le code de la méthode ::run () qui doit générer la réponse à adresser au client via un appel à la méthode Service::reply () héritée.

Remonter les erreurs du serveur

Pour permettre le traitement de l'erreur par le client, qu'il s'agisse d'une erreur rencontrée par PHP ou le service, le script en charge de l'exécution du service prend le contrôle de la gestion des erreurs rencontrées par PHP. Pour cela, il installe son propre gestionnaire à l'aide de la fonction set_error_handler ().
Dès lors, les erreurs rencontrées par PHP - du moins celle qui peuvent parvenir à ce gestionnaire, car elles surviennent après son installation - peuvent être formatées comme les erreurs rencontrées par le service, et le client peut ainsi traiter les deux pareillement.
En l'occurrence, il s'agit de créer renvoyer une réponse comme celle que le client attend, si ce n'est qu'elle comporte un code signalant qu'une erreur a été rencontrée :
set_error_handler (function ($errorLevel, $errorMessage, $errorFile, $errorLine) {
	$message = array ('file' => $errorFile, 'line' => $errorLine, 'message' => $errorMessage);
	$response = new Response ("System", RESPONSE_ERROR_PHP, $message);
	echo ($response->toJSON ());
	exit ();
});
Toutefois, certaines erreurs ne peuvent être interceptées de la sorte. La documentation de set_error_handler () précise :
The following error types cannot be handled with a user defined function: E_ERROR, E_PARSE, E_CORE_ERROR, E_CORE_WARNING, E_COMPILE_ERROR, E_COMPILE_WARNING, and most of E_STRICT raised in the file where set_error_handler() is called.
Pour parvenir malgré tout à remonter ces erreurs, le script installe un autre gestionnaire à l'aide de la fonction register_shutdown_function () :
register_shutdown_function (function () {
	// Ne pas oublier de passer la directive display_errors à "stderr" dans php.ini !
	$error = error_get_last ();
	if (!$error)
		return;
	$message = array ('file' => $error['file'], 'line' => $error['line'], 'message' => $error['message']);
	$response = new Response ("System", RESPONSE_ERROR_PHP, $message);
	echo ($response->toJSON ());
	exit ();
});
Attention! pour que cette astuce fonctionne, il ne faut pas oublier de passer la directive display_errors à "stderr" dans php.ini. A défaut, PHP envoie l'erreur sur la sortie standard, ce qui signifie que l'erreur est affichée avant que le gestionnaire ne soit appelé, si bien que ce dernier perd l'avantage de pouvoir retourner l'erreur sous une forme exploitable par le client.
De son côté, le client peut donc être confronté à deux types d'erreurs :
  • des erreurs protocolaires (par exemple, une erreur 404) ;
  • des erreurs système (celles dont il vient d'être question).
Le client détecte les deux, et remonte l'information au développeur via la console. Cette information n'étant pas de la même nature selon le type d'erreur - aucune réponse n'est fournie par le script dans le premier cas, puisqu'il n'a pu être joint -, elle fait l'objet de mises en forme différenciées.
Tout cela s'articule avec la gestion de base de l'appel au service par le client. La méthode .run () de l'objet Service crée un objet XMLHttpRequest, en lui fournissant comme callback une fonction anonyme qui appelle la méthode .callback () de ce même objet :
Service.prototype.run = function (params=null) {
	return (new Promise ((resolve, reject) => {
		var formData;

		this.request = new XMLHttpRequest ();
		this.request.open ("POST", this.script, true);
		this.request.onreadystatechange = ((o, resolve, reject) => {
			return (() =>  o.callback.call (o, resolve, reject));
		}) (this, resolve, reject);
		request = new Request (this.service, params, this.debug);
		formData = new FormData ();
		formData.append ("request", JSON.stringify (request));
		if (this.debug)
			this.log (`Sending\n\t\tRequest:\t${JSON.stringify (request)}\n\t\t\t\tService:\t${this.service}\n\t\t\t\tParams:\t\t${JSON.stringify (params)}\n\t\t\t\tDebug:\t\t${this.debug}`);
		this.request.send (formData);
	}));
};

Service.prototype.callback = function (resolve, reject) {
	if (this.request.readyState !== 4)
		return;
	if (this.request.status !== 200)
		this.onFailure (resolve, reject);
	else
		this.onSuccess (resolve, reject);
};
  • Les erreurs protocolaires sont remontées dans la console par la méthode .onFailure (), qui s'appuie sur les informations que l'objet XMLHttpRequest contient :
    Service.prototype.onFailure = function (resolve, reject) {
    	if (this.debug)
    		this.log (`HTTP failure code ${this.request.status}`);
    	reject (`HTTP failure code ${this.request.status}`);
    };
    
  • Les erreurs système sont remontées dans la console par la méthode .onSuccess (), qui s'appuie sur les informations que la réponse du script contient :
    Service.prototype.onSuccess = function (resolve, reject) {
    	var response;
    
    	response = Response.prototype.fromJSON (this.request.responseText);
    	switch (response.code) {
    		case Service.prototype.RESPONSE_ERROR_PHP:
    			if (this.debug)
    				this.log (`Receiving\n\t\tError:\t\tERROR (PHP)\n\t\tFile:\t\t${response.data.file}\n\t\tLine:\t\t${response.data.line}\n\t\tMessage:\t${response.data.message}`);
    			reject (response.data);
    			break;
    		case Service.prototype.RESPONSE_ERROR_SERVICE:
    			if (this.debug)
    				this.log (`Receiving\n\t\tError:\t\tERROR (SERVICE)\n\t\tFile:\t\t${response.data.file}\n\t\tLine:\t\t${response.data.line}\n\t\tMessage:\t${response.data.message}`);
    			reject (response.data);
    			break;
    		case Service.prototype.RESPONSE_SUCCESS:
    			if (this.debug)
    				this.log (`Receiving\n\t\tSuccess\n\t\tResponse:\t${this.request.responseText}\n\t\t\t\tCode:\t${response.code}\n\t\t\t\tData:\t${JSON.stringify(response.data)}\n\t\t\t\tDebug:\t${response.debug}`);
    			resolve (response.data);
    			break;
    	}
    };
    
En dehors de ces erreurs, pour déboguer le script, il peut être utile de faire parvenir un message au client au niveau d'une certaine instruction dans ce dernier. Pour cela, la meilleure solution consiste à s'appuyer sur la levée d'exceptions. Par exemple :
throw (new Exception ("Message pour le client");
Cela implique que l'exécution du service se déroule dans le cadre d'une gestion des exceptions. C'est la raison pour laquelle le programme principal du script est enserré dans un bloc try { ... } catch { ... } :
try {
	$request = Request::fromJSON (getCGI ('request'));
	if (!class_exists ($request->service))
		throw (new Exception("Service \"$request->service\" does not exist."));
	$service = new $request->service;
	$service->run ($request->params);
}
catch (Exception $exception) {
	header ('Content-Type: application/json');
	$message = array ('file' => $exception->getFile (), 'line' => $exception->getLine (), 'message' => $exception->getMessage ());
	$response = new Response ("System", RESPONSE_ERROR_SERVICE, $message);
	echo ($response->toJSON ());
	exit ();
}

Enfin, déboguer...

Pour visualiser les erreurs pouvant survenir lors de l'invocation d'un service, il faut spécifier le drapeau debug à true lors de la création de l'objet du service sur le client, par défaut à false :
service = new ServiceGetDatabases (true);
Plus généralement, cela permet d'accéder au détail de tous les échanges. La requête et la réponse, que cette dernière remonte une réponse ou une erreur, sont alors affichées avec un luxe de détails dans la console. Par exemple :
// Log (start)
Script:		/test/index.php
Service:	GetTables
Message:	Sending
		Request:	{"service":"GetTables","params":{"database":"test"},"debug":true}
				Service:	GetTables
				Params:		{"database":"test"}
				Debug:		true
//Log (end)
// Log (start)
Script:		/test/services.php
Service:	GetTables
Message:	Receiving
		Success
		Response:	{"code":0,"data":["tst_log","tst_news","tst_pages","tst_sessions","tst_users"],"debug":false}
				Code:	0
				Data:	["tst_log","tst_news","tst_pages","tst_sessions","tst_users"]
				Debug:	false
//Log (end)
Cliquez ici pour récupérer une archive contenant le code complet d'une petite application Web qui permet de lister les bases de données, les tables et les champs à l'aide d'autant de services appelés de manière asynchrone, via une chaîne de promises sur le client.
Déboguer facilement un service en JavaScript et PHP
Pour faire fonctionner l'exemple, vous devrez adapter quelques constantes figurant dans le fichier common.php :
// Répertoire virtuel du site

define ("SITE_VPATH", "votre répertoire virtuel");

// Base de données MySQL

define ('MYSQL_HOSTNAME', 'votre hôte MySQL');
define ('MYSQL_USER', 'votre identifiant');
define ('MYSQL_PASSWORD', 'votre mot de passe');
Déboguer facilement un service Web en PHP et JavaScript