Mês: abril 2012

Integrações entre Sistemas – Parte 3 – File Transfer

Objetivo

Nesta parte veremos uma breve explicação sobre o código em relação ao método de transferência de arquivos. Para isso antes, será necessário explicar a estrutura das classes de acesso a dados, que fazem o trabalho propriamente dito.

Classe de acesso a dados

Como o objetivo desta série não é exercitar patterns, encapsulamento, nada deste tipo, a classe de acesso a dados somente contém a lógica de acesso ao banco, segue o código dela:

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using IntegrationTests.ServiceClasses.Domain;
using System.Data.SqlClient;
using System.Data;

namespace IntegrationTests.ServiceClasses
{
    public class DAO
    {
        public static ServiceTable GetServiceTable(string ConnString, int ServiceTableID)
        {
            ServiceTable result = new ServiceTable();

            SqlConnection conn = new SqlConnection(ConnString);
            conn.Open();

            SqlCommand cmd = new SqlCommand("select ServiceTableID, DescServiceTable, Value, CreationDate, StringField1, StringField2 " +
                "from ServiceTable where ServiceTableID = @ServiceTableID", conn);

            using (conn)
            {
                SqlParameter p1 = cmd.Parameters.Add("@ServiceTableID", SqlDbType.Int);
                p1.Value = ServiceTableID;

                SqlDataReader rd = cmd.ExecuteReader();
                rd.Read();
                using (rd)
                {
                    result.ServiceTableID = rd.GetInt32(0);
                    result.DescServiceTable = rd.GetString(1);
                    result.Value = (float)rd.GetDouble(2);
                    result.CreationDate = rd.GetDateTime(3);
                    result.StringField1 = rd.GetString(4);
                    result.StringField2 = rd.GetString(5);
                }
            }

            return result;
        }

        public static void ProcessServiceTable(string ConnString, ServiceTable table)
        {
            SqlConnection conn = new SqlConnection(ConnString);
            conn.Open();

            SqlCommand cmd = new SqlCommand("insert into ClientTable (ClientTableID, DescClientTable, Value, CreationDate, StringField1, StringField2)" +
                "values (@ClientTableID, @DescClientTable, @Value, @CreationDate, @StringField1, @StringField2)", conn);

            using (conn)
            {

                SqlParameter p1 = cmd.Parameters.Add("@ClientTableID", SqlDbType.Int);
                SqlParameter p2 = cmd.Parameters.Add("@DescClientTable", SqlDbType.VarChar, 200);
                SqlParameter p3 = cmd.Parameters.Add("@Value", SqlDbType.Float);
                SqlParameter p4 = cmd.Parameters.Add("@CreationDate", SqlDbType.DateTime);
                SqlParameter p5 = cmd.Parameters.Add("@StringField1", SqlDbType.VarChar, 200);
                SqlParameter p6 = cmd.Parameters.Add("@StringField2", SqlDbType.VarChar, 200);

                p1.Value = table.ServiceTableID;
                p2.Value = table.DescServiceTable;
                p3.Value = table.Value;
                p4.Value = table.CreationDate;
                p5.Value = table.StringField1;
                p6.Value = table.StringField2;

                cmd.ExecuteNonQuery();
            }
        }

        public static void ClearClientTable(string ConnString)
        {
            ServiceTable result = new ServiceTable();

            SqlConnection conn = new SqlConnection(ConnString);
            conn.Open();

            SqlCommand cmd = new SqlCommand("delete from ClientTable", conn);

            using (conn)
                cmd.ExecuteNonQuery();
        }
    }
}

Como vemos, a maioria dos métodos são estáticos, somente para simplificar o uso da classe, visto que ela não tem nenhuma propriedade ou dado.

Os métodos GetServiceTable table e ProcessServiceTable simplemente pegam ou inserem um registro da base, sempre utilizando uma representação em classe invés de um dataset (Classe ServiceTable).

A string de conexão sendo passada como parâmetro é uma péssima prática, mas não é nosso objetivo trabalhar nesse tipo de conceito aqui.

Utilitários para trabalhar com Stream

Como a maioria dos métodos de transferência utilizam-se de streams para trafegar os dados, optei por construir esta classe, para encapsular o comportamento de gravar/ler de streams, sempre utilizando um formato XML interno, visto que nosso objetivo é justamente manter o mesmo payload e somente trocar o método de transferência.

Segue o código da classe StreamUtil, para trabalhar com streams:

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.IO;
using System.Xml;
using IntegrationTests.ServiceClasses.Domain;
using System.Globalization;

namespace IntegrationTests.ServiceClasses
{
    public class StreamUtil
    {
        public void ProcessClientBigRequest(string ConnString, Stream requestStream, Stream responseStream, bool dispose)
        {
            XmlTextReader xr = new XmlTextReader(requestStream);

            XmlTextWriter xw = new XmlTextWriter(responseStream, Encoding.UTF8);

            xw.WriteStartDocument();
            xw.WriteStartElement("table");

            using (xr)
            {
                xr.Read();
                xr.Read();
                xr.ReadStartElement("request");
                while (xr.Name == "id")
                {
                    ServiceTable st = DAO.GetServiceTable(ConnString, Convert.ToInt32(xr.ReadElementString("id")));

                    xw.WriteStartElement("record");
                    xw.WriteElementString("ServiceTableID", st.ServiceTableID.ToString());
                    xw.WriteElementString("DescServiceTable", st.DescServiceTable);
                    xw.WriteElementString("Value", st.Value.ToString("0.00"));
                    xw.WriteElementString("CreationDate", st.CreationDate.ToString("dd/MM/yyyy hh:mm:ss"));
                    xw.WriteElementString("StringField1", st.StringField1);
                    xw.WriteElementString("StringField2", st.StringField2);
                    xw.WriteEndElement();

                }
                xr.ReadEndElement();
            }
            xw.WriteEndElement();
            xw.Flush();
            Console.WriteLine("Finished processing big request");


            if (dispose)
                xw.Close();
        }

        public void GenerateBigRequest(Stream stream, bool dispose, int size)
        {
            XmlTextWriter xw = new XmlTextWriter(stream, Encoding.UTF8);

            xw.WriteStartDocument();
            xw.WriteStartElement("request");
            for (int i = 0; i < size; i++)
            {
                xw.WriteElementString("id", (i + 1).ToString());
            }
            xw.WriteEndElement();
            xw.Flush();

            if (dispose)
                xw.Close();
        }

        public void GenerateBigRequest(Stream stream, bool dispose, int start, int end)
        {
            XmlTextWriter xw = new XmlTextWriter(stream, Encoding.UTF8);

            xw.WriteStartDocument();
            xw.WriteStartElement("request");
            for (int i = start; i <= end; i++)
            {
                xw.WriteElementString("id", (i).ToString());
            }
            xw.WriteEndElement();
            xw.Flush();

            if (dispose)
                xw.Close();
        }

        public XmlTextWriter GenerateBigRequestStart(Stream stream)
        {
            XmlTextWriter xw = new XmlTextWriter(stream, Encoding.UTF8);
            xw.WriteStartDocument();
            xw.WriteStartElement("request");
            return xw;
        }

        public void GenerateBigRequestItem(XmlTextWriter xw, int id)
        {
            xw.WriteElementString("id", id.ToString());
        }

        public void GenerateBigRequestEnd(XmlTextWriter xw)
        {
            xw.WriteEndElement();
        }

        public void ImportarStream(string connString, Stream stream)
        {
            XmlTextReader rd = new XmlTextReader(stream);

            CultureInfo c = new CultureInfo("pt-BR");
            c.DateTimeFormat.ShortDatePattern = "dd/MM/yyyy hh:mm:ss";

            using (rd)
            {
                rd.Read();
                rd.Read();
                rd.ReadStartElement("table");
                while (rd.Name == "record")
                {
                    rd.ReadStartElement("record");
                    ServiceTable st = new ServiceTable();
                    st.ServiceTableID = Convert.ToInt32(rd.ReadElementContentAsString("ServiceTableID", ""));
                    st.DescServiceTable = rd.ReadElementContentAsString("DescServiceTable", "");
                    st.Value = Convert.ToSingle(rd.ReadElementContentAsString("Value", ""));
                    st.CreationDate = Convert.ToDateTime(rd.ReadElementContentAsString("CreationDate", ""), c.DateTimeFormat);
                    st.StringField1 = rd.ReadElementContentAsString("StringField1", "");
                    st.StringField2 = rd.ReadElementContentAsString("StringField2", "");
                    rd.ReadEndElement();

                    DAO.ProcessServiceTable(connString, st);
                }
                rd.ReadEndElement();
            }
        }

    }
}

O método ProcessClientBigRequest recebe um xml com os ID’s que devem ser retornados na requestStream. Ele tem por objetivo ler essa stream e gravar o resultado na ResponseStream. Internamente, a classe DAO é usada para retornar os dados do banco. O parâmetro dispose, indica somente se o XmlWriter usado para escrever o código será liberado no final. Ele foi criado, pois quando estamos utilizando MemoryStream, e liberamos o XmlWriter, a Stream é liberada também. Não desejamos este comportamento em alguns casos.

O método GenerateBigRequest, gera um arquivo de request para o método ClientBigRequest. O inteiro que ele recebe é somente a quantidade de registros que ele vai colocar no request. Ele tem duas variações, uma recebendo o tamanho, outra recebendo o ID de início e de fim. A segunda será usada no futuro. Outra variação são os métodos GenerateBigRequestStart, GenerateBigRequestItem e GenerateBigRequestEnd. Um escreve o cabeçalho, outro os itens de processamento, outro o fim.

Por último, o método ImportarStream. Este método faz o trabalho de receber a stream com a resposta (todos os dados) e inserir na tabela, é quem processa a “resposta” do servidor.

Utilitário para trabalhar com arquivos

A classe abaixo basicamente faz as mesmas coisas que a de cima, só encapsula parâmetros de arquivos, cria FileStreams e joga para a StreamUtil fazer o trabalho sujo.

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Data.SqlClient;
using System.Xml;
using System.Data;
using System.IO;
using System.Globalization;
using IntegrationTests.ServiceClasses.Domain;
using System.Threading;

namespace IntegrationTests.ServiceClasses
{
    public class FileUtil
    {
        public void ExtrairParaArquivo(string ConnString, int IDInicial, int IDFinal, string file)
        {
            SqlConnection conn = new SqlConnection(ConnString);
            conn.Open();

            SqlCommand cmd = new SqlCommand("select ServiceTableID, DescServiceTable, Value, CreationDate, StringField1, StringField2 " +
                "from ServiceTable where ServiceTableID between @IDInicial and @IDFinal", conn);

            XmlTextWriter xw = new XmlTextWriter(file, Encoding.UTF8);

            using (xw)
            {
                xw.WriteStartDocument();
                xw.WriteStartElement("table");

                using (conn)
                {
                    SqlParameter p1 = cmd.Parameters.Add("@IDInicial", SqlDbType.Int);
                    p1.Value = IDInicial;
                    SqlParameter p2 = cmd.Parameters.Add("@IDFinal", SqlDbType.Int);
                    p2.Value = IDFinal;

                    SqlDataReader rd = cmd.ExecuteReader();
                    using (rd)
                    {
                        while (rd.Read())
                        {
                            int ServiceTableID = rd.GetInt32(0);
                            string DescServiceTable = rd.GetString(1);
                            string Value = ((float)rd.GetDouble(2)).ToString("0.00");
                            string CreationDate = rd.GetDateTime(3).ToString("dd/MM/yyyy hh:mm:ss");
                            string StringField1 = rd.GetString(4);
                            string StringField2 = rd.GetString(5);

                            xw.WriteStartElement("record");
                            xw.WriteElementString("ServiceTableID", ServiceTableID.ToString());
                            xw.WriteElementString("DescServiceTable", DescServiceTable);
                            xw.WriteElementString("Value", Value);
                            xw.WriteElementString("CreationDate", CreationDate);
                            xw.WriteElementString("StringField1", StringField1);
                            xw.WriteElementString("StringField2", StringField2);
                            xw.WriteEndElement();
                        }
                    }
                }
                xw.WriteEndElement();
            }
        }

        public void ImportarArquivo(string connString, string file)
        {
            FileStream fs = new FileStream(file, FileMode.Open, FileAccess.Read, FileShare.None);            
            StreamUtil u = new StreamUtil();
            u.ImportarStream(connString, fs);            
        }

        public int ProcessRequest(string requestFile)
        {
            FileStream fs = new FileStream(requestFile, FileMode.Open, FileAccess.Read, FileShare.None);
            byte[] buffer = new byte[sizeof(int)];
            fs.Read(buffer, 0, sizeof(int));
            return BitConverter.ToInt32(buffer, 0);
        }

        public void ProcessClientRequest(string ConnString, string responseFile, int size)
        {            
            ExtrairParaArquivo(ConnString, 1, size, responseFile);
        }

        public void ProcessClientBigRequest(string ConnString, string requestFile, string responseFile)
        {
            FileStream requestFileStream = new FileStream(requestFile, FileMode.Open);       
            FileStream responseFileStream = new FileStream(responseFile, FileMode.Create);            
            StreamUtil u = new StreamUtil();
            u.ProcessClientBigRequest(ConnString, requestFileStream, responseFileStream, true);
        }

        public static void WaitForUnlock(string file)
        {
            FileStream fs = null;
            bool tryAgain = true;
            while (tryAgain)
            {
                try
                {
                    fs = new FileStream(file, FileMode.Open, FileAccess.ReadWrite, FileShare.None);
                    using (fs)
                        fs.Close();
                    tryAgain = false;
                }
                catch (IOException)
                {
                    tryAgain = true;
                    Thread.Sleep(200);
                }

            }
        }


    }
}

O método ExtrairParaArquivo ainda não foi refatorado. A lógica de acesso a dados deveria estar na DAO e a lógica de escrever na Stream na StreamUtil, porém, mantive ele aqui por ser um brinde. Ele será usado no exemplo de extração sem nenhum request. É interessante para observamos a diferença de tempo entre somente extrair a informação e importar (integração entre sistemas) e a abordagem request/response (reuso de comportamento).

O método ImportarArquivo, somente encapsula ImportarStream, com o objetivo de inserir o resultado na base de dados. ProcessRequest pega somente a “quantidade” de registros a ser gerado. ProcessClientRequest será usado pelo servidor para baseado na quantidade obtida por ProcessRequest e extrair um arquivão com os registros. ProcessClientBigRequest é quem recebe registro a registro o que deve ser extraído e gera a resposta.

WaitForUnlock talvez seja a única coisa diferente aqui. Dado um arquivo, ele fica tentando acessá-lo até que o mesmo esteja disponível. É uma bela gambiarra, mas não percebi outra forma de determinar se um arquivo está em lock ou não (em uso por outro processo, outra thread, etc).

Os exemplos dos métodos um a um não são claros. Quando estudarmos a abordagem dos testes de File Transfer, eles farão mais sentido.

File Transfer – Extração Direta

Essa abordagem traz a seguinte idéia:

  • O cliente manda um arquivo para o servidor, este arquivo possui um inteiro dizendo quantos registros ele espera
  • O servidor recebe este arquivo, pega a quantidade de registros e gera um arquivo com todo o conteúdo (os campos da ServiceTable), de acordo com a quanitdade informada
  • O cliente fica ouvindo um diretório esperando o arquivo chegar. Quando o arquivo chega, ele o processa e insere na base do cliente (ClientTable)

O código começaremos com a classe FileTransferRequest:

        public override bool Execute()
        {
            Stopwatch watch = new Stopwatch();
            watch.Start();

            FileSystemWatcher watcher = new FileSystemWatcher(InputDir);
            watcher.Created += new FileSystemEventHandler(watcher_Created);
            watcher.EnableRaisingEvents = true;

            FileStream fs = new FileStream(OutputDir + "\\request.xml", FileMode.Create);
            Log.LogMessage("Generating " + OutputDir + " with " + Records.ToString());
            using (fs)
                fs.Write(BitConverter.GetBytes(Records), 0, 3);

            Log.LogMessage("Waiting for " + InputDir + "\\response.xml");

            while (!finished)
                Thread.Sleep(250);

            watch.Stop(;)

            Log.LogMessage("Total processing time: " + watch.Elapsed.TotalSeconds.ToString("0.00") + " seconds");

            return true;
        }

O método acima é executado pelo MSBuild. O stopwatch é utilizado apra calcular os tempos e um FileSystemWatcher é utilizado para “ouvir” o arquivo. Quando chegar o arquivo de resposta, o método watcher_Created será chamado (pendurado no evento Created) do FileSystemWatcher.

Em seguida, abrimos um arquivo e escrevemos a quantidade de registros esperada (recebido em propriedade do MSBuild). Isso deveria estar na FileUtil, mas fica para um refactor futuro.

Uma vez feito isso, fica em loop dormindo até que a resposta seja recebida. O bool finished é atualizado pelo evento watcher_Created.

Agora vamos seguir o fluxo e pensar como servidor. Esse código está na classe FileTransferTestServer:

        public override bool Execute()       
        {
            FileSystemWatcher watcher = new FileSystemWatcher(InputDir);
            watcher.Created += new FileSystemEventHandler(watcher_Created);
            watcher.EnableRaisingEvents = true;

            while (true)
                Thread.Sleep(250);            
        }                   

O servidor também está numa task MSBuild. Obviamente essa não é uma forma adequada de se escrever um servidor, mas para os nossos propósitos, atende. O servidor somente cria o file watcher e dorme. O resto do trabalho é feito no evento watcher_Created do servidor.

            if (e.Name == "request.xml")
            {
                Log.LogMessage("Received request.xml. Writing response.xml");
                FileUtil.WaitForUnlock(InputDir + "\\request.xml");
                _util.ProcessClientRequest(ConnString, InputDir + "\\tempresponse.xml", _util.ProcessRequest(InputDir + "\\request.xml"));
                Log.LogMessage("Response written");
                if (File.Exists(OutputDir + "\\response.xml"))
                    File.Delete(OutputDir + "\\response.xml");
                File.Copy(InputDir + "\\tempresponse.xml", OutputDir + "\\response.xml");
                Log.LogMessage("Response copied to " + InputDir + "\\response.xml");
            }
            else if (e.Name == "bigrequest.xml")
            {
                Log.LogMessage("Received bigrequest.xml");
                Log.LogMessage("Waiting for file to be unlocked");                
                FileUtil.WaitForUnlock(InputDir + "\\bigrequest.xml");
                Log.LogMessage("bigrequest.xml unlocked.");
                _util.ProcessClientBigRequest(ConnString, InputDir + "\\bigrequest.xml", InputDir + "\\tempresponse.xml");
                Log.LogMessage("Starting copy");
                if (File.Exists(OutputDir + "\\response.xml"))
                    File.Delete(OutputDir + "\\response.xml");
                File.Copy(InputDir + "\\tempresponse.xml", OutputDir + "\\response.xml");
                Log.LogMessage("Finished the copy: source " + InputDir + "\\tempresponse.xml, destination: " + OutputDir + "\\response.xml");
            }

Esse é o código do servidor. Este servidor é usado tanto para o teste utilizando o “request” quanto o “bigrequest”. O nosso caso é o primeiro.

Este trecho executa o WaitForUnlock. Isso é necessário porque o FileWatcher nos avisa que um arquivo pingou no diretório, porém, ele nos avisa no momento em que o arquivo é criado e não quando terminaram de escrever nele (o cliente). Essa sincronização é necessária. O WaitForUnlock fica tentando abrir o arquivo e somente deixa o código do servidor continuar quando o cliente parou e escrever no arquivo.

A partir daí o servidor processa o request, gerando em tempresponse.xml o resultado todo (é uma massa razoável de dados). Em seguida, copia de volta para o diretório Output. Nessa cópia é que ocorrerá o custo mais pesado de rede.

Voltamos para o cliente e o código do seu file watcher que está esperando o response.xml:

        private void watcher_Created(object sender, FileSystemEventArgs e)
        {
            if (e.Name == "response.xml")
            {
                Log.LogMessage("Received " + InputDir + "\\response.xml");
                ((FileSystemWatcher)sender).EnableRaisingEvents = false;

                string file = InputDir + "\\response.xml";

                Log.LogMessage("Waiting for file to be unlocked");
                FileUtil.WaitForUnlock(file);
                Log.LogMessage("File unlocked");                

                util.ImportarArquivo(ConnString, file);

                finished = true;
            }
        }

O cliente faz o mesmo trabalho de sincronização que o servidor, ou seja, espera o arquivo estar acessível. Na seqüência importa o mesmo na base de dados.

File Transfer – Request/Response

Esse teste tem a seguinte idéia:

  • O cliente gera um arquivo bigrequest para o servidor. Este arquivo tem ID por ID o que espera do servidor.
  • O servidor processa o bigrequest.xml e gera um response.xml.
  • O cliente processa o response.xml e joga na usa base de dados

Este teste está implementado na task MSBuild FileTransferBigRequest. O código do cliente começa com:

        public override bool Execute()
        {
            Stopwatch watch = new Stopwatch();
            watch.Start();

            FileSystemWatcher watcher = new FileSystemWatcher(OutputDir);
            watcher.Created += new FileSystemEventHandler(watcher_Created);
            watcher.EnableRaisingEvents = true;

            Log.LogMessage("Writing big request with " + BigRequestSize.ToString() + " items");

            FileStream fs = new FileStream(OutputDir + "\\bigrequest.xml", FileMode.Create);

            StreamUtil util = new StreamUtil();
            util.GenerateBigRequest(fs, true, BigRequestSize);

            while (!finished)
                Thread.Sleep(250);

            watch.Stop();

            Log.LogMessage("Total processing time: " + watch.Elapsed.TotalSeconds.ToString("0.00") + " seconds");

            return true;
        }

O cliente cria o seu file watcher para ouvir a resposta e na seqüência gera o “big request”. O big request é um xml, com todos os ID’s desejados, um a um. É um teste mais próximo da realidade quando falamos em reuso de comportamento.

O servidor é o mesmo do teste anterior, e a lógica é a mesma. Muda somente o método que processa o xml, pegando cada um dos ID’s requisitados e gerando a resposta:

            else if (e.Name == "bigrequest.xml")
            {
                Log.LogMessage("Received bigrequest.xml");
                Log.LogMessage("Waiting for file to be unlocked");                
                FileUtil.WaitForUnlock(InputDir + "\\bigrequest.xml");
                Log.LogMessage("bigrequest.xml unlocked.");
                _util.ProcessClientBigRequest(ConnString, InputDir + "\\bigrequest.xml", InputDir + "\\tempresponse.xml");
                Log.LogMessage("Starting copy");
                if (File.Exists(OutputDir + "\\response.xml"))
                    File.Delete(OutputDir + "\\response.xml");
                File.Copy(InputDir + "\\tempresponse.xml", OutputDir + "\\response.xml");
                Log.LogMessage("Finished the copy: source " + InputDir + "\\tempresponse.xml, destination: " + OutputDir + "\\response.xml");
            }

O cliente novamente é muito parecido com o teste anterior:

        private void watcher_Created(object sender, FileSystemEventArgs e)
        {
            FileUtil util = new FileUtil();

            if (e.Name == "response.xml")
            {
                Log.LogMessage("Received " + InputDir + "\\response.xml");
                ((FileSystemWatcher)sender).EnableRaisingEvents = false;

                string file = InputDir + "\\response.xml";

                Log.LogMessage("Waiting for file to be unlocked");
                FileUtil.WaitForUnlock(file);
                Log.LogMessage("File unlocked");

                util.ImportarArquivo(ConnString, file);

                finished = true;
            }
        }

Conclusão

Observamos que apesar de ter um desempenho bom, essa abordagem gera um problema de sincronização de arquivos. Quando envolvemos somente máquinas, isso não é um processo arriscado e difícil, porém, envolvendo pessoas no processo, podemos ter problemas.

Imagine se no momento em que o arquivo está sendo escrito alguém resolve abrí-lo. O servidor ficará eternamente esperando pelo arquivo. Outros casos como duas rotinas configuradas com o mesmo nome de arquivo também podem acontecer.

Além disso, temos a questão que é difícil chamar o servidor de um “serviço”, porque ele estará sempre acoplado a um cliente. É necessário saber onde devolver o arquivo e o formato precisa ser conhecido por ambos.

Apesar dessa abordagem ser muito utilizada, acredito que deve ser evitada.

Anúncios

Integrações entre Sistemas – Parte 2 – Setup

Objetivo

Nesta parte, veremos como configurar todo o ambiente para executar os testes apresentados na primeira parte.

Configurando banco de dados

Para realizarmos os testes necessários, será necessário primeiro criar um ambiente. O primeiro passo é criar um novo database no SQL Server e as tabelas necessárias:

create table ServiceTable (
  ServiceTableID int not null,
  DescServiceTable varchar(200) not null,
  Value float not null,
  CreationDate datetime not null,
  StringField1 varchar(200),
  StringField2 varchar(200),
  constraint PK_ServiceTable primary key (ServiceTableID) 
)

create table ClientTable (
  ClientTableID int not null,
  DescClientTable varchar(200) not null,
  Value float not null,
  CreationDate datetime not null,
  StringField1 varchar(200),
  StringField2 varchar(200),
  constraint PK_ClientTable primary key (ClientTableID) 
)

Usaremos duas tabelas bem simples, somente para ilustrar o problema. Nosso objetivo não é exercitar conceitos de modelagem de banco de dados.

Esse database e tabelas devem ser criados tanto no cliente quanto no servidor.

Criando uma massa de dados

Para simplificar todo o controle e execução dos programas utilizados nestes exemplos, utilizei o MSBuild, devido à grande quantidade de parâmetros e configurações necessárias.

Para criar a massa de dados, criei uma task MSBuild “DataGeneration”:

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Data.SqlClient;
using System.Data;
using Microsoft.Build.Utilities;
using Microsoft.Build.Framework;

namespace IntegrationTests.TestClasses.Util
{
    public class DataGeneration : Task
    {
        [Required]
        public string ConnString
        {
            get;
            set;
        }

        public override bool Execute()
        {
            SqlConnection conn = new SqlConnection(ConnString);
            conn.Open();

            SqlCommand cmd = new SqlCommand("insert into ServiceTable (ServiceTableID, DescServiceTable, Value, CreationDate, StringField1, StringField2)" +
                "values (@ServiceTableID, @DescServiceTable, @Value, @CreationDate, @StringField1, @StringField2)", conn);

            using (conn)
            {

                SqlParameter p1 = cmd.Parameters.Add("@ServiceTableID", SqlDbType.Int);
                SqlParameter p2 = cmd.Parameters.Add("@DescServiceTable", SqlDbType.VarChar, 200);
                SqlParameter p3 = cmd.Parameters.Add("@Value", SqlDbType.Float);
                SqlParameter p4 = cmd.Parameters.Add("@CreationDate", SqlDbType.DateTime);
                SqlParameter p5 = cmd.Parameters.Add("@StringField1", SqlDbType.VarChar, 200);
                SqlParameter p6 = cmd.Parameters.Add("@StringField2", SqlDbType.VarChar, 200);

                Random r = new Random();

                int count = 0;                
                for (int i = 1; i <= 2000000; i++)
                {
                    p1.Value = i;
                    p2.Value = "Server Value " + i.ToString();
                    p3.Value = r.Next();
                    p4.Value = DateTime.Now;
                    p5.Value = "Useless Field 1: " + r.Next().ToString();
                    p6.Value = "Useless Field 1: " + r.Next().ToString();

                    cmd.ExecuteNonQuery();

                    count++;
                    if (count >= 1000)
                    {
                        count = 0;
                        Log.LogMessage("Generated " + i.ToString() + "/2000000 records");
                    }
                }

                return true;
            }
        }
    }
}

O objetivo aqui é simples, gerar 2 milhões de registros para realizar o teste. Esse procedimento precisa ser executado somente na máquina servidora. O batch “generatedata” executa este procedimento. Não esqueça de atualizar a propriedade “ConnString”. Pode ser tanto via parâmetro no arquivo .bat ou dentro do arquivo IntegrationTests.build.

Caso tenha alguma dúvida sobre MSBuild: MSBuild in a Nutshell.

Configurando o serviço WCF

O serviço precisa ser configurado apenas na máquina servidora. Entre na console de administração do IIS (inetmgr). Dentro do seu Default Web Site, crie um novo aplicativo.

  • Alias: integrationtests
  • Pool de aplicativos: DefaultAppPool (v.4.0, Integrated)
  • Caminho físico: Apontar para a raiz do projeto IntegrationTests.WCFServiceApp
  • Habilitar endpoint net.tcp: Entrar em configurações avançadas da nova aplicação, na propriedade “protocolos habilitados”, preencher com http,net.tcp

As configurações do serviço WCF modificadas. Foram criados os bindings net.tcp e http e a segurança foi desabilitada para evitar problemas entre as duas máquinas. A configuração proposta foi:

  <system.serviceModel>
    <bindings>
      <netTcpBinding> 
        <binding name="nettcp1" closeTimeout="00:10:00" maxReceivedMessageSize="65536000" transferMode="Buffered">
          <security mode="None">
            <transport clientCredentialType="None" protectionLevel="None" />
            <message clientCredentialType="None" />
          </security>
        </binding>
      </netTcpBinding>

    </bindings>
    <services>
      <service name="IntegrationTests.WCFServiceApp.IntegrationTestsService">
        <endpoint binding="netTcpBinding" bindingConfiguration="nettcp1" name="nettcp1"
          contract="IntegrationTests.WCFServiceApp.IIntegrationTestsService" />
        <endpoint binding="basicHttpBinding" bindingConfiguration="" name="basicHTTP"
          contract="IntegrationTests.WCFServiceApp.IIntegrationTestsService" />
      </service>
    </services>
    <behaviors>
      <serviceBehaviors>
        <behavior>
          <!-- To avoid disclosing metadata information, set the value below to false and remove the metadata endpoint above before deployment -->
          <serviceMetadata httpGetEnabled="true"/>
          <!-- To receive exception details in faults for debugging purposes, set the value below to true.  Set to false before deployment to avoid disclosing exception information -->
          <serviceDebug includeExceptionDetailInFaults="false"/>
        </behavior>
      </serviceBehaviors>
    </behaviors>
    <serviceHostingEnvironment multipleSiteBindingsEnabled="true" />
  </system.serviceModel>

Nada impede dessas configurações serem modificadas para atender requisitos do seu ambiente.

Outro ponto importante é corrigir a conexão de banco de dados na web.config do servidor:

<appSettings>
    <add key="ConnString"  value=""/>
 </appSettings>

Propriedades do MSBuild

O script do MSBuild criado funciona tanto para o servidor quanto o cliente, mas algumas propriedades precisam ser modificadas para refletir as configurações do cliente e do servidor:

  • Máquina servidor
    • ConnString: Configurar apontando para a base criada no início do post, na máquina servidora
    • InputDir: Local onde o servidor vai ficar ouvindo os arquivos de requisição enviados pelo cliente. Configurar um diretório temporário local (Ex.: C:\Shared
    • OutputDir: Local onde o servidor vai jogar os arquivos para o cliente. Configurar para um diretório temporário local (Ex.: C:\Shared)
  • Máquina cliente
    • ConnString: Configurar apontando para a base criada no início do post, na máquina cliente
    • OutputDir: Local onde o cliente vai jogar os arquivos de requisição para o servidor. Configurar para um diretório de rede mapeado para o InputDir da máquina servidora.
    • InputDir: Local onde o cliente vai esperar a resposta do servidor. Configurar para um diretório de rede mapeado para o OutputDir da máquina servidora.
    • WebServiceUri: http://server-ip-address/integrationtests/IntegrationTestsService.svc
    • NetTcpUri: net.tcp://server-ip-address/integrationtests/IntegrationTestsService.svc

Executando testes

Antes de executar, sempre execute o bat runservers (internamente target RunServers) na máquina server antes, senão obviamente o cliente vai ficar esperando um arquivo que nunca vai chegar. Implementei o lado servidor também como uma task MSBuild para o caso dos arquivos. É totalmente inapropriada a implementação que eu fiz, mas pra esse propósito (testar os tempos), funciona bem.

A target MSBuild RunClientTests executa os testes (file transfer, web service linha a linha e web service em lotes) num cenário de 20.000 registros. É bom para saber se está tudo funcionando.

A target MSBuild RunAllClientTestes executa todos os testes, 5 vezes, em cenários de 20.000, 50.000, 500.000 e 1 milhão de registros. É óbvio que o RunAllClientTests vai demorar bem mais, porém, o objetivo dele é tirar métricas mais precisas em execuções constantes dos testes para obter resultados médios. Por questões dos caches de banco de dados, consumo memória, etc., as execuções podem variar.

Código fonte

O código fonte está disponível no git hub: https://github.com/ericlemes/IntegrationTests.

Conclusão

Este post tem objetivo somente fornecer o código fonte e explicar todo o setup de ambiente. Nos posts posteriores, vou explicar em mais detalhes cada uma dos testes realizados para que o método fique mais claro.

MSBuild in a nutshell

Objetivo

Recentemente, quando estava escrevendo os artigos sobre integração, estava novamente me matando para organizar console applications, tratar uma série de parâmetros quando lembrei do MSBuild.

Já trabalhei com ele um bom tempo, mas nunca tirei um tempinho pra escrever sobre ele. Agora aproveitei que ia ter que explicar o meu código do outro post, e resolvi escrever.

Pra que serve o MSBuild?

O MSBuild é uma ferramenta de build (genial!). Serve para escrever scripts de build de aplicativos, encadeando uma série de rotinas referentes a apagar arquivos, realizar build, executar testes unitários, empacotar, gerar instalador, carimbar versão e qualquer outra coisa que se queira fazer.

Eu penso sempre em ferramentas de build como um belo canivete suíço para automatizar um monte coisas repetitivas que fazemos no dia-a-dia de desenvolvimento. Gosto muito da prática de escrever scripts de build, pois sempre pensamos: “já tenho uma solution que combina todos os meus projetos, não preciso disso”, mas na prática nem sempre as aplicações estão escritas numa única tecnologia, ou ainda, nem sempre estão em uma única solution, ou não depende somente de compilação (aqui está o grande erro).

Quando utilizamos uma ferramenta de build e criamos um script para realizar tudo aquilo que garante a qualidade da nossa aplicação associada a um servidor de integração contínua (Ex.: Cruise Control.NET), começamos a descobrir uma série de coisas sobre nosso código e nosso time.

Todas as vezes que implementei essas práticas pelas empresas que passei, descobri que nem todo mundo tinha o mesmo cuidado ao realizar a subida dos fontes para o controle de versão, ou ainda, nem todos executavam os testes unitários antes de subir os fontes. A dupla dinâmica ferramenta de build + servidor de integração contínua ajudam muito a criar essa cultura na equipe.

Nesse post, não vou falar de integração contínua, apenas uma rápida introdução sobre MSBuild que ajuda a dar um start nessa prática.

MSBuild na prática

O MSBuild é uma console application que vem distribuída junto da framework. Sozinha não faz absolutamente nada. No framework 4.0, ela fica no caminho: C:\Windows\Microsoft.NET\Framework\v4.0.30319\MSBuild.exe

A referência completa dos parâmetros de linha de comando do MSBuild, você encontra no MSDN (MSBuild Command Line Reference). Não vou passar um a um aqui. Apenas os principais:

msbuild [ProjectFile] /t:[Targets] /l:[Logger] /p:[Name=Value] 
  • Project File é o xml na estrutura do MSBuild que possui todas a instrução de build
  • Targets é a lista de “Targets” a serem executados. Um target é um conjunto de atividades (tasks). Mais abaixo veremos como definí-los.
  • Logger é a classe utilizada para tratar o log do MSBuild. Veremos algumas opções também.
  • O /p permite a passagem de propriedades, ou seja, pares de nome e valor.

Para demonstrar a estrutura de um arquivo do MSBuild, vamos criar um arquivo MSBuildTests.build. Você pode usar o seu editor de textos favorito para editar o arquivo, geralmente pintando com sintaxe xml para facilitar a vida. Vamos criar com a seguinte estrutura:

<?xml version="1.0" encoding="utf-8"?>
<Project ToolsVersion="4.0" DefaultTargets="Build" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">	
	<Target Name="SaySomething">
		<Message Text="Hello world!" />
	</Target>
</Project>

O xml acima exemplifica a estrutura padrão do MSBuild. A tag “Target” define um target com uma única task Message que indica um texto a ser devolvido no log. Para executarmos isso, vamos criar no mesmo diretório um arquivo runmsbuild.bat, com o seguinte conteúdo:

C:\Windows\Microsoft.NET\Framework\v4.0.30319\MSBuild.exe MSBuildTests.build /t:SaySomething
pause

O batch acima executa o msbuild, referenciando o projeto MSBuildTests.build e o /t: indica que o target a ser executado é o “SaySomething”. Executando o batch, você deve receber o resultado:

C:\Exercicios\MSBuild\build&gt;C:\Windows\Microsoft.NET\Framework\v4.0.30319\MSBuild.exe MSBuildTests.build /t:SaySomething
Microsoft (R) Build Engine Version 4.0.30319.1
[Microsoft .NET Framework, Version 4.0.30319.261]
Copyright (C) Microsoft Corporation 2007. All rights reserved.

Build started 22/04/2012 19:08:21.
Project "C:\Exercicios\MSBuild\build\MSBuildTests.build" on node 1 (SaySomethin
g target(s)).
SaySomething:
  Hello world!
Done Building Project "C:\Exercicios\MSBuild\build\MSBuildTests.build" (SaySome
thing target(s)).


Build succeeded.
    0 Warning(s)
    0 Error(s)

Time Elapsed 00:00:00.03

C:\Exercicios\MSBuild\build&gt;pause
Pressione qualquer tecla para continuar. . .

Isso significa que tudo funcionou. Agora basta montar sua seqüencia de ações para montar o seu build.

Propriedades

Para simplificar a vida em relação a parâmetros, o MSBuild nos permite definir propriedades. Vejamos o exemplo:

<?xml version="1.0" encoding="utf-8"?>
<Project ToolsVersion="4.0" DefaultTargets="Build" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">	
	<PropertyGroup>
		<TextToSay>This is a property</TextToSay>	
	</PropertyGroup>

	<Target Name="SaySomething">
		<Message Text="$(TextToSay)" />
	</Target>
</Project>

Ao executarmos, veremos que $(TextToSay) é substituído pelo valor da propriedade definido no começo do arquivo. Também é possível sobrescrever o valor, passando parâmetros pelo arquivo batch:

C:\Windows\Microsoft.NET\Framework\v4.0.30319\MSBuild.exe MSBuildTests.build /t:SaySomething /p:TextToSay="Novo valor"
pause

Realizando o build de uma solução

Para fazermos algo mais útil com o MSBuild, podemos utilizá-lo num processo padrão de build de uma solution do visual studio. Segue o exemplo:

<?xml version="1.0" encoding="utf-8"?>
<Project ToolsVersion="4.0" DefaultTargets="Build" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">	
	<PropertyGroup>
		<TextToSay>This is a property</TextToSay>	
	</PropertyGroup>
	
	<ItemGroup>
		<Binaries Include="$(MSBuildProjectDirectory)\..\MSBuildTests\**\*.dll" />
	</ItemGroup>

	<Target Name="SaySomething">
		<Message Text="$(TextToSay)" />
	</Target>
	
	<Target Name="Build">
		<Delete Files="@(Binaries)" />
		<MSBuild Projects="$(MSBuildProjectDirectory)\..\MSBuildTests\MSBuildTests.sln" />
	</Target>
</Project>

Aqui tem vários detalhes interessantes. O primeiro é que qualquer solution do Visual Studio é automaticamente reconhecida pelo MSBuild, ou seja, simplesmente executando a Task “MSBuild” numa solution, ela compila. É possível também passar parâmetros, definindo a configuração (Release, Debug) ou outras coisas que a solution permita.

O segundo detalhe interessante é que existem algumas propriedades “padrões” do MSBuild como a $(MSBuildProjectDirectory). No exemplo acima, para encontrar a solution eu uso um path relativo para ela, de forma que independente de onde estejam os arquivos do meu projeto, eles serão encontrados pelo build.

O terceiro detalhe é que eu usei o nome “Build” para a task, que é o mesmo que está no “DefaultTargets” na tag Project. Apenas removendo o /t:SaySomething do arquivo .bat, ela é automaticamente executada, porque foi especificada como default.

Agora um detalhe que sempre gera confusão é o ItemGroup. Neste exemplo, criei um item chamado “Binaries” e a máscara fornecida acima pega qualquer arquivo com extensão .dll em qualquer subdiretório abaixo de MSBuildTests. A idéia é apagar todos os binários antes de compilar (usei apenas como exemplo). Ao chamar a task Delete, todo o conjunto de arquivos encontrados que bate com aquela máscara será excluído.

Outro detalhe importante sobre o “ItemGroup” é que eles são avaliados no momento que começa a execução do projeto. Se você gera arquivos durante o build e quer considerá-los, use a task “CreateItem” para montar os grupos de arquivos.

Criando tasks customizadas

Existe uma série de tasks pré-definidas e sua referência completa pode ser encontrada no link: MSBuild Task Reference. Mesmo assim, é possível construir suas próprias para que você possa fazer suas maluquices.

Para construir uma task personalizada, basta criar uma class library (no meu exemplo, MSBuildTests.Tasks) e criar uma nova classe. É importante criar uma referência para os assemblies Microsoft.Build.Utilities.v4.0 e Microsoft.Build.Framework.

Segue o exemplo de código de uma task:

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using Microsoft.Build.Utilities;
using Microsoft.Build.Framework;
using System.IO;

namespace MSBuildTests.Tasks
{
    public class GenerateDumbFiles : Task
    {
        [Required]
        public string Directory
        {
            get;
            set;
        }

        [Required]
        public string Prefix
        {
            get;
            set;
        }

        [Required]
        public int Count
        {
            get;
            set;
        }

        public override bool Execute()
        {
            for (int i = 0; i < Count; i++)
            {
                FileStream fs = new FileStream(Directory + "\\" + Prefix + (i+1).ToString() + ".txt", FileMode.Create);
                using (fs)
                {
                    byte[] content = Encoding.UTF8.GetBytes("I'm a dumb file");
                    fs.Write(content, 0, content.Length);
                }
            }
            return true;
        }
    }
}

No exemplo acima, dado um prefixo e uma quantidade de arquivos, a task gera arquivos idiotas. Segue exemplo de como usá-la:

<?xml version="1.0" encoding="utf-8"?>
<Project ToolsVersion="4.0" DefaultTargets="Build" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">	
	<PropertyGroup>
		<TextToSay>This is a property</TextToSay>	
	</PropertyGroup>
	
	<ItemGroup>
		<Binaries Include="$(MSBuildProjectDirectory)\..\MSBuildTests\**\*.dll" />
	</ItemGroup>
	
	<UsingTask TaskName="GenerateDumbFiles" AssemblyFile="$(MSBuildProjectDirectory)\..\MSBuildTests\MSBuildTests.Tasks\bin\Debug\MSBuildTests.Tasks.dll" />

	<Target Name="SaySomething">
		<Message Text="$(TextToSay)" />
	</Target>
	
	<Target Name="Build">
		<Delete Files="@(Binaries)" />
		<MSBuild Projects="$(MSBuildProjectDirectory)\..\MSBuildTests\MSBuildTests.sln" />
		<MakeDir Directories="$(MSBuildProjectDirectory)\..\dumbfiles" />
		<GenerateDumbFiles Directory="$(MSBuildProjectDirectory)\..\dumbfiles" Prefix="DumbFile" Count="100" />
	</Target>
</Project>

O que inserimos de novidade aqui foi a criação do diretório dumbfiles e a tag UsingTask. Esta tag é quem diz para o MSBuild o nome da Task que estamos procurando e qual assembly ele deve carregar. Notem que o assembly é compilado e carregado em tempo de build. A task que está sendo usada dentro do build está sendo compilada em tempo de build.

A tag GenerateDumbFiles simplesmente executa o código da task que criamos, respeitando os parâmetros “Required”. Se não especificarmos nenhum, dá erro na execução da task, assim como se o assembly não for localizado, ou internamente dentro do assembly o nome da classe não bater com o TaskName especificado.

Logger

O MSBuild permite também que você “espete” classes de log específicas. É comum que com o tempo os builds fiquem bastante grandes e pra entender o que aconteceu dá um certo trabalho. Debuggar com as cores é bem mais legal e o logger default não suporta.

Aí você procura na internet e acha um cara legal que fez um logger em xml, como este: http://geekswithblogs.net/kobush/archive/2006/01/14/xmllogger.aspx. Você baixa o fonte dele, compila e pega a classe de logger.

No batch de execução do MSBuild, você coloca o seguinte parâmetro:

C:\Windows\Microsoft.NET\Framework\v4.0.30319\MSBuild.exe MSBuildTests.build /p:TextToSay=”Novo valor” /l:XmlLogger,Kobush.Build.dll;msbuild-output.xml
pause

Isso indica que ele carregará a classe do Kobush de log, e o output será no arquivo msbuild-output.xml. Se executar, você verá o resultado. Ainda assim não estando satisfeito, você pode brincar mais ainda com o MSBuild, realizando uma transformação de xml para um formato mais legal.

Agora adicionamos também no nosso xml, um target:

<Target Name="TransformLog">
		<XslTransformation XmlInputPaths="$(MSBuildProjectDirectory)\msbuild-output.xml" XslInputPath="$(MSBuildProjectDirectory)\msbuild.xsl" OutputPaths="$(MSBuildProjectDirectory)\log.html" />
	</Target>

E alteramos nosso .bat para também executar este target:

C:\Windows\Microsoft.NET\Framework\v4.0.30319\MSBuild.exe MSBuildTests.build /p:TextToSay="Novo valor" /l:XmlLogger,Kobush.Build.dll;msbuild-output.xml
C:\Windows\Microsoft.NET\Framework\v4.0.30319\MSBuild.exe MSBuildTests.build /t:TransformLog
pause

O resultado final é um arquivo “log.html” com uma transformação do log num formato “bonitinho” para debug.

É claro que é possível também implementar seus próprios loggers. Mas não pretendo abordar isso neste post.

Código fonte

O código fonte está disponível no git hub: https://github.com/ericlemes/MSBuildTests

Conclusão

O MSBuild é uma poderosa ferramenta para nos ajudar a orquestrar diversas tarefas de automação de build. Toda a complicação de encontrar caminhos relativos, administrar parâmetros de execuções de pequenos aplicativos ou console applications é praticamente eliminado usando o MSBuild.

A própria prática de ter um “roteiro” para empacotar uma aplicação já adiciona muito valor ao processo de desenvolvimento, pois permite que cada rotina, biblioteca ou atividades realizadas para construir a aplicação esteja documentada e seja mantida junto com o build.