lunes, 29 de julio de 2013

Patrón PHP POST/Redirect/GET

Todas las aplicaciones web tienen dos objetivos básicos:
- Poder obtener información enviada por el usuario desde un navegador.
- Mostrar resultados obtenidos por el procesado de la información anterior.
Esto se consigue mediante los métodos HTTP POST y GET.
En anteriores tutoriales  ya describimos las peculiaridades de utilizar cada método (POST/GET) para el traslado de información en la comunicación cliente-servidor. Y concluimos que para el envío de información importante desde el navegador al servidor era necesario utilizar el método POST. Debido a que los datos no viajaban en la URL, sino que lo hacían junto con las cabeceras.
Además también comentamos que los datos enviados mediante POST son almacenados en la memoria caché de los navegadores. Y aunque a priori este hecho no pueda parecer problemático, vamos a presentar un problema por el cual se puede ocurrir un doble envío de datos.

Para evitar tener que solicitar varias veces los mismos recursos (datos o imágenes) el navegador almacena en el disco duro dichos recursos. De esta forma, si alguien entra  en una página web, el navegador intentará cargar recursos que tenga almacenados. Ya que si no es la primera vez que se visita la página, el navegador habrá guardado recursos en caché.

El problema viene cuando una página se ha generado de forma dinámica (formulario correcto enviado) dependiendo de los datos enviados por POST y ocurre alguna de las siguientes situaciones:
- Se recarga la página utilizando el botón de recarga del navegador o F5.
- Se usa el botón de ir a la página anterior y seguidamente usar el botón de ir a la página siguiente.
Si no se han tenido en cuenta estas situaciones el resultado es el reenvío de todos los datos para obtener nuevamente la página mencionada y por lo tanto el procesado de los mismos. Lo que puede provocar, por ejemplo, un nuevo almacenado de datos en bases de datos.

Este sería el esquema:

Esquema problema reenvío datos al servidor
Los navegadores, conocedores de la importancia del envío de datos por POST muestran un mensaje alertando del posible envío de los mismos datos al servidor. Pero pienso que es un mensaje inadecuado para los usuarios ya que es muy técnico para un usuario medio.  Además lo correcto es que el servidor sepa tratar estas situaciones y no dejarlo en las manos del usuario. Que ya sea por inexperiencia o por error, puede provocar una situación problemática.
Imaginemos el siguiente escenario: En una página de compras tras realizar la compra se espera el mensaje de confirmación de la misma. Pero resulta que este mensaje de confirmación no aparece. Y el usuario presiona el botón de recargar la página. Con lo que aparece el mensaje técnico del navegador que como usuario medio puede no entender. Por lo que este usuario acepta el aviso con un resultado que no es de su agrado: al mirar el saldo de su tarjeta se han cargado dos compras. Con el consiguiente enfado (seguramente nunca vuelva a usar la página de compras.).
Por lo tanto, siempre es mejor haber que el sistema este preparado para evitar el posible doble envío en lugar de cargar la responsabilidad en el usuario.

Ejemplo del problema


Imaginemos un simple script que procesa los datos de un formulario de contacto. Sean correctos o no los datos de envío se mostrará el formulario. Si son correctos los datos, a parte de enviarse un email, el formulario mostrará un mensaje. Para el ejemplo, dicho mensaje mostrará un contador de mensajes enviados (contador guardado en sesión).

 <?php  
 session_start();  
 $respuesta = "";  
 if (isset($_POST['nombre_contacto'], $_POST['telefono_contacto'], $_POST['email_contacto'], $_POST['comments'])) {  
   $nombre = trim($_POST['nombre_contacto']);  
   $tlf = $_POST['telefono_contacto'];  
   $mail = $_POST['email_contacto'];  
   $mensaje = trim($_POST['comments']);  
   $dire = 'informacion';  
   $host = 'ejemplo.com';  
   //mirar seguridad htmlentities?  
   if (($nombre != "") && ($mensaje != "") && $tlf != "" && filter_var($mail, FILTER_VALIDATE_EMAIL)) {  
     $dia = date("d");  
     $mes = date("m");  
     $año = date("Y");  
     $target = $dire . "@" . $host; # la dirección electrónica a la que enviar el email  
     $subject = "Contacto desde www." . $host; #el encabezado  
     $mens = '<html>  
        <head><title>Formulario de Contacto de www.' . $host . '</title></head>  
        <body>  
        <table>  
        <tr><th colspan=2 bgcolor=#666666><font color=#ffffff>Datos de Contacto</font></th></tr>  
        <tr><td><b>Nombre:</b> ' . $nombre . '</td></tr>  
        <tr><td><b>Tlf:</b> ' . $tlf . '</td></tr>  
        <tr><td><b>E-Mail:</b> <a href="mailto:' . $mail . '">' . $mail . '</a></td></tr>  
        <tr><th colspan=2 bgcolor=#666666><font color=#ffffff>Contenido del Mensaje:</font></th></tr>  
        <tr><td><div align="justify">' . $mensaje . '</div></td></tr>  
        <tr><th colspan=2 bgcolor=#666666><font color=#ffffff>Fecha de envio del mensaje:</font></th></tr>  
        <tr><td><center>' . $dia . '-' . $mes . '-' . $año . '</center></td></tr>  
        </table>  
        </body>  
        </html>     ';  
     $headers = "";  
     $headers .= "X-Sender: $target <$target>\n"; //   
     $headers .= "From: formularios@$host <formularios@$host>\n";  
     $headers .= "Reply-To: formularios@$host <formularios$host>\n";  
     $headers .= "Date: " . date("r") . "\n";  
     $headers .= "Message-ID: <" . date("YmdHis") . "formulario@" . $_SERVER['SERVER_NAME'] . ">\n";  
     $headers .= "Return-Path: formularios$host <formularios$host>\n";  
     $headers .= "Delivered-to: formularios$host <formularios$host>\n";  
     $headers .= "MIME-Version: 1.0\n";  
     $headers .= "Content-type: text/html;charset=utf-8\n";  
     $headers .= "X-Priority: 1\n";  
     $headers .= "Importance: High\n";  
     $headers .= "X-MSMail-Priority: High\n";  
     $headers .= "X-Mailer: Formulario $host con PHP!\n";  
     mail($target, $subject, $mens, $headers);  
     if(isset($_SESSION['enviados'])) $_SESSION['enviados']++;  
     else $_SESSION['enviados'] = 1;  
     $respuesta = "email nº" . $_SESSION['enviados'] ." enviado con éxito";  
   }  
 }  
 ?>  
 <html>  
   <head>  
     <meta charset="utf-8"/>  
     <style type="text/css">  
       fieldset{  
         text-decoration: none;  
       }  
       ol,ul {   
         list-style: none;   
       }   
       .span6{  
         width: 460px;  
       }  
       .box{
         width:600px;
       }
       #contacto legend{  
         border-bottom: 1px solid #666666;  
         margin-bottom: 20px;  
       }  
       #contacto li label{  
         width:150px;  
         display:block;  
       }  
       #contacto textarea{  
         resize:none;  
         width: 458px;  
       }  
       #botones,#camposObs{  
         margin-top: 20px;  
       }  
     </style>  
   </head>  
   <body>  
     <p> <?php echo $respuesta; ?></p>  
     <form action="" class="box" id="contacto" method="post">  
       <div style="margin:0;padding:0;display:inline">                      
         <fieldset>  
           <legend>Contacto</legend>  
           <ul class="control-group string required unstyled">  
             <li>  
               <label id="nombre_label" for="nombre_contacto">Nombre y Apellidos *</label>  
               <input name="nombre_contacto" id="nombre_contacto" type="text" class="span6" placeholder="Escriba su nombre y apellidos..."/>  
             </li>  
             <li>  
               <label id="telefono_label" for="telefono_contacto">Teléfono *</label>  
               <input type="text" name="telefono_contacto" id="telefono_contacto" class="span6" placeholder="Escriba su nº de teléfono..."/>  
             </li>  
             <li>  
               <label id="email_label" for="email_contacto">E-mail *</label>  
               <input type="text" name="email_contacto" id="email_contacto" class="span6" placeholder="Escriba su direccion de correo electrónico..."/>  
             </li>  
             <li>  
               <label id="comments_label" for="comments">Mensaje *</label>  
               <textarea class="input-xlarge" name="comments" id="comments" rows="3" cols="20" placeholder="Escriba el mensaje que quiere que nos llegue..."></textarea>  
             </li>       
             <li id="camposObs">  
               (*) Campos obligatorios.  
             </li>                        
             <li id="botones">     
               <input class="btn" name="borrar" type="reset" id="btn_borrar" value="Borrar"/>  
               <input class="btn" name="enviar" id="btn_enviar" type="submit" value="Enviar"/>  
             </li>  
           </ul>  
         </fieldset>  
       </div>  
     </form>  
   </body>  
 </html>  

Tras enviar el formulario veremos que como respuesta obtendremos el mismo formulario vacío y con el mensaje que indica que se ha enviado 1 mensaje.
Si a continuación presionamos F5, veremos como se muestra el mensaje emergente que indica que si continuamos se va a repetir alguna acción (envío de datos):

Mensaje del navegador reenvío de datos al servidor

Y si se acepta el mensaje el resultado es que se habrá enviado otro email e incrementado el contador:

Ejemplo formulario con reenvío de datos al servidor

Como vemos en este sencillo ejemplo, una simple recarga de la página puede ocasionar el reenvío de los datos. Ocasionando duplicados de información. En este ejemplo no es muy grave pero en un sistema real, como el del escenario de compras presentado anteriormente, puede ocasionar más de un problemas para el servidor y para el cliente.

Solución


La solución parte de la separación del script que genera el formulario de envío de datos y el script que procesa los datos recibidos por dicho formulario. Y finalmente que este último realice una redirección GET inmediatamente después del procesado.  Ya sea al script que muestra la página tras procesado exitoso o nuevamente al script que crea el formulario (normalmente por datos erroneos ). De ahí el nombre del patrón Post/Redireccíon/Get.

- De esta manera cuando recarguemos (F5) la página actual (respuesta al envió correcto de datos) estamos haciendo una petición GET. La cual no lleva datos del usuario incorporados en la petición. Y el mensaje (pop-up) de advertencia de reenvío de datos ya no aparece porque evidentemente no se está reenviado datos por método POST.
- Y si estamos en la página de respuesta por envió correcto de datos, al presionar el botón 'atrás' el navegador retornará a la página con el formulario (con los datos introducidos en los campos de texto) antes de haberlo enviado.  Recuerda que el script intermedio que se encarga de procesar se ejecuta en el servidor y el navegador solo recibe su respuesta (que es la que se guardará en caché). Y si seguidamente se presiona el botón 'adelante'  se mostrará  la página de respuesta al formulario ya que en el proceso se llegó anteriormente a ella mediante una redirección GET.  Con lo que tampoco hay peligro con el reenvío de datos.

Visto desde el punto de vista de PHP, se redirigirá la respuesta generada por la petición POST usando el código de respuesta HTTP 303 y renderizando (mostrando) una página diferente usando una petición GET:

 header("HTTP/1.1 303 See Other");  
 header("Location: http://pagina_reenvio.php");  
 exit();  

O también utilizando la siguiente sintaxis de la función header:

 header("Location: http://pagina_reenvio.php",true,303);  
 exit();  

Recuerda que una redirección mediante header() es una petición URL a otro script y por lo tanto una petición GET.

El nuevo esquema, tras la aplicación de patrón, es el siguiente:
Esquema formulario sin reenvío de datos al servidor
Nota: recuerda que una respuesta de redirección por parte de servidor provoca que el navegador haga una solicitud GET (al servidor) a la página de la redirección. Y si se recarga la página de respuesta se estará lanzando una petición GET hacia el servidor. Por lo que no se enviarán ningún dato POST en la comunicación. Y  no podrá haber doble envío de datos.

Vamos a ver como se podría solucionar el problema del duplicado de envío de datos POST del ejemplo presentado anteriormente (formulario de contacto). Ahora hemos separado el script en dos. Por un lado contacto2.php se encargará de generar el formulario y mostrar el mensaje del número de email enviados. Y envia_email.php se encargará del procesado de datos POST del formulario, del envío del email y la redirección a la página de respuesta. Que en este ejemplo vuelve a ser el formulario.

contacto2.php
 <?php  
 session_start();  
 $respuesta="";  
 if(isset($_SESSION['enviados2']))  
   $respuesta = "email nº" . $_SESSION['enviados2'] . " enviado con éxito";  
 ?>  
 <html>  
   <head>  
     <meta charset="utf-8"/>  
     <style type="text/css">  
       fieldset{  
         text-decoration: none;  
       }  
       ol,ul {   
         list-style: none;   
       }   
       .box{  
         width:600px;  
       }  
       .span6{  
         width: 460px;  
       }  
       #contacto legend{  
         border-bottom: 1px solid #666666;  
         margin-bottom: 20px;  
       }  
       #contacto li label{  
         width:150px;  
         display:block;  
       }  
       #contacto textarea{  
         resize:none;  
         width: 458px;  
       }  
       #botones,#camposObs{  
         margin-top: 20px;  
       }  
     </style>  
   </head>  
   <body>  
     <p> <?php echo $respuesta; ?></p>  
     <form action="envia_email.php" class="box" id="contacto" method="post">  
       <div style="margin:0;padding:0;display:inline">                      
         <fieldset>  
           <legend>Contacto</legend>  
           <ul class="control-group string required unstyled">  
             <li>  
               <label id="nombre_label" for="nombre_contacto">Nombre y Apellidos *</label>  
               <input name="nombre_contacto" id="nombre_contacto" type="text" class="span6" placeholder="Escriba su nombre y apellidos..."/>  
             </li>  
             <li>  
               <label id="telefono_label" for="telefono_contacto">Teléfono *</label>  
               <input type="text" name="telefono_contacto" id="telefono_contacto" class="span6" placeholder="Escriba su nº de teléfono..."/>  
             </li>  
             <li>  
               <label id="email_label" for="email_contacto">E-mail *</label>  
               <input type="text" name="email_contacto" id="email_contacto" class="span6" placeholder="Escriba su direccion de correo electrónico..."/>  
             </li>  
             <li>  
               <label id="comments_label" for="comments">Mensaje *</label>  
               <textarea class="input-xlarge" name="comments" id="comments" rows="3" cols="20" placeholder="Escriba el mensaje que quiere que nos llegue..."></textarea>  
             </li>       
             <li id="camposObs">  
               (*) Campos obligatorios.  
             </li>                        
             <li id="botones">     
               <input class="btn" name="borrar" type="reset" id="btn_borrar" value="Borrar"/>  
               <input class="btn" name="enviar" id="btn_enviar" type="submit" value="Enviar"/>  
             </li>  
           </ul>  
         </fieldset>  
       </div>  
     </form>  
   </body>  
 </html>  

envia_email.php
 <?php  
 session_start();  
 $respuesta = "";  
 if (isset($_POST['nombre_contacto'], $_POST['telefono_contacto'], $_POST['email_contacto'], $_POST['comments'])) {  
   $nombre = trim($_POST['nombre_contacto']);  
   $tlf = $_POST['telefono_contacto'];  
   $mail = $_POST['email_contacto'];  
   $mensaje = trim($_POST['comments']);  
   $dire = 'informacion';  
   $host = 'ejemplo.com';  
   //mirar seguridad htmlentities?  
   if (($nombre != "") && ($mensaje != "") && $tlf != "" && filter_var($mail, FILTER_VALIDATE_EMAIL)) {  
     $dia = date("d");  
     $mes = date("m");  
     $año = date("Y");  
     $target = $dire . "@" . $host; # la dirección electrónica a la que enviar el email  
     $subject = "Contacto desde www." . $host; #el encabezado  
     $mens = '<html>  
        <head><title>Formulario de Contacto de www.' . $host . '</title></head>  
        <body>  
        <table>  
        <tr><th colspan=2 bgcolor=#666666><font color=#ffffff>Datos de Contacto</font></th></tr>  
        <tr><td><b>Nombre:</b> ' . $nombre . '</td></tr>  
        <tr><td><b>Tlf:</b> ' . $tlf . '</td></tr>  
        <tr><td><b>E-Mail:</b> <a href="mailto:' . $mail . '">' . $mail . '</a></td></tr>  
        <tr><th colspan=2 bgcolor=#666666><font color=#ffffff>Contenido del Mensaje:</font></th></tr>  
        <tr><td><div align="justify">' . $mensaje . '</div></td></tr>  
        <tr><th colspan=2 bgcolor=#666666><font color=#ffffff>Fecha de envio del mensaje:</font></th></tr>  
        <tr><td><center>' . $dia . '-' . $mes . '-' . $año . '</center></td></tr>  
        </table>  
        </body>  
        </html>     ';  
     $headers = "";  
     $headers .= "X-Sender: $target <$target>\n"; //   
     $headers .= "From: formularios@$host <formularios@$host>\n";  
     $headers .= "Reply-To: formularios@$host <formularios$host>\n";  
     $headers .= "Date: " . date("r") . "\n";  
     $headers .= "Message-ID: <" . date("YmdHis") . "formulario@" . $_SERVER['SERVER_NAME'] . ">\n";  
     $headers .= "Return-Path: formularios$host <formularios$host>\n";  
     $headers .= "Delivered-to: formularios$host <formularios$host>\n";  
     $headers .= "MIME-Version: 1.0\n";  
     $headers .= "Content-type: text/html;charset=utf-8\n";  
     $headers .= "X-Priority: 1\n";  
     $headers .= "Importance: High\n";  
     $headers .= "X-MSMail-Priority: High\n";  
     $headers .= "X-Mailer: Formulario $host con PHP!\n";  
     mail($target, $subject, $mens, $headers);  
     if (isset($_SESSION['enviados2']))  
       $_SESSION['enviados2']++;  
     else  
       $_SESSION['enviados2'] = 1;  
     header("Location: ./contacto2.php",true,303);  
   }  
 }  
 ?>  

Ahora tras haber enviado el formulario por primera vez da igual cuantas veces presionemos F5. No se van a volver a enviar ningún dato. Ni aparecerá el mensaje técnico indicándolo. Ya que con la redirección GET con código 303 (recuerda que por defecto es 302) la página no lleva datos POST en sus cabeceras. Por lo que no hay problema si la recargamos.

Consecuencias

La consecuencia de usar este patrón es que ahora la comunicación de la respuesta se divide en dos:
- Sin usar este patrón, se hace una petición POST a contacto.php y el servidor responde con la respuesta que envía el mismo script.
- Con el patrón PRG (POST/Redirect/GET) se hace una petición POST a envia_email.php (script intermedio del ejemplo anterior) y este responde al navegador con una redirección HTTP/1.1 303. Con lo que el navegador solicitará una petición GET (al servidor) a contacto2.php y este devolverá la respuesta.
Por lo que volumen total de la comunicación es mayor ya que requiere más conexiones y por lo tanto el tamaño (KB) será algo mayor. Pero esto evita problemas de doble envío de datos por parte del usuario. Por lo que lo que el aumento de la complejidad de la comunicación está más que justificada.

Conclusión

Evitar el problema del doble envío es tan sencillo como separar el script que genera formulario, el script que procesa los datos y el script que muestra la respuesta. De esta manera el script que procesa los datos hará una redirección GET con estado 303 al script que generará la respuesta. Así en la cache del navegador únicamente se habrán almacenado las páginas GET: formulario y respuesta. Y estás no tendrán implícitas el envío de datos POST. Lo cual no ocasionará problemas con los botones  'recargar' y 'atrás'.


Entradas relacionadas

Formularios y PHPl
Redireccionamiento en PHP

4 comentarios:

  1. Perfecto! lo puse a prueba en una pasarela de pagos y todo esta bien ahora, muchas gracias

    ResponderEliminar
  2. Me funiona bien,pero tengo un problema,si hago click en volver a tras en el navegador,el formulario siempre pide que haga refresh y se reenvian los datos.Asi debe ser? o hay algo mal? en la pagina de respuesta ya no me refresca,incluso si lo envio a la misma pagina,como cuando alguien inserta un registro

    ResponderEliminar
  3. Excelente explicación, primera vez en TODA la internet que encuentro primero que todo una forma de limpiar GET o POST luego de que se envían y segundo, una explicación tannn clara de como funciona!
    Se ve que esto (aunque parece sencillo) es un conocimiento avanzado en php, porque en muchisimos foros mucha gente hace este tipo de pregunta y siempre responden cosas como.. usa javascript y haz esto.. o.. usa php y haz esto, pero ninguna de esas soluciones funcionan por un simple motivo: Toca colocarles en su mayoria contadores y darle un tiempo adivinando cuanto podria tardar procesando lo cual es malo por el simple hecho de que puede que la misma consulta por cogestion de red se demore tiempos distintos. EN fin.. EXCELENTE EXPLICACIÓN! :)

    ResponderEliminar
  4. Muchas Gracias, me dieron la idea y me funciono

    ResponderEliminar