Por JOHN PAPA
| Este artigo discute | Este artigo usa as seguintes tecnologias | ||||||||||
|
|
Várias melhorias significativas foram introduzidas no ADO.NET 2.0, incluindo otimização de desempenho, flexibilidade e adição de novos recursos. No meu artigo da edição 24 ("DataSet e DataTable em ADO.NET 2.0"), discuti as melhorias adicionadas à classe DataTable, o efeito da nova e poderosa enumeração LoadOption, como mudar o estado de um registro e a nova classe DataTableReader. Nesta edição continuarei explorando as melhorias significativas que foram introduzidas com o ADO.NET 2.0, tais como o novo SqlConnectionStringBuilder, melhorias no DataView, atualização em lote (batch update) e serialização otimizada de DataSet. Também mostrarei como algumas das novas características se comportam quando são colocadas à prova.
O ADO.NET 1.x provê formas para serializar um DataSet. A má notícia é que o formato XML usado para DataSets pode causar problemas de desempenho quando um grande DataSet for serializado e transmitido via rede. O ADO.NET 2.0 pode serializar um DataSet ou um DataTable em formato binário puro, resultando em menor consumo de memória e largura da banda de rede.
Vejamos como implementar serialização binária em um DataTable. A propriedade RemotingFormat do DataTable foi introduzida no ADO.NET 2.0 que pode ser usada para fixar o formato da serialização. A propriedade RemotingFormat espera por um dos dois valores de enumeração do SerializationFormat: SerializationFormat.Xml ou SerializationFormat.Binary. Uma vez definido o RemotingFormat, podemos criar uma instância da classe BinaryFormatter para serializar o DataTable. O exemplo da Listagem 1 carrega uma lista de clientes em um DataTable e o serializa no formato binário.
Listagem 1. Serialização binária de um DataTable
string cnStr = @"server=.;database=northwind;integrated security=true;";
using (SqlConnection cn = new SqlConnection(cnStr))
{
string sql = "SELECT o.OrderID, o.CustomerID, o.OrderDate, " +
"od.ProductID, p.ProductName, od.UnitPrice, " +
"od.Quantity FROM Orders o " +
"INNER JOIN [Order Details] od " +
"ON o.OrderID = od.OrderID INNER JOIN Products p ON " +
" p.ProductID = od.ProductID ";
SqlCommand cmd = new SqlCommand(sql, cn);
SqlDataAdapter adpt = new SqlDataAdapter(cmd);
DataTable dtCustomers = new DataTable("Customers");
adpt.Fill(dtCustomers);
BinaryFormatter bin = new BinaryFormatter();
using (StreamWriter sw = new StreamWriter(@"c:\customers.bin"))
{
dtCustomers.RemotingFormat = SerializationFormat.Binary;
bin.Serialize(sw.BaseStream, dtCustomers);
}
}
Um DataTable que é serializado em formato binário é geralmente menor em tamanho que o mesmo DataTable serializado em formato XML. Mas o quanto ele é menor?
Como sou cético, fiz alguns testes para ver quão diferente é o tamanho de um DataSet quando serializado em formato XML versus formato binário. Para meus testes, criei uma tabela chamada MyOrders e adicionei vários registros à mesma (variando de 1 a 100.000). Então rodei o código da Listagem 2, que produziu os resultados mostrados na Tabela 1 (os resultados podem variar, dependendo dos dados utilizados).
Listagem 2. Serializando um DataTable
string cnStr = @"server=.;database=northwind;integrated security=true;";
for (int power = 0; power <= 5; power++)
{
using (SqlConnection cn = new SqlConnection(cnStr))
{
double top = (Math.Pow(10, power));
string sql = "SELECT TOP " + top +
" OrderID, CustomerID, OrderDate, " +
"ProductID, ProductName, UnitPrice, Quantity" +
"FROM MyOrders ";
SqlCommand cmd = new SqlCommand(sql, cn);
SqlDataAdapter adpt = new SqlDataAdapter(cmd);
DataTable dtMyOrders = new DataTable("MyOrders");
adpt.Fill(dtMyOrders);
BinaryFormatter bin = new BinaryFormatter();
using (StreamWriter sw = new StreamWriter(
@"c:\myorders" + top + ".bin"))
{
dtMyOrders.RemotingFormat = SerializationFormat.Binary;
bin.Serialize(sw.BaseStream, dtMyOrders);
}
using (StreamWriter sw = new StreamWriter(
@"c:\myorders" + top + ".xml"))
{
dtMyOrders.RemotingFormat = SerializationFormat.Xml;
bin.Serialize(sw.BaseStream, dtMyOrders);
}
}
}
| Linhas | KB Binário | KB XML | KB / Registro Binário | KB / Registro XML |
1 | 9.1 | 2.15 | 9.1 | 2.15 |
10 | 9.67 | 5.38 | 0.967 | 0.538 |
25 | 10.5 | 10.6 | 0.42 | 0.424 |
100 | 14.9 | 37.3 | 0.149 | 0.373 |
1,000 | 68.2 | 389 | 0.0682 | 0.389 |
10,000 | 602 | 3596 | 0.0602 | 0.3596 |
100,000 | 5934 | 36137 | 0.05934 | 0.36137 |
Uma coisa dá para perceber: nos testes que fiz para 1 e para 10 registros, mostram que os arquivos binários são realmente maiores que os arquivos XML. Para descobrir onde a interseção do número de registros acontece, fiz o teste umas 100 vezes adicionais para as linhas de 1 a 100. Para meus dados de teste, o arquivo binário era menor que o XML, desde que houvesse 25 ou mais registros, portanto, acrescentei os resultados para 25 registros à grade da Tabela 1. A conclusão a que chegamos, é que o formato binário pode reduzir significativamente a largura de banda e tempo de rede necessários para transferir um DataTable ou um DataSet.
Indo um passo adiante, acrescentei duas colunas adicionais à Tabela 1 para representar a quantidade de memória consumida por registro. Observando os dados XML serializados e a partir de 100 registros ou mais, note como a quantidade de espaço requerido por registro não varia muito (0.375 contra 0.389). Porém, a memória requerida para cada registro pelo método de serialização binária diminui significativamente, à medida que o número de registros aumenta (0.149KB para 100 linhas, diminuindo progressivamente até 0.059KB para 100,000 registros).
É importante lembrar de que ainda é uma boa prática transferir o menor número necessário de registros na aplicação. Se estiver transferindo dados para uma camada de dados, passe só as informações modificadas (inserções, atualizações e exclusões), usando o método GetChanges do DataTable, pois raramente é necessário transferir dados que não foram modificados. Porém, se sua aplicação requer que seja passado um conjunto completo de dados entre camadas ou possivelmente para outro sistema, a serialização binária pode ser bastante conveniente.
O DataView é excelente para filtrar, ordenar e apresentar dados ao usuário em um formato mais amigável. No ADO.NET 2.0, a classe DataView provê várias características novas que a tornam ainda mais poderosa. Por exemplo, assumindo para o ADO.NET 1.x, que tivéssemos um DataTable preenchido com uma lista de clientes obtidos de um banco de dados. O DataTable é usado para armazenar a lista de clientes e então um DataView é usado com base no DataTable, para exibir ao usuário a lista de clientes em uma grade ordenada. Neste exemplo, o DataView está sendo usado como um filtro para apresentação de dados. Indo um passo adiante, poderíamos querer obter uma lista de cidades a partir dos dados do cliente, e carregá-la em outra grade ou lista dropdown. Mas se também quisermos persistir o DataView dos clientes ou o DataView das cidades, não poderemos fazê-lo diretamente do DataView. Isso não é mais assim no ADO.NET 2.0.
Pelo método DataView.ToTable, o DataView permite agora criar um DataTable diretamente a partir do DataView. O novo DataTable é um objeto separado e distinto que não é vinculado ao DataTable original. Por exemplo, assumindo que tivéssemos um DataTable chamado dtCust e criássemos um DataView chamado view para exibir uma lista filtrada e ordenada dos clientes no dtCust. Se invocássemos então o método ToTable do objeto view para criar um segundo DataTable chamado dtCities, teríamos agora dois objetos DataTable, cada um armazenando suas próprias cópias separadas dos dados. Assim, se alterássemos um cliente em dtCust, não afetaríamos absolutamente os dados em dtCities.
O código da Listagem 3 cria um DataTable chamado dtCust e o carrega com uma lista de clientes de um banco de dados. A seguir, cria um DataView chamado vwCust, que só contém uma lista distinta das cidades dos clientes dos E.U.A. O método ToTable do DataView é usado para criar um novo DataTable chamado dtCities. Este exemplo usa a assinatura sobrecarregada do método ToTable, que aceita o nome do novo DataTable, um valor booleano que indica se valores distintos devem ou não ser obtidos, e um array de strings que contém a lista de colunas a serem obtidas do DataView e acrescentadas ao novo DataTable.
Listagem 3. Usando ToTable para criar um DataTable a partir de um DataView
string cnStr = @"server=.;database=northwind;integrated security=true;";
string sql = "SELECT CustomerID, CompanyName, City, Region, Country " +
"FROM Customers ORDER BY CustomerID";
DataTable dtCust = new DataTable();
using(SqlConnection cn = new SqlConnection(cnStr))
{
SqlCommand cmd = new SqlCommand(sql, cn);
SqlDataAdapter adpt = new SqlDataAdapter(cmd);
adpt.Fill(dtCust);
}
DataView view = new DataView(dtCust);
view.RowFilter = "Country='USA'";
view.Sort = "City";
DataTable dtCities = view.ToTable("CustomerCities",
true, "City", "Country");
O ADO.NET 2.0 apresenta uma nova classe chamada SqlConnectionStringBuilder, que pode ser usada para ajudar a construir um string de conexão. Tudo que temos a fazer é instanciá-la e definir suas propriedades, e a string de conexão será construída (através de sua propriedade ConnectionString). O código de exemplo a seguir mostra como funciona o SqlConnectionStringBuilder:
SqlConnectionStringBuilder cnBldr = new SqlConnectionStringBuilder();
cnBldr.DataSource = "MyServer";
cnBldr.InitialCatalog = "Northwind";
cnBldr.IntegratedSecurity = true;
using (SqlConnection cn = new SqlConnection(cnBldr.ConnectionString))
{
...
}
Neste exemplo, defini o nome do servidor pela propriedade DataSource, o nome do banco de dados pela propriedade InitialCatalog e determinei que fosse usada segurança integrada. O IntelliSense torna essa abordagem mais fácil do que tentar construir um string de conexão via código, e a natureza fortemente tipada ajuda a eliminar inconsistências. Não há mais nenhuma necessidade de tentar lembrar do nome do atributo para a string de conexão. O Microsoft .NET Framework também vem com as classes System.Data.OleDb.OleDbConnectionStringBuilder, System.Data.Odbc.OdbcConnectionStringBuilder e System.Data.OracleClient.OracleConnectionStringBuilder. Todas elas são classes fortemente tipadas que herdam da classe DBConnectionStringBuilder. Claro que, se o provedor de banco de dados não oferecer uma classe construtora de strings, poderíamos criar nossa própria classe para construir conexões, herdando da classe DBConnectionStringBuilder.
O recurso de Batch Updates (atualizações em lote) é uma das melhores novas características do ADO.NET. A atualização em lote é fácil de implementar e reduz o tráfego e comunicação entre a camada de dados e o banco de dados. Para mostrar a utilidade de atualizações em lote, veremos um cenário de atualização comum que usa o ADO.NET 1.x sem atualizações em lote e então discutiremos um outro que usa o ADO.NET 2.0 com a nova funcionalidade.
Em primeiro lugar, criarei um DataTable e o carregarei com 100 registros de um banco de dados e o apresentarei a um usuário por meio de uma grade. Adicionarei 12 registros, modificarei 9 e apagarei 11 registros do DataTable. Neste momento tenho um DataTable com 70 linhas inalteradas, 9 modificadas, 11 apagadas e 12 adicionadas. Então uso o método GetChanges para obter um novo DataTable que contém só os deltas (as linhas modificadas, adicionadas e apagadas). Só quero os deltas, pois não vejo nenhuma razão para transferir todas as linhas para a camada de dados através das camadas da aplicação. Na camada de dados, crio um DataAdapter e configuro suas propriedades InsertCommand, UpdateCommand e DeleteCommand para usarem stored procedures. Quando o método Update do DataAdapter for executado sobre o delta DataTable, o UpdateCommand será executado para cada um dos registros modificados. De forma semelhante, quando houver um registro apagado ou adicionado, o DeleteCommand e InsertCommand são usados, respectivamente. O método DataAdapter.Update executa 30 chamadas às stored procedures (1 para cada registro modificado) e faz 30 percursos diferentes de ida e volta entre as camadas de dados e BD.
Neste exemplo, usando técnicas do ADO.NET 1.1, cada execução consiste em chamar o stored procedure e enviar dados ao BD, esperar por qualquer informação que possa ser retornada e então executar o próximo stored procedure. Se as camadas de dados e a camada do banco de dados estão em diferentes servidores físicos, acabamos de introduzir 30 percursos de ida e volta pela rede. Se tivéssemos 100 registros alterados, aconteceriam 100 percursos de ida e volta. Dá para imaginar!
Este mesmo exemplo com o ADO.NET 2.0 reduz os percursos de ida e volta de rede usando atualizações em lote. Para isso, antes de executar o método DataAdapter.Update, defini a propriedade UpdateBatchSize do DataAdapter para 30. Isso diz para o DataAdapter que envie 30 execuções de comando de cada vez ao banco de dados. Desta vez, quando o método DataAdapter.Update executar e iterar pelos registros do delta, preparará as stored procedures a serem executadas até que 30 estejam preparadas. Então enviará os 30 comandos ao banco de dados em uma única requisição RPC. Neste exemplo, todas as 30 chamadas aos stored procedures são enviadas pela rede e então executadas individualmente no servidor de banco de dados. Assim, só um percurso de ida e volta de rede é requerido, o que agiliza este cenário.
Para efeito de demonstração, construí um exemplo mostrado na Listagem 4, que emula o cenário antes descrito, em um único bloco de código. Em um cenário do mundo real, não recomendaria colocar todo este código em um único método. Neste exemplo, recupero alguns registros da tabela de clientes, modifico alguns e acrescento um novo ao DataTable. Então crio o DataAdapter e seus comandos usando declarações SQL. Neste momento, defino o UpdateBatchSize para 10, o que efetivamente diz ao DataAdapter para agrupar seus comandos e enviá-los em grupos de 10 (ou menos no caso de haver menos de 10 operações para serem executadas).
Listagem 4. Implementando atualizações em lote
string cnStr = @"server=.;database=northwind;integrated security=true;";
string sql = "SELECT CustomerID, CompanyName, City, Region, Country " +
"FROM Customers ORDER BY CustomerID";
using (SqlConnection cn = new SqlConnection(cnStr))
{
SqlCommand selCmd = new SqlCommand(sql, cn);
SqlDataAdapter adpt = new SqlDataAdapter(selCmd);
DataTable dtCustomers = new DataTable("Customers");
adpt.Fill(dtCustomers);
dtCustomers.PrimaryKey =
new DataColumn[] { dtCustomers.Columns["CustomerID"] };
// Adiciona uma linha
DataRow newRow = dtCustomers.NewRow();
newRow["CustomerID"] = "FOO";
newRow["CompanyName"] = "The Foo Company";
newRow["City"] = "Fooville";
dtCustomers.Rows.Add(newRow);
// Modifica uma linha
DataRow row = dtCustomers.Rows.Find("ALFKI");
row["City"] = "Here";
// Prepara o InsertCommand
string insSql = "INSERT Customers (CustomerID, CompanyName, City) " +
"VALUES (@CustomerID, @CompanyName, @City)";
SqlCommand insCmd = new SqlCommand(insSql, cn);
insCmd.UpdatedRowSource = UpdateRowSource.None;
insCmd.Parameters.Add("@CustomerID", SqlDbType.NChar,
10, "CustomerID");
insCmd.Parameters.Add("@CompanyName", SqlDbType.NChar,
80, "CompanyName");
insCmd.Parameters.Add("@City", SqlDbType.NChar, 30, "City");
adpt.InsertCommand = insCmd;
// Prepara o UpdateCommand
string updSql = "UPDATE Customers SET CompanyName = @CompanyName, " +
"City = @City WHERE CustomerID = @CustomerID";
SqlCommand updCmd = new SqlCommand(updSql, cn);
updCmd.UpdatedRowSource = UpdateRowSource.None;
updCmd.Parameters.Add("@CompanyName", SqlDbType.NChar,
80, "CompanyName");
updCmd.Parameters.Add("@City", SqlDbType.NChar, 30, "City");
updCmd.Parameters.Add("@CustomerID", SqlDbType.NChar,
10, "CustomerID");
adpt.UpdateCommand = updCmd;
// Define o Batch Size. 0 (zero) significa tudo; o padrão é 1.
adpt.UpdateBatchSize = 10;
// Envia as linhas novas e alteradas para o database
adpt.Update(dtCustomers.GetChanges());
}
Portanto, a idéia é agrupar comandos múltiplos e empacotá-los para transmissão pela rede ao servidor de banco de dados. Por padrão, o UpdateBatchSize é fixado em 1, ou seja, funcionará como no ADO.NET 1.1, onde cada registro do delta faz com que um único comando seja enviado de cada vez ao banco de dados. Ao usar um valor explícito, como 10, o DataAdapter empacota 10 comandos e os envia em uma única chamada RPC para o servidor de banco de dados. Se houver um total de 15 registros modificados, adicionados e apagados, e se o UpdateBatchSize for fixado em 10, serão transmitidos 2 pacotes separados: o primeiro com 10 linhas e o segundo com as 5 linhas restantes. Se o UpdateBatchSize for fixado em 0, o DataAdapter tentará enviar todas as execuções de comando em um único pacote. Se atualizações em lote não forem suportadas, então o UpdateBatchSize é revertido automaticamente para 1.
Atualizações em lote são implementadas empacotando tudo em uma única requisição RPC. Assim se houver seis comandos a serem executados no banco de dados, todos serão enviados em uma única requisição. A requisição RPC conterá cada comando SQL separado por um marcador RPC. Isso é importante porque se observarmos o SQL Profiler depois de rodar o exemplo da Listagem 4, veremos dois comandos que são executados individualmente no banco de dados. Essa técnica também é melhor que algumas outras técnicas que limitam o número de parâmetros ou o tamanho do comando SQL. A primeira vez que usei atualizações em lote, achei que havia algo estranho porque observei o SQL Profiler e vi todos os comandos SQL executados. Só quando usei um sniffer de rede, foi que constatei claramente que só um percurso de ida e volta estava acontecendo.
Há um tempo e lugar para tudo, e as atualizações em lote não são nenhuma exceção. Atualizações em lote não suportam o uso de parâmetros de retorno (return) ou de saída (output), assim será melhor evitar o uso de atualização em lote, caso precisemos de parâmetros desses tipos. Atualizações em lote também afetam o evento RowUpdated, pois o mesmo só dispara uma vez para cada lote, embora sejam feitas várias atualizações. A Tabela 2 mostra algumas diferenças na execução do manipulador de evento RowUpdated baseado no UpdateBatchSize quando 25 registros forem atualizados.
| UpdateBatchSize | Número de vezes que o RowUpdated dispara | Comentários |
0 | 1 | Uma vez para o lote completo |
1 | 25 | Uma vez por registro |
4 | 7 | Uma vez cada 4 registros: Limitado(25/4) |
10 | 3 | Uma vez para cada 10 registros: Limitado(25/10) |
25 | 1 | Uma vez para o lote completo |
30 | 1 | Uma vez para o lote completo |
Tabela 2. Execução do Evento RowUpdated com 25 registros
Quando usar atualização em lote, lembre que a propriedade Row exposta no manipulador de evento RowUpdated, representará a última linha do lote. Se a contagem de linhas atual no lote for menor que o UpdateBatchSize, a propriedade Rows retornará nulo.
O ADO.NET 2.0 traz vários novos recursos e melhorias para suas classes básicas, além de novas classes, algumas das quais trazem um melhor desempenho e outras maior funcionalidade e flexibilidade. Com todas as mudanças no ADO.NET 2.0, uma boa idéia é experimentar as características um pouco de cada vez, assim poderemos refinar nossas ferramentas para acesso a banco de dados e estar pronto para obter o melhor desempenho com o menor custo para aplicações orientadas a dados.
Envie suas perguntas e comentários para John (em inglês) através do e-mail mmdata@microsoft.com.
John Papa é um Consultor .NET Senior em ASPSOFT (aspsoft.com) e um fanático do beisebol que gasta a maioria das noites de verão torcendo para os Yankees com a sua família e o seu cachorro fiel Kadi. É autor de vários livros em ADO, XML e Servidor SQL, e pode ser achado freqüentemente palestrando em conferências para a indústria, como VSLive, ou "blogando" em codebetter.com/blogs/john.papa.