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.
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 objetFormData
contenant le résultat de la sérialisation de l'objetRequest
; -
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 :
|
||||||
.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 :
|
||||||
.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 deResponse
et renvoie le résultat au client sous la forme d'un document JSON (type MIMEapplication/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'objetXMLHttpRequest
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.
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');