Puntos de datos

Nueva vida para una aplicación ASP.NET Web Forms de diez años

Julie Lerman

Descargar el código de ejemplo

Julie LermanCódigo heredado: imposible vivir con él, imposible vivir sin él. Y cuanto mejor hagamos nuestro trabajo, más tiempo se quedará con nosotros la aplicación. Mi primera aplicación ASP.NET Web Forms ya lleva un poco más de diez años de uso. Ahora llegó el momento de reemplazarla con una aplicación para tableta que está escribiendo otra persona. Mientras tanto, el cliente me pidió que agregue una función nueva para que la empresa pueda comenzar a juntar desde ya algunos de los datos que recopilará la versión nueva.

No se trata solo de unos pocos campos sencillos. La aplicación existente, una compleja ficha de asistencia para registrar las horas de los empleados, contiene un conjunto dinámico de casillas que se definen mediante una lista de tareas. El cliente guarda esa lista en una aplicación independiente. En la aplicación web, el usuario puede seleccionar una cantidad arbitraria de elementos en esa lista para especificar las tareas que realizó. La lista contiene un poco más de 100 elementos y crece lentamente en el transcurso del tiempo.

Ahora el cliente quiere registrar la cantidad de horas que se demora en cada tarea seleccionada. La aplicación solo se va a usar durante unos pocos meses más, así que no tiene sentido invertir mucho en el cambio, pero había dos objetivos importantes con respecto al cambio:

  1. Que fuera muy fácil para el usuario introducir las horas, sin necesidad de hacer clic en botones adicionales y sin provocar postbacks.
  2. Agregar la función al código de la manera menos invasiva posible. Aunque me resultaba muy tentador revisar una aplicación de diez años con las herramientas más modernas, quería agregar la lógica nueva de tal forma que no tuviera ningún impacto en el código existente (en funcionamiento), ni siquiera en el acceso a datos ni en la base de datos.

Reflexioné bastante tiempo sobre las opciones disponibles. El segundo objetivo significaba que debía dejar intacto el CheckBoxList. Decidí que pondría las horas en una cuadrícula independiente, pero el primer objetivo significaba que no debía usar el control GridView de ASP.NET (por fortuna). Opté por emplear una tabla y JavaScript para recuperar y persistir la información de las horas dedicadas a las tareas y exploré algunas formas para lograr esto. No podía usar los PageMethods de AJAX para llamar el código subyacente, ya que mi página se recuperaba mediante un Server.Transfer desde otra página. Las llamadas insertadas, como <%MyCodeBehindMethod()%>, funcionarían hasta que tuviera que realizar algún tipo de validación compleja de los datos (demasiado difícil de lograr en JavaScript), que requiriese de una mezcla de objetos del lado cliente y servidor. La situación también comenzó a ponerse fea ya que tenía que tocar todo con la llamada insertada estática. Por lo tanto, no me estaba resultando lo de “lo menos invasivo posible”.

Por último me di cuenta que podía tratar de mantener totalmente separada la lógica nueva y dejarla en una WebAPI que fuera accesible fácilmente desde JavaScript. Esto permitiría una separación nítida entre la lógica nueva y la antigua.

Aun así quedaban varias dificultades. La experiencia que había tenido anteriormente con Web API fue la creación de un proyecto MVC nuevo. Comencé con eso, pero llamar los métodos en la Web API desde la aplicación existente generó problemas de uso compartido de recursos con origen cruzado (CORS) que se resistieron a todos los patrones que encontré para evitar el CORS. Por último descubrí un artículo de Mike Wasson sobre cómo agregar una Web API directamente a un proyecto Web Forms (bit.ly/1jNZKzI) y así es como me puse en marcha, aunque me quedaban muchos puentes por cruzar aún. No lo haré revivir todos los sufrimientos que padecí al abrirme camino a punta de tentativas, golpes, sablazos. En vez de esto, le mostraré paso a paso la solución que encontré al final.

En vez de mostrar la aplicación real de mi cliente para demostrar cómo inserté las funciones nuevas en la aplicación antigua, emplearé un ejemplo que registra las preferencias de los usuarios mediante comentarios sobre las cosas que les gusta hacer: cosas entretenidas. Pasaré por alto la lista de los 100 elementos aquí: el formulario solo contiene un CheckBoxList breve, además del trabajo adicional para validar los datos. Y en vez de registrar las horas, registraré los comentarios de los clientes.

Una vez que aposté por Web API, agregar el método de validación no presentaba ningún tipo de problema. Como opté por crear un ejemplo nuevo, usé Microsoft .NET Framework 4.5 y Entity Framework 6 (EF) en vez de .NET Framework 2.0 y ADO.NET en bruto. En la figura 1 vemos el punto de partida de la aplicación de ejemplo: un formulario de ASP.NET Web Forms con un nombre de usuario y un control CheckBoxList editable que contiene las actividades posibles. Esta es la página a la que agregaré la capacidad de registrar los comentarios para cada elemento activo, tal como se muestra en la cuadrícula esbozada.

Starting Point: A Simple ASP.NET Web Form with the Planned AdditionFigura 1 Punto de partida: formulario ASP.NET Web Forms sencillo con el cambio previsto

Paso 1: agregar la clase nueva

Necesitaba una clase para almacenar los comentarios nuevos. Concluí que, con los datos que tenía, lo más lógico era usar una clave compuesta por UserId y FunStuffId para determinar a qué usuario y actividad entretenida se adjuntaría el comentario:

namespace DomainTypes{
  public class FunStuffComment{
    [Key, Column(Order = 0)]
    public int UserId { get; set; }
    [Key, Column(Order = 1)]
    public int FunStuffId { get; set; }
    public string FunStuffName { get; set; }
    public string Comment { get; set; }
  }
}

Como me propuse usar EF para la persistencia de los datos, tenía que especificar las propiedades que se convertirían en la clave compuesta. En EF, el truco para asignar las claves compuestas es agregar el atributo Column Order junto con el atributo Key. También quiero señalar la propiedad FunStuffName. Aunque podría crear una referencia cruzada a la tabla FunStuff para obtener el nombre de un registro determinado, me pareció más fácil exponer simplemente FunStuffName en esta clase. Puede parecer redundante, pero recuerde que me propuse evitar toda interferencia posible con la lógica existente.

Paso 2: agregar Web API al proyecto basado en Web Forms

Gracias al artículo de Wasson, descubrí que podía agregar un controlador de Web API directamente en el proyecto existente. Simplemente basta con hacer clic con el botón secundario en el proyecto en el Explorador de soluciones para que aparezca Clase controladora de Web API como opción en el menú contextual Agregar. El controlador que se crea fue diseñado para operar con MVC, así que el primer asunto del que debo ocuparme es eliminar todos los métodos y agregar el método Comments para recuperar los comentarios existentes de un usuario determinado. Como voy a usar la biblioteca Breeze para JavaScript y ya la tengo instalada en mi proyecto con NuGet, empleo las convenciones de nomenclatura de Breeze en mi clase controladora de Web API, tal como de aprecia en la figura 2. Todavía no enlacé Comments en mi acceso a datos, así que por el momento devuelvo algunos datos que se encuentran en memoria.

Figura 2 Web API BreezeController

namespace April2014SampleWebForms{
[BreezeController] 
public class BreezeController: ApiController  {
  [HttpGet]
  public IQueryable<FunStuffComment> Comments(int userId = 0)
    if (userId == 0){ // New user
      return new List<FunStuffComment>().AsQueryable();
    }
      return new List<FunStuffComment>{
        new FunStuffComment{FunStuffName = "Bike Ride",
          Comment = "Can't wait for spring!",FunStuffId = 1,UserId = 1},
        new FunStuffComment{FunStuffName = "Play in Snow",
          Comment = "Will we ever get snow?",FunStuffId = 2,UserId = 1},
        new FunStuffComment{FunStuffName = "Ski",
          Comment = "Also depends on that snow",FunStuffId = 3,UserId = 1}
      }.AsQueryable();    }
  }
}

El artículo de Wasson nos explica en detalle cómo agregar el enrutamiento necesario al archivo global.asax. Pero agregar Breeze mediante NuGet crea un archivo .config que ya tiene definido todo el enrutamiento necesario. Es por eso que respeto los nombres recomendados por Breeze en el controlador de la figura 2.

Ahora puedo llamar el método Comments fácilmente desde el lado cliente de FunStuffForm. Me gusta probar mi Web API en un explorador para asegurarme de que todo esté funcionando correctamente. Para esto, puede ejecutar la aplicación y luego ir a http://localhost:1378/breeze/Breeze/Comments?UserId=1. Asegúrese de usar los valores de host:puerto correctos que está usando la aplicación en su caso.

Paso 3: agregar el enlace de datos del lado cliente

Pero no he terminado aún. Necesito hacer algo con esos datos, así que revisé mis columnas anteriores sobre Knockout.js (msdn.microsoft.com/magazine/jj133816 para el enlace de datos con JavaScript) y Breeze (msdn.microsoft.com/magazine/jj863129, que simplifica aún más en enlace de datos). Breeze convierte automáticamente los resultados de mi Web API en objetos enlazables que Knockout (y otras API) puede usar directamente, sin necesidad de crear modelos de vista ni lógica de asignación adicionales. Agregar el enlace de datos es la parte más exigente de la conversión, agravado por mis conocimientos de JavaScript y jQuery, que siguen siendo muy limitados. Pero perseveré y a lo largo del recorrido casi me convertí en una profesional de la depuración de JavaScript en Chrome. La mayor parte del código nuevo se encuentra en un archivo JavaScript independiente que está ligado a mi página Web Form original, FunStuffForm.aspx.

Cuando tenía casi listo este artículo, alguien me indicó que Knockout está un poco anticuado (dijo que era “tan años 2012”) y que muchos desarrolladores de JavaScript están usando marcos más simples y sofisticados como AngularJS o DurandalJS en vez de este. Esa es una lección que tendré que dejar para otro momento. Estoy segura que a mi equipo portátil de diez años de edad no le importará que esta herramienta tenga dos años de antigüedad. Pero definitivamente echaré una mirada a esas herramientas en una columna posterior.

En mi formulario Web Forms definí una tabla llamada comments, donde las columnas las rellenan los campos de los datos a los que enlazaré con Knockout (ver figura 3). También enlazaré los campos UserId y FunStuffId, que voy a necesitar más adelante, pero los mantengo ocultos.

Figura 3 Configuración de la tabla HTML para el enlace con Knockout

<table id="comments">
  <thead>
    <tr>
      <th></th>
      <th></th>
      <th>Fun Stuff</th>
      <th>Comment</th>
    </tr>
  </thead>
  <tbody data-bind="foreach: comments">
    <tr>
      <td style="visibility: hidden" data-bind="text: UserId"></td>
      <td style="visibility: hidden" data-bind="text: FunStuffId"></td>
      <td data-bind="text: FunStuffName"></td>
      <td><input data-bind="value: Comment" /></td>
    </tr>
  </tbody>
</table>

El primer bloque de lógica en el archivo de JavaScript que llamé FunStuff.js es lo que se conoce como una función ready y se ejecuta en cuanto el documento representado está listo. En mi función, defino el tipo viewModel (que se muestra en la figura 4), cuya propiedad comments usaré para enlazar a la tabla comments en mi formulario Web Forms.

Figura 4 Principio de FunStuff.js

var viewModel;
$(function() {
  viewModel = {
    comments: ko.observableArray(),
    addRange: addRange,
    add: add,
    remove: remove,
    exists: exists,
    errorMessage: ko.observable(""),
  };
  var serviceName = 'breeze/Comments';
  var vm = viewModel;
  var manager = new breeze.EntityManager(serviceName);
  getComments();
  ko.applyBindings(viewModel, 
    document.getElementById('comments'));
 // Other functions follow
});

La función ready también especifica el siguiente código de inicio:

  • serviceName define la URI de Web API.
  • vm es un alias breve para viewModel.
  • manager configura EntityManager de Breeze para Web API.
  • getComments es un método que llama la API y devuelve datos.
  • ko.applyBinding es un método de Knockout para enlazar el viewModel a las tablas con los comentarios.

Observe que declaré viewModel fuera de la función. Más adelante tendré que accederlo desde un script en la página .aspx, así que el alcance debía permitir la visibilidad desde afuera.

La propiedad más importante en viewModel es un observableArray llamado comments. Knockout llevará la cuenta de los comentarios que se encuentran en la matriz y actualizará la tabla enlazada cuando cambie la matriz. Las otras propiedades simplemente exponen funciones adicionales que defino debajo de este código de inicio a través del viewModel.

Comencemos con la función getComments que aparece en la figura 5.

Figura 5 Consulta de datos a través de Web API mediante Breeze

function getComments () {
  var query = breeze.EntityQuery.from("Comments")
    .withParameters({ UserId: document.getElementById('hiddenId').value });
  return manager.executeQuery(query)
    .then(saveSucceeded).fail(failed);
}
function saveSucceeded (data) {
  var count = data.results.length;
  log("Retrieved Comments: " + count);
  if (!count) {
    log("No Comments");
    return;
  }
  vm.comments(data.results);
}
function failed(error) {
  vm.errorMessage(error);
}

En la función getComments, uso Breeze para ejecutar mi método Comments de Web API, al pasar el UserId actual desde un campo oculto en la página web. Recuerde que ya definí la URI de Breeze y Comments en la variable manager. Si la consulta procede correctamente, entonces se ejecuta la función saveSucceeded, se registra un poco de información en la pantalla y se insertan los resultados de la consulta en la propiedad comments de viewModel. En mi equipo portátil, puedo ver la tabla vacía antes de que finalice la tarea asincrónica y luego la tabla se rellena de golpe con los resultados (ver figura 6). Y recuerde, todo esto ocurre en el lado cliente. No se realiza ningún postback, así que es una experiencia fluida para el usuario.

Comments Retrieved from Web API and Bound with the Help of Knockout.js
Figura 6 Comentarios que se recuperaron de Web API y se enlazaron con la ayuda de Knockout.js

Paso 4: reaccionar cuando las casillas se activan y desactivan

El siguiente reto fue lograr que la lista respondiera cuando el usuario seleccionara los elementos de Fun Stuff List. Cuando se selecciona un elemento, hay que agregarlo o eliminarlo de la matriz viewModel.comments y de la tabla enlazada, dependiendo de si el usuario quita o pone la marca de verificación. La lógica para actualizar la matriz se encuentra en el archivo JavaScript, pero la lógica para alertar al modelo sobre la acción reside en un script en el archivo .aspx. Es posible enlazar funciones como onclick de una casilla a Knockout, pero me decanté por otro camino.

En el marcado del formulario .aspxm, agregué el siguiente método en la sección header de la página:

$("#checkBoxes").click(function(event) {
  var id = $(event.target)[0].value;
  if (event.target.nodeName == "INPUT") {
    var name = $(event.target)[0].parentElement.textContent;
    // alert('check!' + 'id:' + id + ' text:' + name);
    viewModel.updateCommentsList(id, name);  }
});

Esto es posible gracias al hecho que tengo un div llamado checkBoxes en torno a todos los controles CheckBox que se generaron en forma dinámica. Uso jQuery para captar el valor del control CheckBox que desencadenó el evento y el nombre en la etiqueta correspondiente. Luego los paso al método updateCommentsList de mi viewModel. La alerta era solo para corroborar que la función estuviera conectada correctamente.

Ahora echemos una mirada a updateCommentsList y las funciones relacionadas en mi archivo JavaScript. Un usuario podría activar o desactivar un elemento, así que hay que agregar o quitarlo. En vez de preocuparme del estado de la casilla, en el método exists simplemente me valgo de la función utils de Knockout para ver si el elemento ya se encuentra en la matriz de los comentarios. Si lo está, hay que quitarlo. Como Breeze registra los cambios, lo elimino de observableArray pero le indico al registro de cambios de Breeze que lo considere como eliminado. Esto hace dos cosas. Primero, al guardar, Breeze envía un comando DELETE a la base de datos (a través de EF en este caso). Pero si el elemento se activa nuevamente y hay que volver a agregarlo a observableArray, entonces Breeze simplemente lo restaura en el registro de cambios. De lo contrario, como empleo una clave compuesta para la identidad de los comentarios, si tuviera un elemento nuevo y uno eliminado con la misma identidad se generaría un conflicto. Observe que aunque Knockout responde al método de inserción para agregar los elementos, debo notificarlo sobre el cambio de la matriz, para que pueda responder en el caso cuando se elimina un elemento. Nuevamente, debido al enlace de datos, la tabla cambia en forma dinámica cuando las casillas se activan y desactivan.

Observe que cuando creo un elemento nuevo, tomo el userId del usuario desde el campo oculto en el marcado del formulario. En la versión original del Page_Load del formulario, establezco este valor después de captar el usuario. Al enlazar UserId y FunStuffId con cada elemento en los comentarios, puedo almacenar todos los datos necesarios junto con los comentarios para asociarlos con el usuario y elemento correctos.

Ahora que tengo conectado oncheck y que observableArray de comments se modifica como respuesta, Vemos que al alternar la casilla Watch Doctor Who, por ejemplo, la fila Watch Doctor Who aparece o desaparece en función del estado de la casilla.

Paso 5: guardar los comentarios

Mi página ya tiene una función Save para guardar las casillas marcadas como verdaderas, pero ahora quiero guardar los comentarios al mismo tiempo con otro método de Web API. El método save existente se ejecuta cuando la página realiza el postback como respuesta al clic del botón SaveThatStuff. La lógica se encuentra en el código subyacente de la página. De hecho, puedo realizar una llamada del lado cliente para guardar los comentarios antes de la llamada del lado servidor mediante el mismo clic del botón. Sabía que esto era posible a la antigua con Web Forms, mediante un atributo onClientClick, pero en la aplicación de la ficha de asistencia que estaba modificando también debía llevar a cabo una validación para determinar si las horas y la fecha de la tarea estaban listas para ser guardadas. Si la validación generaba un error, no solo tenía que olvidarme del guardado de Web API, sino que tenía que prevenir que se ejecutaran los métodos de postback y de guardado del lado servidor. Como me costó mucho lograr esto con onClientClick, aproveché de modernizar nuevamente con jQuery. Del mismo modo como puedo responder a los clics de CheckBox en el cliente, puedo tener una respuesta del lado cliente cuando se hace clic en btnSave. Y ocurre antes del postback y de la respuesta del lado servidor. Así que tengo los dos eventos con un solo clic del botón: 

$("#btnSave").click(function(event) {
  validationResult = viewModel.validate();
  if (validationResult == false) {
    alert("validation failed");
    event.preventDefault();
  } else {
    viewModel.save();
  }
});

Tengo un método de validación artificial en el ejemplo, que siempre devuelve true, aunque revisé que todo se comportara correctamente cuando se devuelve false. En ese caso, uso event.preventDefault de JavaScript para detener el proceso. No solo no guardo los comentarios, sino que el postback y el guardado del lado servidor no ocurrirán. De lo contrario, llamo viewModel.save y la página sigue con el comportamiento del lado servidor del botón, hasta guardar los valores que eligió el usuario en FunStuff. Mi función saveComments es llamada por viewModel.save, que le pide a entityManager de Breeze que ejecute un saveChanges:

function saveComments() {
  manager.saveChanges()
    .then(saveSucceeded)
    .fail(failed);
}

Esto, a su vez, encuentra mi método SaveChanges del controlador y lo ejecuta:

[HttpPost]
  public SaveResult SaveChanges(JObject saveBundle)
  {
    return _contextProvider.SaveChanges(saveBundle);
  }

Para que esto funcione, agregué Comments en el nivel de datos de EF6 y luego cambié el método controller de Comments para que ejecute una consulta en la base de datos con el componente del lado servidor de Breeze (que realiza una llamada a mi nivel de datos de EF6). Por lo tanto, los datos que se devuelven al cliente serán datos de la base de datos, que SaveChanges luego puede guardar nuevamente en la base de datos. Puede verlo en el ejemplo descargable que usa EF6 y Code First, crea una base de datos de ejemplo y la rellena con valores de inicialización.

Figura 7 JavaScript para actualizar la lista Comments cuando el usuario hace clic en las casillas

function updateCommentsList(selectedValue, selectedText) {
  if (exists(selectedValue)) {
    var comment = remove(selectedValue);
    comment.entityAspect.setDeleted();
  } else {
  var deleted = manager.getChanges().filter(function (e) {
    return e.FunStuffId() == selectedValue
  })[0];  // Note: .filter won't work in IE8 or earlier
  var newSelection;
  if (deleted) {
    newSelection = deleted;
    deleted.entityAspect.rejectChanges();
  } else {
    newSelection = manager.createEntity('FunStuffComment', {
      'UserId': document.getElementById('hiddenId').value,
      'FunStuffId': selectedValue,
      'FunStuffName': selectedText,
      'Comment': ""
    });
  }
  viewModel.comments.push(newSelection);    }
  function exists(stuffId) {
    var existingItem = ko.utils.arrayFirst(vm.comments(), function (item) {
      return stuffId == item.FunStuffId();
    });
    return existingItem != null;
  };
  function remove(stuffId) {
    var selected = ko.utils.arrayFirst
    (vm.comments(), function (item) {
    return stuffId == item.FunStuffId;
    });
    ko.utils.arrayRemoveItem(vm.comments(), selected);
    vm.comments.valueHasMutated();
  };

JavaScript con un poco de ayuda de mis amigos

Al trabajar en este proyecto y en el ejemplo que creé para este artículo, escribí más JavaScript que nunca antes en mi vida. No es mi área de especialización (tal como indiqué repetidas veces en esta columna), aunque estoy bastante orgullosa de mis resultados. Sin embargo, sabiendo que para muchos lectores algunas de estas técnicas serían completamente nuevas, le pedí ayuda a Ward Bell de IdeaBlade (los creadores de Breeze) para que revisara el código a fondo y para realizar un poco de programación en pareja, lo que me ayudaría a limpiar mi código Breeze, además de JavaScript y jQuery. A excepción quizás del uso “anticuado” de Knockout.js, el ejemplo de la descarga debería entregar algunas lecciones bastante buenas. Pero recuerde que el enfoque está en mejorar un proyecto antiguo de Web Forms con estas técnicas más modernas para que la experiencia para el usuario final sea mucho más placentera.

Julie Lerman es MVP (Profesional más valioso) de Microsoft, consultora y mentora de .NET, y vive en las colinas de Vermont. Realiza presentaciones sobre el acceso a datos y otros temas de Microsoft .NET en grupos de usuarios y congresos en todo el mundo. Mantiene un blog en thedatafarm.com/blog y es la autora de “Programming Entity Framework” (2010), además de una edición para Code First (2011) y otra para DbContext (2012), todos en O’Reilly Media. Sígala en Twitter en twitter.com/julielerman y vea sus cursos de Pluralsight en juliel.me/PS-Videos.

Gracias a los siguientes expertos técnicos de Microsoft por su ayuda en la revisión de este artículo: Damian Edwards (dedward@microsoft.com) y Scott Hunter (Scott.Hunter@microsoft.com)