msdn12_capa.JPG

Clique aqui para ler todos os artigos desta edição

 

Concorrências de dados com o ADO.NET

por John Papa

Este artigo discute

Este artigo usa as seguintes tecnologias:

·         Armazenamento e atualização de dados no DataSet

ADO.NET, Stored Procedure, C#

 

Download:

DataPoints0409.exe (148KB)

Chapéu

ADO.NET

 

 

Um dos recursos básicos do DataSet do ADO.NET é que pode ser um armazenamento de dados auto-suficiente e desconectado. Pode conter o esquema e os dados de vários rowsets nos objetos DataTable, assim como informações sobre como relacionar os objetos DataTable, todos na memória. O DataSet não sabe nem se importa com a origem dos dados, nem precisa de um link até uma fonte de dados básica. Como é independente de fonte de dados, você pode passar o DataSet por redes ou mesmo serializá-lo em XML e passar pela Internet sem perder qualquer um dos seus recursos. Contudo, em um modelo desconectado, a concorrência obviamente se torna um problema muito maior do que no modelo conectado.

Neste artigo, explorarei como o ADO.NET está equipado para detectar e tratar violações de concorrência. Começarei discutindo casos em que violações de concorrência podem ocorrer usando o modelo desconectado do ADO.NET. Depois percorrerei um aplicativo ASP.NET que trata as violações de concorrência dando ao usuário a opção de sobrescrever as alterações ou atualizar os dados fora de sincronismo e começar a editar novamente. Como parte da administração de um modelo de concorrência otimista pode envolver a manutenção de um timestamp (rowversion) ou outro tipo de flag que indique quando uma linha foi atualizada pela última vez, mostrarei como implementar esse tipo de flag e como manter seu valor após cada atualização de banco de dados.

 

Seu copo está meio cheio?

Há três técnicas comuns para administrar o que acontece quando os usuários tentam modificar os mesmos dados ao mesmo tempo: pessimista, otimista e o last-in wins (último que entrar ganha). Cada um deles trata os problemas de concorrência de maneira diferente.

O método pessimista diz: "Ninguém poderá causar uma violação de concorrência em meus dados se eu não os deixar chegar aos meus dados enquanto eu os estiver usando". Essa tática impede a concorrência, acima de tudo, mas limita a extensibilidade, porque impede todos os acessos concorrentes. Geralmente a concorrência pessimista tranca uma linha desde o momento em que for lida até o momento em que as atualizações forem atualizadas no banco de dados. Como isso exige uma conexão para permanecer aberto durante todo o processo, a concorrência pessimista não pode ser implementada com sucesso no modelo desconectado como o DataSet do ADO.NET, que abre uma conexão com duração suficiente apenas para preencher o DataSet e depois o libera e fecha, de modo que não pode ser mantida uma trava de banco de dados.

Outra técnica para lidar com concorrência é o método last-in wins. Esse modelo é bastante simples e fácil de implementar: qualquer modificação de dados que tiver sido feita por último é o que será gravado no banco de dados. Para implementar essa técnica é preciso apenas colocar os campos de chaves primárias da linha na cláusula WHERE da instrução UPDATE. Independente do que tiver sido alterado, a instrução UPDATE substituirá as alterações por suas próprias alterações, já que o que estará procurando é a linha que corresponde aos valores da chave primária. Ao contrário do modelo pessimista, o método last-in wins permite que os usuários leiam os dados enquanto estão sendo editados na tela. Contudo, poderá haver problemas quando os usuários tentarem modificar os mesmos dados ao mesmo tempo, porque os usuários podem escrever por cima das alterações uns dos outros, sem serem notificados sobre a colisão. O método last-in wins não detecta ou notifica o usuário sobre violações, porque não se importa. Contudo, a técnica otimista não detecta violações.

Nos modelos de concorrência otimista, uma linha somente é bloqueada durante a atualização no banco de dados. Assim os dados podem ser recuperados e atualizados por outros usuários a qualquer momento, a não ser durante a operação de atualização propriamente dita da linha. A concorrência otimista permite que os dados sejam lidos simultaneamente por diversos usuários e bloqueia outros usuários com menos freqüência do que seu correspondente pessimista, o que faz desse método uma boa opção para o ADO.NET. Nos modelos otimistas, é importante implementar algum tipo de detecção de violação de concorrência, que detectará qualquer tentativa adicional de modificar registros que já tiverem sido modificados, mas não confirmados. Você pode escrever seu código para tratar a violação, rejeitando e cancelando sempre o pedido de alteração, ou substituindo o pedido com base em algumas regras comerciais. Outra maneira de lidar com a violação de concorrência é deixar que o usuário decida o que fazer. O exemplo de aplicativo mostrado na Figura 1 ilustra algumas dessas opções que podem ser apresentadas ao usuário no caso de uma violação de concorrência.

 

image001.gif

Figura 1 Violação de concorrência

 

Para onde foram minhas alterações?

Quando os usuários correm o risco de substituir as alterações uns dos outros, devem-se implementar mecanismos de controle. Do contrário, as alterações poderão se perder. Se a técnica que você estiver usando for o método last-in wins, esses tipos de substituição serão inteiramente possíveis.

Por exemplo, imagine que Julie queira editar o sobrenome de um funcionário para corrigir a grafia. Ela navegará até uma tela que carrega as informações dos funcionários em um DataSet e as exibe em uma página Web. Enquanto isso, Scott é notificado que o telefone do mesmo funcionário mudou. Enquanto Julie está corrigindo o sobrenome do funcionário, Scott começa a corrigir o número do telefone. Julie salva suas alterações primeiro, em seguida Scott salva suas alterações.

Partindo do princípio de que o aplicativo usa o método last-in wins e atualiza a linha com uma cláusula SQL WHERE contendo somente o valor da chave primária, e supondo que uma alteração em uma coluna exija a atualização de toda a linha, nem Julie nem Scott poderão perceber imediatamente o problema de concorrência que acabou de ocorrer. Nessa situação em particular, as alterações de Julie foram substituídas pelas alterações de Scott. porque ele salvou por último, e o sobrenome foi revertido de volta à versão incorreta.

Então, como você pode ver, muito embora os usuários tenham alterado campos diferentes, suas alterações colidiram e fizeram com que as alterações de Julie fossem perdidas. Sem alguma forma de detecção e tratamento de concorrência, esses tipos de substituições podem ocorrer e até mesmo passar despercebidos.

Quando você executar o aplicativo de exemplo incluído no arquivo para download desta coluna, deverá abrir duas instâncias independentes do Microsoft® Internet Explorer. Quando gerei o conflito, abri duas instâncias para simular dois usuários com duas sessões independentes, de modo a provocar uma violação de concorrência no aplicativo de exemplo. Quando fizer isso, tenha cuidado para não usar Ctrl+N, porque se você abrir uma instância e depois usar a técnica do Ctrl+N para abrir outra instância, ambas as janelas compartilharão a mesma sessão.

 

Detectando violações

A violação de concorrência reportada ao usuário na Figura 1 demonstra o que pode acontecer se vários usuários editarem os mesmos dados ao mesmo tempo. Na Figura 1, o usuário tentou modificar o primeiro nome para "Joe" mas como outra pessoa já havia modificado o sobrenome para "Fuller III", foi detectada e reportada uma violação de concorrência. O ADO.NET detecta uma violação de concorrência quando um DataSet contendo valores alterados é passado a um método Update do SqlDataAdapter e nenhuma linha é realmente modificada. A simples utilização da chave primária (neste caso EmployeeID) na cláusula WHERE da instrução UPDATE não levará à uma detecção de violação, porque ainda atualiza a linha (na verdade, essa técnica produz o mesmo efeito que a técnica last-in wins). Em vez disso, devem ser especificadas mais condições na cláusula WHERE para que o ADO.NET detecte a violação.

O segredo aqui é tornar a cláusula WHERE suficientemente explícita para que não somente verifique a chave primária mas também uma outra condição apropriada. Uma maneira de conseguir isso é passar todos os campos modificáveis para a cláusula WHERE, além da chave primária. Por exemplo, o aplicativo mostrado na Figura 1 poderia ter sua instrução UPDATE parecida com a stored procedure mostrada na Listagem 1.

Observe que no código da Listagem 1 as colunas que podem receber valores null também são verificadas, para saber se o valor passado foi NULL. Esta técnica não só é confusa como também pode dificultar a manutenção manual e exige que você teste um número significativo de condições WHERE apenas para atualizar uma linha. Isso produz o resultado desejado de somente atualizar linhas em que nenhum dos valores tiver sido alterado desde a última vez que o usuário obteve os dados, mas há outras técnicas que não exigem uma cláusula WHERE tão grande.

 

Listagem 1 Colocando todos os campos na cláusula WHERE

CREATE PROCEDURE prUpd_EmployeeTest1

  @LastName nvarchar(20), @FirstName nvarchar(10),

  @Title nvarchar(30), @BirthDate datetime,

  @HireDate datetime, @Extension nvarchar(4),

  @Original_EmployeeID int, @Original_BirthDate datetime,

  @Original_Extension nvarchar(4), @Original_FirstName nvarchar(10),

  @Original_HireDate datetime, @Original_LastName nvarchar(20),

  @Original_Title nvarchar(30), @EmployeeID int

  AS

  UPDATE Employees

  SET LastName = @LastName, FirstName = @FirstName,

      Title = @Title, BirthDate = @BirthDate,

      HireDate = @HireDate,  Extension = @Extension

  WHERE (EmployeeID = @Original_EmployeeID)

  AND (FirstName = @Original_FirstName)

  AND (LastName = @Original_LastName)

  AND (BirthDate = @Original_BirthDate OR

       @Original_BirthDate IS NULL

  AND  BirthDate IS NULL)

  AND (Extension = @Original_Extension OR

       @Original_Extension IS NULL

  AND Extension IS NULL)

  AND (HireDate = @Original_HireDate OR

       @Original_HireDate IS NULL    

  AND HireDate IS NULL)

  AND (Title = @Original_Title OR

       @Original_Title IS NULL AND Title IS NULL);

 

Outra maneira de certificar que a linha será atualizada somente se não tiver sido alterada por outro usuário desde a obtenção dos dados é adicionar uma coluna timestamp à tabela. O tipo de dado TIMESTAMP do SQL Serverâ„¢ atualiza a si mesmo automaticamente com um novo valor toda vez que um valor for alterado na linha. Isto faz desse recurso uma ferramenta muito simples e cômoda para detectar violações de concorrência.

Uma terceira técnica é usar uma coluna DATETIME para rastrear as alterações feitas na linha correspondente. No meu aplicativo de exemplo, acrescentei uma coluna chamada LastUpdateDateTime à tabela Employees.

 

ALTER TABLE Employees ADD LastUpdateDateTime DATETIME

 

Lá atualizo o valor do campo LastUpdateDateTime automaticamente no procedimento armazenado UPDATE com a função GETDATE interna do SQL Server.

A coluna binária TIMESTAMP é de criação e uso simples, já que re-gera automaticamente seu valor sempre que sua linha for modificada, mas como a técnica da coluna DATETIME é mais fácil de ser apresentada na tela e de demonstrar quando ocorre uma alteração, eu a escolhi para meu aplicativo de exemplo. Ambas as opções são robustas, mas prefiro a técnica do TIMESTAMP por não envolver qualquer código adicional para atualizar seu valor.

 

Obtendo flags de linhas

Um dos segredos da implementação de controles de concorrência é a atualização do valor dos campos timestamp ou datetime dentro do DataSet. Se o mesmo usuário quiser fazer outras modificações, esse valor atualizado será refletido no DataSet de modo a poder ser usado novamente. Há algumas maneiras diferentes de se fazer isso. A mais rápida é usar parâmetros de saída dentro da stored procedure (Isto só deverá retornar se @@ROWCOUNT for igual a 1). A segunda maneira mais rápida envolve a seleção da linha novamente após o UPDATE dentro da stored procedure. A mais lenta envolve a seleção da linha a partir de outra instrução SQL ou stored procedure do evento RowUpdated do SqlDataAdapter.

Prefiro usar a técnica do parâmetro de saída, já que é a mais rápida e causa menos sobrecarga. O uso do evento RowUpdated funciona bem, mas exige que eu faça uma segunda chamada do aplicativo ao banco de dados. O trecho de código a seguir adiciona um parâmetro de saída ao objeto SqlCommand, usado para atualizar as informações de Employee:

 

oUpdCmd.Parameters.Add(new SqlParameter("@NewLastUpdateDateTime",

  SqlDbType.DateTime, 8, ParameterDirection.Output,

  false, 0, 0, "LastUpdateDateTime", DataRowVersion.Current, null));

oUpdCmd.UpdatedRowSource = UpdateRowSource.OutputParameters;

 

O parâmetro de saída tem seus argumentos sourcecolumn e sourceversion definidos de modo a apontar o valor de retorno do parâmetro de saída de volta ao valor corrente da coluna LastUpdateDateTime do DataSet. Assim o valor atualizado de DATETIME é obtido e pode ser retornado à página .aspx do usuário.

 

Salvando as alterações

Agora que a tabela Employees conta com o campo de rastreamento (LastUpdateDateTime) e o procedimento armazenado foi criado para usar a chave primária e o campo de rastreamento na cláusula WHERE da instrução UPDATE, vamos analisar o papel do ADO.NET. Para capturar o evento quando o usuário alterar os valores nas textboxes, criei um handler de evento para o evento TextChanged para cada controle de TextBox:

 

private void txtLastName_TextChanged(object sender, System.EventArgs e)

{

  // Obtenha o DataRow employee (só há uma linha, caso contrário eu poderia

  // executar um Find)

  dsEmployee.EmployeeRow oEmpRow =

           (dsEmployee.EmployeeRow)oDsEmployee.Employee.Rows[0];

  oEmpRow.LastName = txtLastName.Text;

  // Salve as alterações de volta na Session

  Session["oDsEmployee"] = oDsEmployee;

}

 

Esse evento recupera a linha e define o valor do campo apropriado a partir do TextBox (Outra maneira de obter os valores alterados é pegá-los quando o usuário clicar no botão Save). Cada evento TextChanged é executado depois que o evento Page_Load dispara em um postback, então, partindo do princípio de que o usuário alterou o primeiro e o último nomes, quando o usuário clicar no botão Save, os eventos poderiam disparar nesta ordem: Page_Load, txtFirstName_TextChanged, txtLastName_TextChanged e btnSave_Click.

O evento Page_Load pega a linha do DataSet no objeto Session; os eventos TextChanged atualizam o DataRow com os novos valores; e o evento btnSave_Click tenta salvar o registro no banco de dados. O evento btnSave_Click chama o método SaveEmployee (mostrado na Listagem 2) e passa ao mesmo um valor bLastInWins igual a falso, já que queremos tentar primeiro um save padrão. Se o método SaveEmployee detectar que as alterações foram feitas na linha (usando o método HasChanges no DataSet ou, opcionalmente, usando a propriedade RowState na linha), criará uma instância da classe Employee e passará o DataSet para seu método SaveEmployee. A classe Employee poderia situar-se numa camada intermediária lógica ou física (Eu queria torná-la uma classe separada, para que fosse fácil retirar o código e separá-lo da lógica da apresentação).

 

Listagem 2 Salvando as alterações

private void SaveEmployee(bool bLastInWins)

{

  try

  {

    // Caso as alterações tenham sido feitas, tente salvar o registro

    if (oDsEmployee.HasChanges(DataRowState.Modified))

    {

      // Crie uma instância do objeto Employee

      Employee oEmp = new Employee();

      // Salve as alterações no banco de dados

      dsEmployee oDsAfterUpdate =

         oEmp.SaveEmployee(oDsEmployee, bLastInWins);

      // Incorpore o valor do novo campo

      // LastUpdateDateTime de volta no DataSet local

      oDsEmployee.Merge(oDsAfterUpdate);

      // Exiba a mensagem de sucesso

      lblMessage.Text = "Changes have been applied successfully";

    }

    else

    {

      // Exiba a mensagem

      lblMessage.Text = "No modifications were made," +

      "nothing to save";

    }

  }

  catch (DBConcurrencyException exDBC)

  {

    // Além de exibir os valores atuais no DataSet,

    // exiba os valores originais do DataSet de

    // quando ele foi carregado pela primeira vez,

    // bem como os valores que estão atualmente no         

    // banco de dados

        FillConcurrencyValues((dsEmployee.EmployeeRow)exDBC.Row);

    btnSaveAnyway.Visible = true;

    // Exiba a mensagem de erro

    string sMsg = "Concurrency Exception occurred and changes" +

           "were not saved to the database." +

           "

" + exDBC.Message;

    lblMessage.Text = sMsg;

  }

  catch (Exception ex)

  {

    // Exiba a mensagem de erro

    lblMessage.Text = "Exception occurred and changes were" +

          "not saved to the database." +

          "

" + ex.Message;

  }

  finally

  {

    // Vincule o DataSet aos controles no form

    BindControls();

  }

}

 

Observe que não usei o método GetChanges para puxar somente as linhas modificadas e passá-las ao método Save do objeto Employee. Saltei essa etapa aqui já que há somente uma linha. Contudo, se houvesse várias linhas na DataTable do DataSet, seria melhor usar o método GetChanges para criar um DataSet que contivesse somente as linhas modificadas.

Se o save for bem sucedido, o método Employee.SaveEmployee retornará o DataSet contendo a linha modificada e a flag de versão da linha recém atualizada (nesse caso, o valor do campo LastUpdateDateTime). O DataSet é então combinado no DataSet original de modo que o valor do campo LastUpdateDateTime possa ser atualizado no DataSet original. Isto precisa ser feito porque se o usuário quiser fazer outras alterações, precisará que os valores correntes do banco de dados sejam combinados no DataSet local e mostrados na tela. Isto inclui o valor de LastUpdateDateTime, usado na cláusula WHERE. Sem o valor corrente desse campo, poderia ocorrer uma falsa violação de concorrência.

 

Reportando violações

Se ocorrer uma violação de concorrência, virá à tona e será pega pelo handler de exceção, mostrado na Listagem 2 no bloco de captura para DBConcurrencyException. O bloco chama o método FillConcurrencyValues, que apresenta tanto os valores originais do DataSet que tentou-se salvar no banco de dados como os valores presentes atualmente no banco de dados. Esse método é usado apenas para mostrar ao usuário por que a violação ocorreu. Observe que a variável exDBC é passada ao método FillConcurrencyValues. Essa instância da classe especial de exceção de concorrência de banco de dados (DBConcurrencyException) contém a linha onde a violação ocorreu. Se ocorrer uma violação de concorrência, a tela será atualizada e ficará como o mostrado na Figura 1.

O DataSet não somente armazena o esquema e os dados correntes, mas também rastreia alterações que tiverem sido feitas em seus dados. Ele sabe que linhas e colunas foram modificados e mantém o controle das versões anteriores e posteriores desses valores. Ao acessar o valor de uma coluna por meio do indexador de DataRow, além do índice da coluna você pode também especificar um valor usando o enumerador DataRowVersion. Por exemplo, depois que o usuário alterar o valor do sobrenome de um funcionário, as seguintes linhas de código C# recuperarão os valores original e corrente armazenados na coluna LastName:

 

string sLastName_Before = oEmpRow["LastName", DataRowVersion.Original];

string sLastName_After = oEmpRow["LastName", DataRowVersion.Current];

 

O método FillConcurrencyValues usa a linha de DBConcurrencyException e obtém uma cópia recente da mesma linha do banco de dados. Ele então exibe os valores usando os enumeradores DataRowVersion para exibir o valor original da linha antes da atualização e o valor do banco de dados junto com os valores atuais das textboxes.

 

Opção do usuário

Depois que o usuário tiver sido notificado sobre o problema de concorrência, você poderá deixar que decida como tratá-lo. Outra alternativa é codificar uma maneira específica de lidar com a concorrência, como sempre tratar a exceção para informar o usuário (mas atualizando os dados a partir do banco de dados). Neste exemplo de aplicativo, deixei que o usuário decidisse o que fazer em seguida. Ela poderia cancelar as alterações, cancelar e recarregar do banco de dados, salvar as alterações ou salvar de qualquer modo.

A opção de cancelar as alterações simplesmente chama o método RejectChanges do DataSet e religa o DataSet aos controles na página do ASP.NET. O método RejectChanges devolve as alterações feitas pelo usuário ao estado original definindo todos os valores correntes do campo aos seus valores originais. A opção de cancelar as alterações e recarregar o dados do banco de dados também rejeita as alterações, mas além disso volta ao banco de dados por meio da classe Employee para obter uma cópia recente dos dados, antes de religar ao controle da página do ASP.NET.

A opção de salvar as alterações tenta salvar as alterações, mas falhará se for encontrada uma violação de concorrência. Finalmente, incluí uma opção "save anyway". Esta opção toma os valores que o usuário tentou salvar e usa a técnica last-in wins, substituindo o que houver no banco de dados. Ele faz isso chamando um objeto de comando diferente associado a uma stored procedure que usa somente o campo da chave primária (EmployeeID) na cláusula WHERE da instrução UPDATE. Essa técnica deve ser usada com cautela, pois substituirá o registro.

Se você quiser uma maneira mais automática de tratar as alterações, poderá obter uma cópia recente do banco de dados. Em seguida substitua somente os campos que o usuário corrente tiver modificado, como o campo Extension. Dessa forma, no exemplo usei o LastName adequado que não seria substituído. Use isto também com cautela, no entanto, porque se o mesmo campo tiver sido alterado por ambos os usuários, você poderá ter que voltar ou consultar o usuário sobre o que deseja fazer em seguida. O que é óbvio aqui é que há várias maneiras de lidar com violações de concorrência, e todas devem ser avaliadas com cuidado antes de decidir a que será usada no aplicativo.

 

Conclusão

A definição da propriedade ContinueUpdateOnError do SqlDataAdapter informa ao SqlDataAdapter para que gere uma exceção quando ocorrer uma violação de concorrência ou salte a linha que causou a violação e continue com as atualizações restantes. Definindo-se essa propriedade como falsa (seu valor padrão), ele gerará uma exceção quando encontrar uma violação de concorrência. Essa técnica é ideal para salvar uma única linha ou quando se estiver tentando salvar várias linhas e quiser que ou todas sejam confirmadas ou todas falhem.

Dividi o tópico sobre gerenciamento de violação de concorrência em duas partes. Na próxima vez enfocarei o que fazer quando várias linhas puderem causar violações de concorrência. Descreverei também como os enumeradores do DataViewRowState podem ser usados para exibir que alterações foram feitas em um DataSet.