Colaboraciones en el Guille

Solucionando el Problema del “Doble Submit”

 

Fecha: 28/Nov/2005 (26 de Noviembre de 2005)
Autor: Santiago L. Valdarrama

 


Resumen

¿Ha pensado alguna vez que su aplicación puede tener un problema que silenciosamente está destruyendo la información que manipula? ¿Han encontrado sus clientes “misteriosos” errores al rellenar y enviar formularios de su aplicación? Todo puede estar dado por un “doble submit” en sus formularios. Este flagelo tan dañino puede encontrarse alojado entre sus líneas de código y solo ser detectado cuando ya sea demasiado tarde. En este artículo veremos qué es el “doble submit”, sus implicaciones, y cómo resolverlo implementando un conjunto de clases y apoyándonos en las herramientas brindadas por la plataforma .NET.

Introducción

¿Qué es el “doble submit”? Esta es la pregunta que puede surgirles a muchos luego de leer el título de este artículo. Los que se han enfrentado a proyectos de cualquier tipo en ASP.NET u cualquier otra tecnología Web, enseguida sabrán de lo que estamos hablando. Los que todavía tienen la duda, les explico para que no les tome de sorpresa.

Técnicamente hablando, el “doble submit” no es más que el acto de enviar dos veces la misma solicitud POST de forma seguida a un mismo formulario. O sea, supongamos que tenemos un sitio con una página que permite registrar a un nuevo usuario. Se produce un “doble submit” si se envía de forma continuada la misma solicitud de registro al formulario.

Pero ¿qué problemas hay con esto? Simplemente, las implicaciones de un “doble submit” pueden ser desde completamente inofensivas hasta desastrosamente dañinas. Desde el mismo surgimiento de los lenguajes de programación para la construcción de sitios dinámicos, los fórums en Internet una y otra vez han sido visitados por programadores del mundo entero que comparten un mismo problema: ¿cómo evitar este desagradable comportamiento?

Implicaciones de un “Doble Submit”

Para comprender a cabalidad los efectos negativos de un “doble submit”, veamos el ejemplo a continuación cuyo código completo aparece adjunto a este artículo.

Tenemos un sitio compuesto de dos páginas. La primera página – llamada default.aspx – cuenta con dos controles: un DataGrid y un botón. La segunda página – add.aspx –, tiene un cuadro de texto y otro botón. En la primera página, en el control DataGrid, se mostrará un conjunto de valores – identificador y nombre –, donde el primero será un valor consecutivo y el segundo será introducido por el usuario en la segunda página.

Para lograr esta funcionalidad tenemos una clase llamada Data la cual contiene una instancia de un DataSet donde se almacenarán los datos que se vayan introduciendo a través de la aplicación:

namespace Sample
{
	public class Data
	{
		private static SampleDataSet dataSet;

		private Data()
		{
		}

		public static SampleDataSet DataSet
		{
			get
			{
				return dataSet;
			}
		}

		static Data()
		{
			dataSet = new SampleDataSet();
			dataSet.Sample.AddSampleRow(1, "Primer Valor");
			dataSet.Sample.AddSampleRow(2, "Segundo Valor");
			dataSet.Sample.AcceptChanges();
		}
	}
}

Como muestra el código anterior, el DataSet una vez creado será inicializado con dos valores arbitrarios. Como ya deben haber notado, el DataSet tiene solo dos campos, un entero y el otro una cadena de caracteres. El entero – denominado Id – es la llave de la tabla del DataSet.

Veamos ahora el código del archivo .cs de la página principal. Las secciones que no tienen importancia para nuestro ejemplo fueron omitidas para mayor claridad:

using System;

namespace Sample
{
	public class DefaultPage : System.Web.UI.Page
	{
		protected System.Web.UI.WebControls.DataGrid DataGrid1;
		protected System.Web.UI.WebControls.Button Button1;
	
		private void Page_Load(object sender, System.EventArgs e)
		{
			DataGrid1.DataSource = Data.DataSet;
			DataGrid1.DataBind();
		}

		…

		private void Button1_Click(object sender, System.EventArgs e)
		{
			int nextId = ((SampleDataSet.SampleRow)Data.DataSet.Sample.Rows[Data.DataSet.Sample.Rows.Count - 1]).Id + 1;
			Response.Redirect("add.aspx?id=" + nextId);
		}
	}
} 

En el evento Page_Load de la página, se relaciona el DataGrid con el DataSet de la clase Data, de forma tal que los datos almacenados en esta sean mostrados en el DataGrid. Ahora, lo más interesante se encuentra en el método relacionado con el evento Click del botón: en este método se calcula el próximo identificador que debe ser almacenado en el DataSet y se le pasa como parámetro a la página add.aspx para que allí se recoja el valor del campo de texto. Veamos que se hace en esta otra página:

using System;

namespace Sample
{
	public class AddPage : System.Web.UI.Page
	{
		protected System.Web.UI.WebControls.TextBox TextBox1;
		protected System.Web.UI.WebControls.Label Label1;
		protected System.Web.UI.WebControls.Button Button1;
	
		private void Page_Load(object sender, System.EventArgs e)
		{
		}

		…

		private void Button1_Click(object sender, System.EventArgs e)
		{
			int id = int.Parse(Request.QueryString["id"]);
			Data.DataSet.Sample.AddSampleRow(id, TextBox1.Text);
			Response.Redirect("default.aspx");
		}
	}
}

Bastante sencillo: se toma el identificador que se suministró como parámetro y conjuntamente con la cadena especificada en el control de texto se inserta en el DataSet. Por último se regresa a la página principal para que el proceso pueda ser nuevamente ejecutado.

Ahora, carguemos la página principal en nuestro explorador. Aparecerá el DataGrid con sus dos valores iniciales. Presionemos el botón de la página y, ya en la segunda, entremos una cadena de texto cualquiera y presionemos el botón Insertar. Ahora nos encontraremos de regreso en la página principal pero el DataGrid mostrará otra nueva fila con identificador igual a 3 y nombre igual a la cadena de texto suministrada.

Provoquemos un “doble submit”: retrocedamos una página atrás utilizando el botón Atrás del explorador y presionemos nuevamente el botón Insertar de la página add.aspx. ¡Sorpresa! Nos encontraremos con una página de error que nos advierte que la columna Id en el DataSet tiene que ser única.

El motivo de esto es bien sencillo y todos deben haberlo notado: al retroceder con el botón atrás del explorador hemos caído en la página add.aspx cuando esta recibió como parámetro un identificador que en estos momentos YA se encuentra insertado en el DataSet.

Otra vía para probar si nuestra aplicación está o no preparada para un “doble submit” es emplear un pequeño truco utilizando las herramientas de depuración que brinda el Visual Studio .NET: pongamos un punto de ruptura (o breakpoint) en el código que será ejecutado tras presionar el botón Insertar. Carguemos nuestra página desde el Visual Studio .NET y una vez que lleguemos a la página instrumentada, presionemos el botón y el depurador automáticamente se detendrá en el punto de ruptura. Acto seguido y sin realizar más nada en el Visual Studio .NET, regresemos a la página, volvamos a presionar el mismo botón y ya está. Ahora tendremos dos solicitudes POST para el mismo formulario. Si comenzamos a depurar el evento con la tecla F10, al terminar su ejecución veremos que nuevamente se comienza a ejecutar el mismo evento. Esto está dado por el segundo POST en nuestro formulario. En ambos casos el resultado será el mismo: una página de error indicando que el DataSet no puede contener dos identificadores con el mismo valor.

El error mostrado es una de las implicaciones más leves de un “doble submit” ya que nuestro DataSet, al no permitir valores duplicados en el identificador de sus elementos, provocará el error y no será dañada de forma alguna la información. El problema se vuelve bastante serio cuando un “doble submit”, por ejemplo, provoca de forma “silenciosa” modificaciones de cualquier tipo en datos almacenados de determinada sensibilidad. Aquí, las implicaciones pueden ser desastrosas y muy difíciles de detectar.

Soluciones Propuestas para Resolver el Problema

Como mencionaba anteriormente, muchas veces se ha solicitado en Internet una solución para el problema que estamos tratando. Entre las propuestas existentes, cada una tiene sus ventajas y desventajas, pero ninguna es lo suficientemente completa para resolver todas las aristas de la situación.

Entre estas soluciones tenemos la de desactivar la barra del navegador para evitar que el usuario pueda utilizar los botones “Anterior”, “Siguiente” y “Refrescar”. Esta solución, aparte de muy molesta para el usuario, no resulta completamente efectiva pues el problema puede surgir por otras vías no necesariamente relacionadas con esta barra. Además, el código para ocultar esta barra no funciona en muchos exploradores diferentes del Internet Explorer.

Otra de las soluciones es desactivar el botón “submit” una vez que se haya presionado por primera vez. La desventaja de esta solución incluye la necesidad de utilizar código JavaScript con sus consecuentes problemas con navegadores diferentes al Internet Explorer.

Existen otras propuestas como el almacenamiento de campos “bandera” en bases de datos o la utilización de cookies en el cliente para señalizar que se ha enviado un POST al servidor. Ambas bastante engorrosas de implementar, rompen con el diseño de nuestra base de datos en el primer caso o depende de la configuración del explorador en el segundo. Además, resuelven el problema parcialmente ya que cuando el usuario de nuestro sitio no viene con las mejores intenciones, haciendo uso de los botones de navegación, los botones de submit y el botón para refrescar, podrá causarse un verdadero caos en nuestra aplicación.

Por último, quisiera referirme a la solución propuesta que ha dado origen a este artículo. Implementada en la popular plataforma Struts (http://jakarta.apache.org/struts), creada para desarrollo web en aplicaciones Java, esta solución es la que de una forma más transparente, robusta y flexible aborda efectivamente el problema y mitiga de forma total sus efectos negativos. Antes de entrar en detalles, analicemos primeramente qué son los módulos HTTP en .NET ya que ellos serán la principal herramienta en la que descansará nuestra implementación de la solución.

Módulos HTTP

No es objetivo de este artículo brindar una disertación sobre módulos HTTP, ya que en el propio sitio de MSDN, por solo poner un ejemplo, pueden encontrarse un gran número de artículos abordando temas relacionados de menor o mayor complejidad. Sí quisiera mencionar a grandes rasgos qué son y cómo usar los módulos HTTP, para de esta forma brindarle al lector una introducción a la solución propuesta para el “doble submit”.

Los módulos HTTP en ASP.NET permiten extender las aplicaciones adicionando procesamiento anterior y posterior a cada solicitud HTTP que llega a la aplicación. Una vez que llega una solicitud al objeto HttpApplication, este la hace pasar a través de uno o más módulos HTTP, dependiendo en la configuración de su sistema particular.

Un módulo HTTP es simplemente una clase que implementa la interfaz System.Web.IHttpModule:

public interface IHttpModule
{
	void Init(HttpApplication context);
	void Dispose();
}

Los métodos Init y Dispose serán llamados automáticamente por el runtime de ASP.NET una vez que el módulo esté listo para interceptar una solicitud HTTP.

Para incluir un módulo determinado en la cadena de procesamiento de ASP.NET, basta con declararlo en el fichero web.config de nuestra aplicación:

<configuration>
	<system.web>
		<httpModules>
			<add name="MiModulo" type="Ejemplo.MiModulo, Ejemplo" />
		</httpModules>
	</system.web>
</configuration>

ASP.NET incluye un conjunto de módulos HTTP predefinidos para realizar una gran variedad de actividades, como control de estado de sesión, autenticación Windows, Passport y de Forma, autorización de URL y Ficheros, etc. Cada uno de estos módulos HTTP es declarado en el fichero global machine.config.

Solución al Problema Utilizando Módulos HTTP

Entremos ahora de lleno en nuestra solución. En resumen, esta se basará en almacenar un atributo de transacción – o Token de aquí en adelante – en la sesión de la aplicación y otro en cada solicitud HTTP a una página. Estos nos servirán para comprobar que solo se haga un submit a un formulario determinado.

Cuando el token no pueda ser encontrado en la sesión, o el valor del mismo no coincida con el token de la solicitud, entonces ha ocurrido una violación – o un “doble submit” – y alertaremos al usuario para que tome las medidas pertinentes.

Para cumplimentar lo anteriormente expuesto, tendremos una clase denominada TokenProcessor cuyo código aparece a continuación:

using System;
using System.Web;

namespace Sample
{
	public sealed class TokenProcessor
	{
		public const string TRANSACTION_TOKEN_KEY = "TRANSACTION-TOKEN-KEY";
		public const string TOKEN_KEY = "TOKEN-KEY";

		private static TokenProcessor instance = new TokenProcessor();

		public static TokenProcessor GetInstance()
		{
			return TokenProcessor.instance;
		}

		public bool IsTokenValid()
		{
			lock(this) 
			{
				return this.IsTokenValid(false);
			}
		}

		public bool IsTokenValid(bool reset)
		{
			lock(this) 
			{
				object saved = HttpContext.Current.Session[TRANSACTION_TOKEN_KEY];
				
				if (saved == null)
				{
					return false;
				}

				if (reset)
				{
					this.ResetToken();
				}

				object token = HttpContext.Current.Items[TOKEN_KEY];
				if (token == null)
				{
					return false;
				}

				return saved.Equals(token);
			}
		}

		public void ResetToken()
		{
			lock(this) 
			{
				HttpContext.Current.Session.Remove(TRANSACTION_TOKEN_KEY);
			}
		}

		public void SaveToken()
		{
			lock(this) 
			{
				string token = GenerateToken();
				if (token != null)
				{
					HttpContext.Current.Session[TRANSACTION_TOKEN_KEY] = token;
				}
			}
		}

		private string GenerateToken()
		{
			return Guid.NewGuid().ToString();
		}

		private TokenProcessor()
		{
		}
	}
}

La clase cuenta con tres métodos públicos fundamentales para realizar su trabajo; ellos son IsTokenValid, ResetToken y SaveToken. El primero comprobará si el token almacenado en la sesión existe o es válido, el segundo se encargará de eliminar el token existente, y el tercero creará y salvará un nuevo token en la sesión.

Si nos adentramos en la implementación del método IsTokenValid, veremos que este recupera el token almacenado en la sesión y comprueba que no sea nulo. En caso contrario, retorna que no es válido y termina la ejecución. Si el token de la sesión no es nulo, se recuperará el token de la solicitud y se comprobarán ambos valores. En caso de que difieran, o si la solicitud no tiene un token asociado, nuevamente se retornará que no hay validez.

El método ResetToken simplemente eliminará el atributo que representa el token de la sesión, y el método SaveToken creará un nuevo valor – utilizando un GUID en el método GenerateToken – y lo almacenará en la sesión.

En conjunto con la clase TokenProcessor tendremos la implementación de un módulo HTTP que se encargará del resto del trabajo:

using System;
using System.Web;

namespace Sample
{
	public class TokenModule : IHttpModule
	{
		public void Init(HttpApplication application) 
		{
			application.PreRequestHandlerExecute += new EventHandler(Aplication_PreRequestHandlerExecute);
		}
    
		private void Aplication_PreRequestHandlerExecute(object sender, EventArgs e)
		{
			HttpApplication application = (HttpApplication)sender;
			if (application.Session != null) 
			{
				object token = application.Session[TokenProcessor.TRANSACTION_TOKEN_KEY];
				
				if (token != null)
				{
					application.Context.Items.Add(TokenProcessor.TOKEN_KEY, token.ToString());
				}
			}
		}
			
		public void Dispose() 
		{
		}
	}
}

Como podemos observar en el ejemplo anterior, en el módulo HTTP se interceptarán las solicitudes justo antes de que comience la ejecución del manipulador de páginas ASPX – evento PreRequestHandlerExecute –. Aquí se recuperará el valor del token almacenado en la sesión y se incluirá en el listado de valores compartidos por el módulo y el manipulador – en este caso, las páginas –. Es importante señalar que el evento PreRequestHandlerExecute solamente será ejecutado cuando se haga una solicitud desde el cliente y nunca por efecto de un retroceso en nuestro explorador.

Ahora creemos una página base de la cual heredarán todas nuestras páginas en el sitio. En este caso, le llamaremos CustomPage. La página tendrá una propiedad llamada Token que brindará el acceso a la funcionalidad implementada en la clase TokenProcessor.

using System.Web.UI;

namespace Sample
{
	public class CustomPage : Page
	{
		private TokenProcessor token = TokenProcessor.GetInstance();

		protected virtual void Page_Load(object sender, System.EventArgs e)
		{
			
		}
			
		public TokenProcessor Token
		{
			get
			{
				return this.token;
			}
		}

	}
}

Por último, para completar nuestra implementación, en el fichero web.config de nuestra aplicación configuramos nuestro módulo de la siguiente forma:

<system.web>
	<httpModules>
		<add name="TokenModule" type="Sample.TokenModule, Sample" />
	</httpModules>
...
<system.web>

Y ya tenemos todo listo para utilizar las herramientas implementadas y dar solución a nuestro problema. Para ello, vamos a utilizar el mismo ejemplo que hemos tratado en este artículo y completar su código de forma tal que evitemos el error anteriormente visto.

Primero que nada, tenemos que poner a heredar nuestras páginas default.aspx y add.aspx de la nueva clase CustomPage, para de esta forma tener acceso a la funcionalidad de la clase TokenProcessor. Una vez hecho esto, modifiquemos el evento del botón de la página default.aspx e incluyamos como primera línea lo siguiente:

Token.SaveToken();

O sea, cuanto el usuario presione el botón que comenzará con el procesamiento para la inserción de un nuevo elemento en el DataGrid, nosotros salvaremos un token de transacción en la sesión. Los cambios en la página add.aspx no son mucho más complejos:

private void Button1_Click(object sender, System.EventArgs e)
{
	if (Token.IsTokenValid())
	{
		int id = int.Parse(Request.QueryString["id"]);
		Data.DataSet.Sample.AddSampleRow(id, TextBox1.Text);
				
		Token.ResetToken();
	}

	Response.Redirect("default.aspx");
}

Como podemos observar, en la manipulación de este evento incluimos dos nuevas líneas: la primera comprobará que el token almacenado sea válido. Si lo es, pasaremos a insertar el nuevo elemento en el DataGrid. La segunda eliminará el token de la sesión para evitar el “doble submit”. Así de simple.

Si ejecutamos la aplicación e intentamos provocar el error tal y como lo hicimos hace unos momentos, podremos comprobar que ahora no ocurre nada anormal: el problema ha quedado solucionado. Una vez que sea insertado un elemento en el DataGrid, el token será eliminado, por lo que las subsiguientes solicitudes fuera de lo establecido fallarán en la comprobación de validez del token.

Ejemplos Adjuntos

Adjunto con este artículo podrán encontrar el ejemplo tratado en dos versiones: la primera de ellas sin incluir la solución propuesta, y la segunda, con la implementación de todas las clases descritas aquí y las modificaciones pertinentes para resolver el problema.

Para comprender a cabalidad el artículo, exhorto a los lectores a que instalen primeramente la solución con el problema del “doble submit” y así comprueben cuan sencillo es hacerla fallar. Traten de resolver el problema por ustedes mismos y comprobarán cuan engorroso y complejo resulta.

Después podrán instalar la aplicación mejorada. Traten de hacer fallar la aplicación por un “doble submit”. Depuren paso a paso toda la aplicación para que comprendan el papel de los token en la línea de procesamiento.

Luego de esto, podrán utilizar el código propuesto en este artículo para sus propias implementaciones. Utilizarlo, como pueden haber notado, es extremadamente simple y brinda grandes ventajas. Espero que la solución cubra sus propias expectativas.

Conclusiones

El problema del “doble submit” ha golpeado a los programadores desde el mismo surgimiento del web. Muchos se han dado a la tarea de resolverlo utilizando técnicas muy engorrosas y no siempre efectivas. La solución propuesta en este artículo es una vía muy efectiva para resolver el problema en todas sus aristas. Queda la tarea al programador de desarrollar su propia biblioteca donde incluya el procesamiento descrito en este artículo para luego utilizarla en sus aplicaciones web.


Espacios de nombres usados en el código de este artículo:

System
System.Web
System.Web.UI

 


Ejemplo con Problemas: svpino_SolucionandoProblemaDobleSubmit_Error.zip - 16 KB

Ejemplo Solucionado: svpino_SolucionandoProblemaDobleSubmit_OK.zip - 18 KB


ir al índice principal del Guille