Requête Ajax Cross-domain via un proxy

2 juin 2008Ajax, Javascript, cross-domain
 Cet article a été rédigé il y a plusieurs années et peut ne plus être tout à fait à jour…

Nous avons vu au cours d'un précédent article comment contourner la limitation de Same Origin Policy en utilisant la balise <script> pour effectuer des requêtes Ajax vers un serveur distant (nom de domaine différent du nom de domaine sur lequel notre application est déployée).

Cette solution nous imposait de modifier notre code côté navigateur, pour ne plus utiliser une instance d'object XMLHttpRequest, mais passer par de la génération dynamique de balises HTML <script>.

Nous allons à présent voir comment effectuer des requêtes Ajax distantes en passant par un proxy, de manière à ne pas avoir à ré-écrire tout notre code de chargement de données depuis l'application HTML+Javascript.

Nous commencerons par l'écriture d'un proxy en PHP, par lequel nous passerons pour effectuer nos appels Ajax, et, en seconde partie, nous verrons comment utiliser les fonctionnalités de proxy-ing du serveur Web Apache, pour ne pas avoir à écrire nous-même le proxy.


Application d'exemple

Pour cet article, nous allons reprendre le principe de l'application d'exemple que nous avions écrit pour l'article Requête Ajax Cross-domain avec la balise <script> : une application de calcul de multiplications, avec un calcul des résultats côté serveur :

  • Deux champs de saisie,
  • Un bouton lançant l'appel Ajax : les deux valeurs saisies sont envoyées au serveur,
  • Et un champ pour afficher en retour la valeur calculée par le serveur.

Voici une capture d'écran de l'application : application-multiplier.png

Tout ce qu'il y a de plus basique, n'est-ce pas ?


Page HTML

Voici le code de la page HTML que nous utiliserons pour nos exemples :

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN"
 "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
<html>
<head>
  <meta http-equiv="content-type" content="text/html; charset=utf-8"/>
  <title>Démo Ajax serveur distant</title>
  <script type="text/javascript" src="lib/prototype.js"></script>
  <script type="text/javascript" src="demo-ajaxdistant-2.js"></script>
</head>
<body>
  <form action="" id="multiplier" onsubmit="return false;">
    <div>
      <input type="text" maxlength="6" size="8" id="a" name="a" />
      *
      <input type="text" maxlength="6" size="8" id="b" name="b" />
      <input type="button" name="do" id="do" value="=" onclick="multiply();" />
      <input type="text" size="8" id="result" name="result" readonly="readonly" />
    </div>
  </form>
  <div id="msg">&nbsp;</div>
  
  <script type="text/javascript">
    // Appui sur "ENTER" dans le formulaire => lance la multiplication
    Event.observe($('multiplier'), 'keydown', function (evt) {
        if (evt.keyCode == 13) {
            multiply();
        }
    });
  </script>
  
</body>
</html>

Comme prévu, deux champs de saisie, un bouton, et un champ pour afficher le résultat.
Un clic sur le bouton déclenchera l'appel de la fonction Javascript multiply, chargée de calculer le résultat de la multiplication, et de l'afficher dans le champ prévu à cet effet.


Code Javascript

Côté Javascript, pour faciliter les écritures, et ne pas avoir à changer la partie HTML de notre application, nous passerons par un alias : pour chaque nouvelle version, nous affecterons une nouvelle fonction à la variable multiply, par laquelle l'application passera toujours.

Par exemple, pour passer par une requête Ajax "normale" :

var multiply = multiply_ajaxLocal;

Avec le code suivant pour réaliser l'appel Ajax en lui-même :
(Ici encore, je choisis d'utiliser le Framework Prototype ; vous trouverez plusieurs articles à son sujet sur ce site ^^)

var multiply_ajaxBase = function (url) {
    var a = parseFloat($F('a'));
    var b = parseFloat($F('b'));
    var myAjax = new Ajax.Request(
    url,
    {
      method: 'get',
      parameters: {
        a: a,
        b: b
      },
      onSuccess: function (xhr, jsonValue) {
          if (jsonValue) {
            $('result').value = jsonValue;
          }
        }, // onSuccess
      onException: function (xhr, e) {
          $('msg').innerHTML = e;
        } // onException
    });
}; // multiply_ajaxBase

var multiply_ajaxLocal = function () {
    multiply_ajaxBase('./demo-ajaxdistant-2-server-xhr.php');

}; // multiply_ajaxLocal

Avec cette portion de code, si vous cliquez sur le bouton lançant le calcul, la valeur calculée s'affichera en retour :

  • Les deux valeurs saisies sont envoyées à un script PHP côté serveur,
  • ce script calcule de résultat de la multiplication et le retourne,
  • et le code Javascript affiche le résultat.


Code PHP effectuant le calcul côté serveur

Et puisque la multiplication s'effectue côté serveur, voici le code responsable du calcul :

// TODO sécurité, vérifications, ...

$a = floatval($_GET['a']);
$b = floatval($_GET['b']);
$result = $a * $b;

// prototype.js attend des données dans 
// une en-tête nommée X-JSON :
header('X-JSON:' . json_encode($result));

Nous récupérons les deux valeurs reçues en paramètres, effectuons la multiplication, et renvoyons le résultat au sein d'une en-tête X-JSON, comme attendu par le Framework prototype.js.

L'inconvénient de ce principe est qu'il est basé sur une instance d'objet XMLHttpRequest. Cela signifie qu'il ne nous sera pas possible, de la sorte, d'effectuer de requête vers un serveur distant - dont le nom de domaine ne soit pas le même que celui sur lequel est déployée notre application.


Requête HTTP vers un serveur distant en passant par un proxy PHP

Pour résoudre ce problème, une première solution est possible :

  • Notre application effectue une requête Ajax vers une page déployée sur notre serveur - ce qui est tout à fait possible;
  • Cette page récupère les données reçues en paramètres,
  • Et effectue à son tour une requête HTTP vers le script distant, situé sur un autre domaine : côté serveur, nous n'avons pas la contrainte de SOP
  • Le script sur le serveur distant récupère lui-aussi les paramètres, et effectue la multiplication,
  • Puis retourne le résultat de cette multiplication au programme situé sur notre serveur,
  • Qui, enfin, retourne ce même résultat à notre code Javascript,
  • Qui peut finir par l'afficher.


Code javascript

Côté client, le principe reste identique à ce que nous faisions plus haut avec notre requête Ajax non cross-domain : nous effectuons une requête Ajax vers le programme proxy, déployé sur notre serveur :

var multiply_ajaxDistantViaProxyPHP = function () {
    multiply_ajaxBase('./demo-ajaxdistant-2-proxy.php');
} // multiply_ajaxDistantViaProxyPHP

Et nous faisons pointer notre alias multiply vers cette nouvelle méthode :

var multiply = multiply_ajaxDistantViaProxyPHP;

(de manière à toujours pouvoir déclencher le calcul en appelant la fonction multiply - pour ne pas avoir à changer le code de la page HTML)


Code du proxy

Comme indiqué plus haut, le programme proxy doit :

  • Récupérer les paramètres reçus via la requête Ajax,
  • Effectuer une requête HTTP vers la page distante,
  • Et renvoyer le résultat.

En PHP, en utilisant l'extension curl, cela pourrait donner ceci :

// TODO sécurité, vérifications, ...
$a = floatval($_GET['a']);
$b = floatval($_GET['b']);

// URL vers laquelle émettre la "vraie" requête :
$url = 'http://distant.example.com/ajax-distant/demo-ajaxdistant-2-server-xhr.php?'
       . 'a=' . urlencode($a)
       . '&b=' . urlencode($b);

// Requête HTTP vers le "vrai" serveur
$ch = curl_init($url);
curl_setopt($ch, CURLOPT_HEADER, true);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_CRLF, true);
curl_setopt($ch, CURLOPT_TIMEOUT, 5);
$retourDistant = curl_exec($ch);
curl_close($ch);

// Récupération du résultat (en-tête X-JSON)
$headers = explode("\r\n", $retourDistant);
foreach ($headers as $header) {
    $matches = array();
    if (preg_match('/^X-JSON: (.*)$/', $header, $matches)) {
        // Et sortie, telle qu'attendue par prototype.js
        header('X-JSON: ' . $matches[1]);
        die;
    }
}

La difficulté ici réside dans le fait que le service distant renvoi son résultat sous forme d'une en-tête X-JSON, et non directement dans le corps de la réponse ; cela demande donc quelques lignes de traitements supplémentaires, pour extraire les en-tête et déterminer lequel nous intéresse.

Ce type de solution est totalement transparent pour le côté client : la seule chose à - éventuellement - modifier est l'URL du service : plutôt que d'essayer d'appeler un appel Ajax vers une URL distante (ce qui est interdit), on l'effectue vers une URL locale.

Par contre, cette solution demande du travail côté serveur : il faut développer le programme proxy ; éventuellement, même, ce développement peut être spécifique : ça a été le cas ici :

  • nous n'avons rien codé de générique au niveau de la récupération des paramètres,
  • et nous avons dû inclure du code spécifique pour gérer le fait que le serveur distant nous renvoyait ses résultats sous forme d'une en-tête HTTP, et non en corps de page.

Cette solution inclut aussi un surcoût en terme de charge serveur : chaque requête Ajax émise par votre application va provoquer l'appel - et l'exécution - d'une page sur votre serveur, même si le but était d'appeler une page située sur un autre serveur !


Requête HTTP vers un serveur distant en passant par un proxy Apache

Pour éviter de déclencher l'exécution d'une page PHP à chaque requête vers un service distant - et pour éviter d'avoir à développer et maintenir des scripts proxy - vous pouvez utiliser les fonctionnalités de proxy-ing de votre serveur Web.

Le principe étant d'effectuer les requêtes Ajax vers des URLs spécifiques de votre serveur Web, qui seront interceptées par le serveur avant d'arriver à la couche PHP.

De la sorte, vous économisez le temps d'interprétation d'un script PHP.

Sous Apache, pour activer le proxy-ing de requêtes HTTP, il faut activer les modules suivants :

  • mod_proxy
  • et mod_proxy_http
    • C'est celui-ci qui fera le travail, mais il dépend du premier.

Sous une distribution Ubuntu, cela se fait à l'aide des commandes suivantes :

ln -s /etc/apache2/mods-available/proxy.load /etc/apache2/mods-enabled/proxy.load
ln -s /etc/apache2/mods-available/proxy_http.load /etc/apache2/mods-enabled/proxy_http.load

Vous aurez certainement à adapter en fonction de votre configuration et/ou de votre distribution, mais cela revient finalement à ajouter les deux lignes suivantes à votre fichier de configuration Apache :

LoadModule proxy_module /usr/lib/apache2/modules/mod_proxy.so
LoadModule proxy_http_module /usr/lib/apache2/modules/mod_proxy_http.so

Là encore, vous aurez certainement à adapter en fonction de votre configuration et/ou distribution.

Ensuite, au niveau de la configuration de votre VirtualHost, il faut activer le Proxy-ing.

Attention, par contre : nous voulons créer un ReverseProxy, et non un ForwardProxy !

  • Dans le premier cas, Apache n'agira comme proxy que pour les pages spécifiées - c'est ce que nous voulons : nous souhaitons qu'une URL locale soit proxy-ée vers une URL distante, et c'est tout.
  • Dans le second cas, Apache agira en tant que proxy permettant à n'importe qui de passer par lui pour accéder à n'importe quelle page de n'importe quel site. Ce n'est probablement pas ce que vous voulez !

De manière générale, la configuration de votre VirtualHost ressemblera aux lignes suivantes :

<VirtualHost *>
    ServerName tests
    DocumentRoot /home/squale/developpement/tests
    
    # Configuration pour le multiplier Ajax en cross-domain :
    ProxyRequests Off  # On ne veut pas activer un "forward proxy", mais uniquement un "Reverse proxy"
    <Proxy *>
        # Permission : tout le monde
        Order deny,allow
        Allow from all
    </Proxy>
    
    # /ajax-distant/multiplier-proxy est un proxy pour http://distant.example.com/ajax-distant/multiplier-server.php
    ProxyPass /ajax-distant/multiplier-proxy http://distant.example.com/ajax-distant/demo-ajaxdistant-2-server-xhr.php
    #ProxyPassReverse /ajax-distant/multiplier-proxy http://distant.example.com/ajax-distant/demo-ajaxdistant-2-server-xhr.php
    
    # Et on veut que Apache modifie les cookies créés par "distant.example.com", pour que le client croie qu'ils ont été créés par "tests"
    ProxyPassReverseCookieDomain distant.example.com tests
    
    CustomLog "/var/log/apache2/access.log" combined
</VirtualHost>

Quelques points à noter :

  • On désactive le mode "forward proxy" ; de la sorte, notre serveur ne pourra pas être utilisé par n'importe qui pour accéder à n'importe quel site par le biais de notre machine.
  • On donne la permission à tout le monde d'utiliser notre proxy : on veut que tous les visiteurs de notre site puissent effectuer des requêtes Ajax via ce mécanisme.
  • On active le proxy-ing pour une URL :
    • Toutes les requêtes émises à destination de "/ajax-distant/multiplier-proxy" seront, de façon totalement transparente, transmises vers "http://distant.example.com/ajax-distant/demo-ajaxdistant-2-server-xhr.php"
    • Et les données retournées par seconde URL seront utilisées comme valeur de retour de la première.
  • Et enfin, on active la ré-écriture des cookies créés par la page distante, pour que le navigateur croie qu'ils ont été créés en local et qu'ils font parti du même si que l'application cliente.
    • Note : pour l'exemple utilisé pour cet article, ce point n'est d'aucune utilité.

A présent, lorsque l'on effectuera une requête HTTP à destination de "http://tests/ajax-distant/multiplier-proxy?a=2&b=6", le serveur Apache la transmettra automatiquement vers "http://distant.example.com/ajax-distant/demo-ajaxdistant-2-server-xhr.php?a=2&b=6".

Cela revient au système que nous avions mis en place précédemment, sauf que, cette fois, tout le travail est effectué par le serveur Apache, sans que nous n'ayons à développer de programme tenant le rôle de proxy ; et, à l'exécution, sans qu'un script PHP n'ait à être lancé.

Pour les plus curieux, si, dans le script PHP effectivement appelé, nous loggons dans un fichier le contenu de la variable $_SERVER, nous remarquons plusieurs informations intéressantes :

Array
(
    [HTTP_HOST] => distant.example.com
    [HTTP_X_REQUESTED_WITH] => XMLHttpRequest
    [HTTP_X_PROTOTYPE_VERSION] => 1.6.0.2
    [HTTP_REFERER] => http://tests/ajax-distant/demo-ajaxdistant-2.html
    [HTTP_MAX_FORWARDS] => 10
    [HTTP_X_FORWARDED_FOR] => 127.0.1.1
    [HTTP_X_FORWARDED_HOST] => tests
    [HTTP_X_FORWARDED_SERVER] => tests
    [SERVER_NAME] => distant.example.com
    [SERVER_PROTOCOL] => HTTP/1.1
    [REQUEST_METHOD] => GET
    [QUERY_STRING] => a=1&b=2
    [REQUEST_URI] => /ajax-distant/demo-ajaxdistant-2-server-xhr.php?a=1&b=2
)

Le serveur réalisant effectivement le traitement a reçu les en-têtes HTTP émises par la requête de départ (HTTP_X_PROTOTYPE_VERSION, par exemple, n'est certainement pas une information ajoutée par le proxy !), mais aussi quelques informations lui indiquant qu'il traite une requête qui a été forwardée.

Dans les logs Apache, on aura, sur la machine faisant office de proxy :

127.0.1.1 - - [14/Apr/2008:13:56:08 +0200] "GET /ajax-distant/multiplier-proxy?a=2&b=7 HTTP/1.1" 200 - "http://tests/ajax-distant/demo-ajaxdistant-2.html" "Mozilla/5.0 (X11; U; Linux i686; en-US; rv:1.9b5) Gecko/2008040603 Firefox/3.0b5"

Et, sur la machine effectuant le traitement :

127.0.0.1 - - [14/Apr/2008:13:56:08 +0200] "GET /ajax-distant/demo-ajaxdistant-2-server-xhr.php?a=2&b=7 HTTP/1.1" 200 - "http://tests/ajax-distant/demo-ajaxdistant-2.html" "Mozilla/5.0 (X11; U; Linux i686; en-US; rv:1.9b5) Gecko/2008040603 Firefox/3.0b5"


Rapide comparaison entre les deux systèmes

Pour terminer, très rapidement, voici une brève comparaison des deux principes.

Proxy sous forme d'un script PHP

  • Contre : Ce système demande de développer une page PHP faisant office de proxy, ce qui prend du temps, demande d'être testé, ...
  • Pour : Aucun besoin de modifier la configuration de votre serveur
    • Adapté en hébergement mutualisé, donc, notamment
  • En fonction de vos besoins : Possibilité de personnaliser la requête / réponse au niveau du proxy

En terme de performances :

ab -c 10 -n 10000 http://tests/ajax-distant/demo-ajaxdistant-2-proxy.php?a=2&b=6

Concurrency Level:      10
Time taken for tests:   8.290562 seconds
Complete requests:      10000
Failed requests:        0
Write errors:           0
Total transferred:      13316655 bytes
HTML transferred:       10195095 bytes
Requests per second:    1206.19 [#/sec] (mean)
Time per request:       8.291 [ms] (mean)
Time per request:       0.829 [ms] (mean, across all concurrent requests)
Transfer rate:          1568.53 [Kbytes/sec] received

1200 requêtes par seconde, avec un temps moyen de 8 ms par requête.
(Ceci avec l'extension APC d'activée ; sans, on tombe à 1100 requêtes par seconde, avec un temps moyen de 8.8 ms par requête)
(Et en désactivant Xdebug, on monte à 1400 requêtes par seconde, avec un temps moyen de 7 ms par requête)


Proxy au niveau Apache

  • Contre : nécessité de modifier la configuration Apache
  • En fonction de vos besoins : impossibilité de personnaliser la requête / réponse
  • Pour : pas de programme spécifique à développer / tester, facilité de mise en place

En terme de performances :

ab -c 10 -n 10000 http://tests/ajax-distant/multiplier-proxy?a=2&b=6

Concurrency Level:      10
Time taken for tests:   4.994163 seconds
Complete requests:      10000
Failed requests:        0
Write errors:           0
Total transferred:      13460000 bytes
HTML transferred:       10340000 bytes
Requests per second:    2002.34 [#/sec] (mean)
Time per request:       4.994 [ms] (mean)
Time per request:       0.499 [ms] (mean, across all concurrent requests)
Transfer rate:          2631.87 [Kbytes/sec] received

2000 requêtes par seconde, avec un temps moyen de 5 ms par requête.
(Ici encore, avec l'extension APC activée ; sans, on tombe à 1900 requêtes par seconde, avec un temps moyen de 5.3 ms par requête)
(Et en désactivant Xdebug, on monte à 3300 requêtes par seconde, avec un temps moyen de 3 ms par requête)

Au niveau des performances, que ce soit avec ou sans les extensions APC (conseillée sur un serveur de prod) et Xdebug (conseillée uniquement sur une machine de développement), pas de doute, la solution à base de proxy Apache est à privilégier, par rapport à une solution basée sur un proxy écrit en PHP.


Vous savez maintenant comment, à faible coût, mettre en place des requêtes Ajax cross-domain, sans avoir à souffrir de la contrainte de Same Origin Policy imposée par les navigateurs, et sans les inconvénients (réécriture d'une partie du code de votre application, sécurité) de passer par la balise <script> pour effectuer vos appels.

Si vous travaillez avec un serveur Web autre que Apache, vous pouvez certainement utiliser le même genre de mécanisme de Proxy-ing ; c'est possible sous LightHTTPD, me semble-t'il, en tout cas.