Pontos de dados

Dando vida nova a um aplicativo do ASP.NET Web Forms de 10 anos atrás

Julie Lerman

Baixar o código de exemplo

Código herdado: não dá pra viver com ele, nem sem ele. E quanto melhor for o seu trabalho em um aplicativo, por mais tempo ele vai durar por aí. Meu primeiro aplicativo do ASP.NET Web Forms está em uso há pouco mais de 10 anos. Agora, ele está finalmente prestes a ser substituído por um aplicativo de tablet que outra pessoa está escrevendo. No entanto, enquanto isso, o cliente me pediu para adicionar um novo recurso para que a empresa comece desde já a coletar alguns dos dados que a nova versão vai pedir.

E não é uma questão que se resolve com um ou dois campos simples. No aplicativo existente — uma complicada planilha de horas para registrar a carga horária de funcionários — há um conjunto dinâmico de caixas de seleção definido por uma lista de tarefas. O cliente mantém a lista em um aplicativo separado. No aplicativo Web, o usuário pode marcar qualquer quantidade de itens daquela lista para especificar as tarefas que cumpriu. A lista contém pouco mais de 100 itens e cresce lentamente com o tempo.

Agora, o cliente precisa controlar o número de horas gastas em cada tarefa selecionada. O aplicativo será usado apenas por mais alguns meses, então não faz sentido investir muito nele, mas eu tinha dois objetivos importantes nessa alteração:

  1. Fazer com que fosse bem fácil para o usuário inserir as horas, o que significa não ter que clicar em botões extras ou causar postbacks.
  2. Adicionar o recurso ao código do modo menos invasivo possível. Seria tentador dar um banho de código nesse aplicativo de 10 anos de idade usando ferramentas mais modernas, mas eu queria adicionar a nova lógica de uma forma que não causaria impacto no código antigo (que funciona), incluindo acesso a dados e o banco de dados.

Eu considerei minhas opções por algum tempo. O Objetivo 2 significa que eu teria que deixar a CheckBoxList intacta. Decidi então conter as horas em uma grade separada, mas o Objetivo 1 significa não poder usar o controle GridView do ASP.NET (graças aos céus). O jeito seria usar JavaScript e uma tabela para recuperar e persistir os dados de horas-tarefa, e eu explorei alguns jeitos de fazer isso. Eu não poderia usar o PageMethods do AJAX para chamar o codebehind porque a minha página estava usando um Server.Transfer para recuperar a partir de outra página. Chamadas embutidas, como <%MyCodeBehindMethod()%>, funcionaram só até eu ter que fazer algumas validações complexas de dados (muito difíceis de conseguir fazer no JavaScript) que exigiam um mix de objetos tanto no lado do servidor quanto do cliente. A situação também começou a ficar feia com a necessidade de fazer com que tudo fosse tocado pelo estático da chamada embutida. Eu estava falhando na coisa do “minimamente invasivo”.

Finalmente, eu me dei conta de que deveria tentar manter a nova lógica totalmente separada e colocá-la dentro de uma API Web que fosse fácil de acessar pelo JavaScript. Isso ajudaria a manter uma separação limpa entre a nova lógica e a antiga.

Mesmo assim, houve desafios. Minha expe­riência anterior com API Web foi criar um novo projeto MVC. Eu comecei com aquilo, mas chamar métodos na API Web a partir de um aplicativo existente estava causando problemas de Cross Origin Resource Sharing (CORS) que desafiavam qualquer padrão que eu pudesse encontrar para evitar o CORS. Por fim, descobri um artigo do Mike Wasson sobre adicionar uma API Web diretamente a um projeto Web Forms (bit.ly/1jNZKzI) e voltei ao caminho certo — embora ainda houvesse muitas pontes a cruzar pela frente. Mas não vou fazer com que você viva a minha dor enquanto eu batia cabeça com essas coisas. Em vez disso, vou guiar você pela solução a que eu cheguei no final.

Não vou mostrar o aplicativo real do meu cliente para demonstrar como eu incluí a funcionalidade nova no aplicativo antigo, mas vou usar um exemplo que controla as preferências dos usuários através de comentários sobre as coisas que eles gostam de fazer: coisas divertidas. Em vez da lista com mais de 100 itens, o formulário mostra apenas uma CheckBoxList curta, assim como o trabalho adicional para validação dos dados. E em vez de controlar horas, eu vou controlar comentários de usuários.

Depois que eu me decidi pela API Web, adicionar o método de validação não foi nenhum desafio. Como eu estava criando uma nova amostra, eu usei o Microsoft .NET Framework 4.5 e o Entity Framework 6 (EF) em vez do .NET Framework 2.0 e do ADO.NET cru. A Figura 1 mostra o ponto inicial do aplicativo de exemplo: um Web Form ASP.NET com um nome de usuário e uma CheckBoxList editável com possíveis atividades. Esta é a página à qual eu vou adicionar a capacidade de controlar comentários para cada item marcado, como mostrado pela grade rascunhada.


Figura 1 Ponto inicial: um simples Web Form ASP.NET com a adição planejada

Etapa 1: Adicione a Nova classe.

Eu precisava de uma classe para armazenar os novos comentários. Levando em consideração os meus dados, concluí que faria mais sentido usar uma chave composta por UserId e FunStuffId para determinar a qual usuário e atividade divertida o comentário se anexaria:

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 eu planejei usar o EF para persistir os dados, eu precisava especificar as propriedades que se tornariam a minha chave composta. No EF, o truque para mapear chaves compostas é adicionar o atributo Column Order junto com o tributo Key. Também quero apontar a propriedade FunStuffName. Eu poderia fazer referência cruzada com a minha tabela FunStuff para obter o nome de uma entrada qualquer, mas achei mais fácil simplesmente flutuar o FunStuffName nessa classe. Pode parecer redundante, mas lembre-se do meu objetivo de evitar mexer na lógica existente.

Etapa 2: adicionar uma API Web ao projeto baseado em Web Forms

Graças ao artigo do Wasson, eu aprendi que poderia adicionar um controlador de API Web diretamente no projeto existente. Basta clicar com o botão direito no projeto dentro do Solution Explorer e você verá a opção de adicionar Web API Controller Class no menu de contexto. O controlador criado é projetado para trabalhar com MVC, portanto o primeiro passo é remover todos os métodos e adicionar o meu método Comments para recuperar comentários existentes para um usuário específico. Como eu estarei usando a biblioteca Breeze JavaScript e já a instalei no meu projeto usando o NuGet, eu uso as convenções de nomenclatura do Breeze para a minha classe Web API Controller, como você pode ver na Figura 2. Eu ainda não liguei o Comments no meu acesso a dados ainda, então vou começar retornando alguns dados que estão na memória.

Figura 2 API Web do 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();    }
  }
}

O artigo do Wasson ensina a adicionar roteamento ao arquivo global.asax. Mas adicionar o Breeze via NuGet cria um arquivo .config com o roteamento apropriado já definido. É por isso que estou usando a nomenclatura recomendada pelo Breeze no controlador da Figura 2.

Agora eu posso chamar o método Comments facilmente a partir do lado cliente do meu FunStuffForm. Eu gosto de testar minha API Web em um navegador para ter certeza de que as coisas estão funcionando, e você pode fazer isso executando o aplicativo e navegando até http://localhost:1378/breeze/Breeze/Comments?UserId=1. Não esqueça de usar o host:port correto que o seu aplicativo está usando.

Etapa 3: Adicionando vinculação de dados no lado do cliente

Mas não terminei ainda. Eu preciso fazer algo com esses dados, então reli minhas colunas antigas sobre Knockout.js (msdn.microsoft.com/magazine/jj133816 para vinculação de dados JavaScript) e Breeze (msdn.microsoft.com/magazine/jj863129, que faz a vinculação de dados parecer ainda mais simples). O Breeze automaticamente transforma o resultado da minha API Web em objetos vinculáveis que o Knockout (e outras APIs) podem usar diretamente, eliminando a necessidade de adicionar novos modelos de visualização e lógica de mapeamento. Adicionar a vinculação de dados é a parte mais intensiva da conversão, e fica ainda pior por causa das minhas habilidades bem limitadas em JavaScript e jQuery. Mas eu segui em frente — processo durante o qual eu acabei ficando quase profissional em depuração de JavaScript no Chrome. A maior parte do novo código está em um arquivo JavaScript separado ligado à minha página Web Form original, a FunStuffForm.aspx.

Quando eu estava quase terminando este artigo, alguém apontou que o Knockout já está meio datado (“Isso é tão 2012”, como ele disse) e que muitos desenvolvedores JavaScript hoje estão usando frameworks mais simples e ricos, como o AngularJS ou o DurandalJS. Isso é algo para eu aprender outra hora. Tenho certeza que o meu aplicativo de 10 anos atrás não vai se importar que eu esteja usando uma ferramenta de 2 anos atrás. Mas com certeza eu vou dar uma olhada nessas ferramentas em alguma coluna futura.

No meu Web Form, eu defini uma tabela chamada comments, com colunas preenchidas com campos dos dados que estarei vinculando a ela com o Knockout (consulte a Figura 3). Também estou vinculando os campos UserId e FunStuffId, que vou precisar depois, mas mantendo-os escondidos.

Figura 3 Configuração da tabela HTML para vinculação com o 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>

O primeiro pedaço de lógica no arquivo JavaScript que eu chamei de FunStuff.js é o que chamamos de função ready, e ele vai ser executado assim que o documento renderizado estiver pronto. Na minha função, eu defino o tipo viewModel, mostrado na Figura 4, cuja propriedade comments eu vou usar para vincular à tabela comments no meu Web Form.

Figura 4 Início do 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
});

A função ready também especifica um pouco de código de inicialização:

  • serviceName define o uri da API Web
  • vm é um alias curto para viewModel
  • manager configura o Breeze EntityManager para a API Web
  • getComments é um método que chama a API e retorna dados
  • ko.applyBinding é um método do Knockout para vincular o viewModel às tabelas comments

Perceba que eu declarei o viewModel fora da função. Eu vou precisar acessá-lo a partir de um script na página .aspx depois, então ele precisava estar num escopo que permitisse visibilidade externa.

A propriedade mais importante no viewModel é um observableArray chamado comments. O Knockout vai manter controle do que está no array e atualizar a tabela vinculada quando o array for modificado. As outras propriedade apenas expõem funções adicionais que eu defini abaixo desse código de inicialização através do viewModel.

Vamos começar com a função getComments mostrada na Figura 5.

Figura 5 Consultando dados através da API Web usando o 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);
}

Na função getComments, eu uso o Breeze para executar meu método Web API, Comments, passando o atual UserId de um campo oculto na página Web. Lembre-se que eu já defini o uri do Breeze e do Comments na variável manager. Se a consulta tiver sucesso, a função saveSucceeded é executada, registrando em log algumas informações da tela e empurrando os resultados da consulta na propriedade comments do viewModel. No meu laptop, posso ver a tabela vazia antes que a tarefa assíncrona seja concluída, e depois a tabela é subitamente preenchida com os resultados (consulte a Figura 6). E lembre-se, tudo isso está acontecendo no lado do cliente. Não está acontecendo nenhum postback, então é uma experiência fluída para o usuário.


Figura 6 Comentário recuperados da API Web e vinculados com a ajuda do Knockout.js

Etapa 4: Reagindo às caixas de seleção sendo marcadas e desmarcadas

O próximo desafio era fazer a lista responder às seleções do usuário na lista Fun Stuff. Quando um item é marcado, ele precisa ser adicionado ou removido do array viewModel.comments e da tabela vinculada, dependendo do caso do usuário estar marcando ou desmarcando uma caixa. A lógica para atualizar o array está no arquivo JavaScript, mas a lógica para alertar o modelo sobre a ação resido em um script no .aspx. É possível vincular funções como um checkbox onclick ao Knockout, mas eu não fui por esse caminho.

Na marcação do formulário .aspx, eu adicionei o seguinte método à seção header da 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);  }
});

Isso é possível graças ao fato de que eu tenho um div chamado checkBoxes cercando todos os controles CheckBox dinamicamente gerados. Eu uso jQuery para pegar o valor do CheckBox que está disparando o evento e uso-o no rótulo correspondente. Depois eu os passo para o método updateCommentsList do meu viewModel. O alerta é só para testar que eu estava com a função funcionando direito.

Agora vamos dar uma olhada na updateCommentsList e outras funções relacionadas no meu arquivo JavaScript. Um usuário pode marcar ou desmarcar um item, então ele precisa ser adicionado ou removido. Em vez de me preocupar com o estado das caixas de seleção, no meu método exists eu apenas deixo a função utils do Knockout me ajudar a ver se o item já está no array de comentários. Se está, eu preciso removê-lo. Como o Breeze está acompanhando as alterações, eu o removo o item do observableArray mas falo para o rastreador de alterações do Breeze considerá-lo excluído. Isso faz duas coisas. Primeiro, quando eu salvo, o Breeze manda um comando DELETE para o banco de dados (via EF, no meu caso). Mas se o item for selecionado novamente e precisar ser readicionado ao observableArray, o Breeze simplesmente o restaura no rastreador de alterações. De outro modo, como eu estou usando uma chave composta para a identidade dos comentários, ter um novo item e um item excluído com a mesma identidade causaria um conflito. Observe que, apesar do Knockout responder ao método push para adicionar itens, eu preciso notificá-lo que o array sofreu mutação para que ele responda à remoção de um item. Novamente, por causa da vinculação de dados, a tabela se altera dinamicamente à medida que as caixas de seleção são marcadas e desmarcadas.

Veja que, quando eu crio um novo item, eu estou pegando o userId do campo oculto na marcação do formulário. Na versão original do Page_Load do formulário, eu defino esse valor depois de pegar o usuário. Ao amarrar o UserId e o FunStuffId a cada item dos comentários, eu posso armazenar todos os dados necessários junto com os comentários para associá-los ao usuário e item corretos.

Com o oncheck preparado e o observableArray dos comentários modificado em resposta, eu posso ver que, por exemplo, clicar na caixa de seleção Watch Doctor Who faz com que a linha Watch Doctor Who apareça ou suma baseado no estado da caixa de seleção.

Etapa 5: Salvando comentários

Minha página já tem um recurso Salvar para salvar as caixas de seleção que estão marcadas, mas agora eu quero salvar os comentários ao mesmo tempo, usando outro método da API Web. O método de salvar existente é executado quando a página faz um postback em resposta ao clique no botão SaveThatStuff. A lógica disso está no codebehind da página. Eu posso usar o mesmo botão para fazer uma chamada no lado do cliente para salvar os comentários antes da chamada no lado do servidor. Eu sabia que isso seria possível com o Web Forms usando um atributo da velha guarda, o onClientClick; mas no aplicativo de planilha de horas que eu estava modificando, eu também teria que realizar uma validação que determinaria se as horas da tarefa e a planilha de horas estavam prontas para serem salvas. Se a validação falhasse, não apenas eu teria que esquecer o salvamento da API Web, como também teria que evitar que o postback e o método de salvar no lado do servidor fossem executados. Eu estava tendo dificuldade em fazer isso funcionar usando o onClientClick, o que me encorajou a dar mais uma modernizada com o jQuery. Da mesma forma que eu posso responder aos cliques no CheckBox no cliente, eu posso ter uma resposta no lado do cliente ao btnSave sendo clicado. E isso vai acontecer antes do postback e da resposta do lado do servidor. Então eu posso ter os dois eventos em um clique do botão, assim: 

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

Eu tenho um método de validação stub no exemplo que sempre retorna true, mas eu testei para saber se as coisas se comportam de maneira correta mesmo que o retorno seja false. Naquele caso, eu uso o event.preventDefault do JavaScript para evitar processamento subsequente. Não apenas eu não vou salvar os comentários, como o postback e o salvamento no lado do servidor não vão acontecer. De outro modo, eu chamo o viewModel.save e a página continua com o comportamento que o botão pediu ao lado do servidor, salvando as escolhas do usuário do FunStuff. Minha função saveComments é chamada pelo viewModel.save, que pede ao entityManager do Breeze para executar um saveChanges:

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

Que, por sua vez, encontra o método SaveChanges do meu controlador e o executa:

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

Para isso funcionar, eu adicionei o Comments na camada de dados do EF6 e depois troquei o método controlador do Comments para executar uma consulta contra o banco de dados usando o componente do lado do servidor do Breeze (que faz um chamado à minha camada de dados do EF6). Por isso, os dados retornados ao cliente serão dados do banco, que o SaveChanges pode então salvar de volta ao banco de dados. Você pode ver isso no download de código de exemplo, que usa o EF6 e o Code First e vai criar e semear um banco de dados de exemplo.

Figura 7 JavaScript para atualizar a lista de comentários em resposta a um usuário clicando nas caixas de seleção

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 com uma pequena ajuda dos meus amigos

Ao trabalhar nesse projeto e no exemplo criado para este artigo, eu escrevi mais JavaScript do que nunca. Não é a minha área de especialização (como disse com frequência nesta coluna), mas eu fiquei bem orgulhosa do resultado. No entanto, sabendo que muitos leitores poderão estar vendo algumas dessas técnicas pela primeira vez, eu pedi ajuda ao Ward Bell da IdeaBlade (os criadores do Breeze) para uma revisão profunda do código, assim como para programar um pouco junto comigo e me ajudar a limpar um pouco do meu trabalho com o Breeze, assim como com o JavaScript e o jQuery. Exceto talvez pelo fato do uso do Knockout.js estar “datado” hoje em dia, o exemplo que você pode baixar deve ensinar algumas boas lições. Mas lembre-se que o foco é melhorar um projeto antigo do Web Forms com essas técnicas modernas que tornam a experiência do usuário tão mais prazerosa.

Julie Lerman é MVP da Microsoft, mentora e consultora do .NET, que reside nas colinas de Vermont. Você pode encontrá-la fazendo apresentações sobre acesso a dados e outros tópicos do Microsoft .NET em grupos de usuários e conferências em todo o mundo. Seu blog está em thedatafarm.com/blog e é autora do livro “Programming Entity Framework” (2010), além das edições Code First (2011) e DbContext (2012), todos da O’Reilly Media. Siga Julie no Twitter em twitter.com/julielerman e confira seus cursos da Pluralsight em juliel.me/PS-Videos.

Agradecemos aos seguintes especialistas técnicos da Microsoft pela revisão deste artigo: Damian Edwards (dedward@microsoft.com) e Scott Hunter (Scott.Hunter@microsoft.com)