Thread No CSharp

Nesse artigo vou mostrar como podemos utilizar Threads no c#, vamos criar um sistema de backup de arquivos simples, a figura abaixo mostra como vai funcionar.

Nesse artigo vou mostrar como podemos utilizar Threads no c#, vamos criar um sistema de backup de arquivos simples, a figura abaixo mostra como vai funcionar.

Figura 1. Sistema de Backup

Criei essa classe com static para ser acessada sem precisar instaciar a classe, nela existe uma propriedade que tem o caminho do programa winrar que vai ser utilizado para compactar os arquivos.

using System; using System.Collections.Generic; using System.Linq; using System.Text; namespace Thread1 { public static class AppConfig { /// <summary> /// Caminho do executavel do programa WinRAR. /// </summary> public static string PathWinRAR { get { return @"C:\Arquivos de programas\WinRAR\WinRAR.exe"; } } } }
Listagem 1. Criar a classe AppConfig

Classe MessageProcess é utilizada para transferir as mensagens de informação, aviso, início de processo e o fim do processo para a interface do usuário. O enumerador TypeMessage é utilizado para indicar que tipo de mensagem está sendo passada no objeto da classe MessageProcess.

using System; using System.Collections.Generic; using System.Linq; using System.Text; namespace Thread1 { public enum TypeMessage { Information = 1, Warning = 2, BeginProcess = 3, CurrentProcess = 4, } public class MessageProcess { public string Message { get; set; } public TypeMessage Type { get; set; } public object Value { get; set; } public MessageProcess(string message, TypeMessage type) { this.Message = message; this.Type = type; } public MessageProcess(object value, TypeMessage type) { this.Type = type; this.Value = value; } } }
Listagem 2. Criar a classe MessageProcess e o enumerador TypeMessage

Classe que tem a função de pesquisar os diretórios e coletar os arquivos para o backup, compactando os lotes de arquivos e movendo para os lotes para o destino informado pelo usuário.

using System; using System.Collections.Generic; using System.Linq; using System.Text; using System.IO; using System.Threading; using System.Diagnostics; namespace Thread1 { public class BackupFiles { /// <summary> /// Construtor /// </summary> /// <param name="packageLength">Tamanho do pacote.</param> /// <param name="pathSource">Caminho da pasta fonte.</param> /// <param name="pathDestiny">Caminho da pasta de destino.</param> public BackupFiles(long packageLength, string pathSource, string pathDestiny) { this.packageLength = packageLength; this.pathSource = pathSource; this.pathDestiny = pathDestiny; } } }
Listagem 3. Criar a classe BackupFiles

Criei delegates e eventos para informar o progresso de backup, a classe BackupFiles dispara eventos conforme o progresso do processo.

/// <summary> /// Erro do processo /// </summary> /// <param name="oException">Objeto da classe Exception</param> public delegate void ErrorHandle(Exception oException); /// <summary> /// Mensagem de informação. /// </summary> /// <param name="message">Menssagem</param> public delegate void MessageHandle(string message); /// <summary> /// Inicio de processo /// </summary> /// <param name="totalPackage">Total de pacotes.</param> public delegate void BeginProcessHandle(long totalPackage); /// <summary> /// Pacote atual. /// </summary> /// <param name="currentPackage">Pacote atual</param> public delegate void CurrentProcessHandle(long currentPackage); /// <summary> /// Fim do processo. /// </summary> public delegate void EndProcessHandle(); /// <summary> /// Foi disparado algum erro no processo. /// </summary> public event ErrorHandle OnError; /// <summary> /// Foi disparado alguma mensagem de informação do processo. /// </summary> public event MessageHandle OnInformation; /// <summary> /// Foi disparado alguma mensagem de aviso do processo. /// </summary> public event MessageHandle OnWarning; /// <summary> /// Foi disparado o começo do processo. /// </summary> public event BeginProcessHandle OnBeginProcess; /// <summary> /// Dispara o pacote atual. /// </summary> public event CurrentProcessHandle OnCurrentProcess; /// <summary> /// Fim do processo /// </summary> public event EndProcessHandle OnEndProcess;
Listagem 4. Delegates e Eventos

São as variáveis que o sistema utiliza para fazer o backup de arquivos, os objetos Queue são classes de fila, o arquivo que entra primeiro é o primeiro que sai da fila.

private Queue<MessageProcess> messageQueue;//Objeto de fila de mensagens. private Queue<FileInfo> fileInfoQueue;//Objeto de fila de dados dos arquivos. private Queue<string> packageInfoQueue;//Objeto de fila de pacotes. O usuário passa o tamanho que a pasta tem que ter. private Queue<string> packageZipInfoQueue;//Objeto de fila de pastas zipadas. private long packageLength;//Tamanho do pacote. private string pathSource;//Caminho da pasta fonte. private string pathDestiny;//Caminho da pasta de destino. private string pathApplication;//Caminho da pasta da aplicação. private bool isEndCollectFiles;//Indica se o processo de coleta de arquivos acabou. private bool isEndMoveFiles;//Indica se o processo de mover arquivos acabou. private bool isEndCompressionDirectory;//Indica se o processo de zipar os arquivos acabou. private bool isEndMovePackageDestiny;//Indica se o processo de mover os arquivos zipados para a pasta de destino acabou. private bool isExecute;//Indica se o processo de backup está executando. private long valueCurrentPackage;//Tamanho da pasta de arquivos. private Thread collectFilesThread;//Processo de coleta de arquivos private Thread moveFilesThread;//Proceso de mover arquivos. private Thread compressionDirectoryThread;//Processo de comprimir arquivos. private Thread movePackageDestinyThread;//Processo de mover a pasta zipada para a pasta de caminho. private Thread monitoringProcessThread;//Processo de monitoração de processos.
Listagem 5. Criar as variáveis da classe

No método Start é onde vamos iniciar os processos de coletar e mover arquivos, zipar e mover as pastas dos arquivos.

public void Start() { try { this.isExecute = true; this.valueCurrentPackage = 0; messageQueue = new Queue<MessageProcess>(); fileInfoQueue = new Queue<FileInfo>(); packageInfoQueue = new Queue<string>(); packageZipInfoQueue = new Queue<string>(); this.pathApplication = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "BackupFiles"); if (Directory.Exists(this.pathApplication)) { Directory.Delete(this.pathApplication, true); } Directory.CreateDirectory(this.pathApplication); this.isEndCollectFiles = false; this.isEndMoveFiles = false; this.isEndCompressionDirectory = false; this.isEndMovePackageDestiny = false; messageQueue.Enqueue(new MessageProcess("Iniciando processo de coleta de arquivos!", TypeMessage.Information)); collectFilesThread = new Thread(new ThreadStart(collectFiles));//Criamos a Thread e o método alvo. collectFilesThread.IsBackground = true;//Aqui indicamos que se o Processo principal for finalizado essa thread é finalizada. collectFilesThread.Start(); messageQueue.Enqueue(new MessageProcess("Iniciando processo de copiar arquivos!", TypeMessage.Information)); moveFilesThread = new Thread(new ThreadStart(moveFiles));//Criamos a Thread e o método alvo. moveFilesThread.IsBackground = true; //Aqui indicamos que se o Processo principal for finalizado essa thread é finalizada. moveFilesThread.Start();//Inicia o processo. messageQueue.Enqueue(new MessageProcess("Iniciando processo de zipar arquivos!", TypeMessage.Information)); compressionDirectoryThread = new Thread(new ThreadStart(compressionDirectory)); //Criamos a Thread e o método alvo. compressionDirectoryThread.IsBackground = true; //Aqui indicamos que se o Processo principal for finalizado essa thread é finalizada. compressionDirectoryThread.Start();//Inicia o processo. messageQueue.Enqueue(new MessageProcess("Iniciando processo de mover arquivos para midia de backup!", TypeMessage.Information)); movePackageDestinyThread = new Thread(new ThreadStart(movePackageDestiny)); //Criamos a Thread e o método alvo. movePackageDestinyThread.IsBackground = true; //Aqui indicamos que se o Processo principal for finalizado essa thread é finalizada. movePackageDestinyThread.Start();//Inicia o processo. monitoringProcessThread = new Thread(new ThreadStart(monitoringProcess)); //Criamos a Thread e o método alvo. monitoringProcessThread.IsBackground = true; //Aqui indicamos que se o Processo principal for finalizado essa thread é finalizada. monitoringProcessThread.Start();//Inicia o processo. } catch (Exception oException) { shootEventError(oException);//Disparamos o evento de error. } } public void Stop() { try { this.isExecute = false; if (collectFilesThread != null) { collectFilesThread.Abort();//Aborta a Thread. collectFilesThread = null; } if (moveFilesThread != null) { moveFilesThread.Abort();//Aborta a Thread. moveFilesThread = null; } if (compressionDirectoryThread != null) { compressionDirectoryThread.Abort();//Aborta a Thread. compressionDirectoryThread = null; } if (movePackageDestinyThread != null) { movePackageDestinyThread.Abort();//Aborta a Thread. movePackageDestinyThread = null; } if (monitoringProcessThread != null) { monitoringProcessThread.Abort();//Aborta a Thread. monitoringProcessThread = null; } } catch (Exception oException) { shootEventError(oException);//Disparamos o evento de error. } }
Listagem 6. Criar os métodos Start e Stop

Essa a primeira tarefa, calcula o total de arquivos e coleta as informações dos arquivos do diretório informado pelo usuário, cada informação coletada é colocada na fila para ser processada pela próxima tarefa [moveFiles()].

collectFiles = Coletando as informações dos arquivos.

private void collectFiles() { try { long totalPackage = calculateTotalPackage(this.pathSource);//Calcula total de pasta zipadas. messageQueue.Enqueue(new MessageProcess(totalPackage, TypeMessage.BeginProcess)); getDirectories(this.pathSource); this.isEndCollectFiles = true; messageQueue.Enqueue(new MessageProcess("Fim do processo de coleta de arquivos!", TypeMessage.Information)); } catch (Exception oException) { if (this.isExecute) { shootEventError(oException); this.Stop(); } } } // Coletando os diretórios da pasta fonte. private void getDirectories(string path) { List<string> directoryCollection = new List<string>(Directory.GetDirectories(path)); getFiles(path); for (int index = 0; index < directoryCollection.Count; index++) { string subDiretorio = directoryCollection[index]; directoryCollection.AddRange(Directory.GetDirectories(subDiretorio)); getFiles(subDiretorio); } } //Coletando os arquivos. private void getFiles(string path) { string[] arquivosArray = Directory.GetFiles(path); for (int index = 0; index < arquivosArray.Length; index++) { FileInfo oFileInfo = new FileInfo(arquivosArray[index]); object lockObject = new object(); lock (lockObject) { fileInfoQueue.Enqueue(oFileInfo); } } } //Calculando as pastas zipadas que serão geradas. private int calculateTotalPackage(string path) { long currentPackageLength = 0; int numberPackage = 0; string[] arrayIniFiles = Directory.GetFiles(path); for (int iFile = 0; iFile < arrayIniFiles.Length; iFile++) { FileInfo oFileInfo = new FileInfo(arrayIniFiles[iFile]); if (currentPackageLength + oFileInfo.Length > this.packageLength) { currentPackageLength = 0; numberPackage++; } currentPackageLength += oFileInfo.Length; } List<string> directoryCollection = new List<string>(Directory.GetDirectories(path)); for (int index = 0; index < directoryCollection.Count; index++) { string subDiretorio = directoryCollection[index]; directoryCollection.AddRange(Directory.GetDirectories(subDiretorio)); string[] arrayFiles = Directory.GetFiles(subDiretorio); for (int iFile = 0; iFile < arrayFiles.Length; iFile++) { FileInfo oFileInfo = new FileInfo(arrayFiles[iFile]); if (currentPackageLength + oFileInfo.Length > this.packageLength) { currentPackageLength = 0; numberPackage++; } currentPackageLength += oFileInfo.Length; } } if (currentPackageLength > 0) { numberPackage++; } return numberPackage; }
Listagem 7. Calculando total de arquivos e coletando as informações dos arquivos

Essa é a segunda tarefa, move os arquivos da pasta fonte para a pasta temporária. Esse método soma o tamanho dos arquivos e verifica o tamanho da pasta que o usuário digitou, caso o tamanho ultrapassa ou é igual ele cria outra pasta, coloca na fila o nome da pasta para ser processada pela próxima tarefa [compressionDirectory()].

private void moveFiles() { try { long currentPackageLength = 0; long numberPackage = 1; string pathPackage = Path.Combine(this.pathApplication, numberPackage.ToString()); Directory.CreateDirectory(pathPackage); while (isEndCollectFiles == false || fileInfoQueue.Count > 0) { if (fileInfoQueue.Count > 0) { FileInfo oFileInfo = null; object lockObject = new object(); lock (lockObject) { oFileInfo = fileInfoQueue.Dequeue(); } if (oFileInfo.Length > this.packageLength) { messageQueue.Enqueue(new MessageProcess(String.Format("O arquivo é maior que o tamanho do lote de backup[]!", oFileInfo.Name, oFileInfo.Length), TypeMessage.Warning)); continue; } if (currentPackageLength + oFileInfo.Length > this.packageLength) { currentPackageLength = 0; numberPackage++; lockObject = new object(); lock (lockObject) { packageInfoQueue.Enqueue(pathPackage); } pathPackage = Path.Combine(this.pathApplication, numberPackage.ToString()); Directory.CreateDirectory(pathPackage); } currentPackageLength += oFileInfo.Length; string pathDest = oFileInfo.FullName.Replace(oFileInfo.Name, "") .Replace(this.pathSource, pathPackage); if (!Directory.Exists(pathDest)) { Directory.CreateDirectory(pathDest); } File.Copy(oFileInfo.FullName, Path.Combine(pathDest, oFileInfo.Name)); } Thread.Sleep(100); } if (currentPackageLength > 0) { object lockObject = new object(); lock (lockObject) { packageInfoQueue.Enqueue(pathPackage); } } this.isEndMoveFiles = true; messageQueue.Enqueue(new MessageProcess("Fim do processo de copiar arquivos!", TypeMessage.Information)); } catch (Exception oException) { if (this.isExecute) { shootEventError(oException); this.Stop(); } } }
Listagem 8. Movendo os arquivos da pasta fonte para a pasta temporária

Essa é a terceira tarefa, compacta as pastas que foram criadas pela tarefa de mover arquivos, depois de compactar a pasta é colocada na fila para ser processada pela próxima tarefa [movePackageDestiny()].

private void compressionDirectory() { try { long numberPackage = 1; while (isEndMoveFiles == false || packageInfoQueue.Count > 0) { if (packageInfoQueue.Count > 0) { string pathPackage = ""; string pathPackageZip = ""; object lockObject = new object(); lock (lockObject) { pathPackage = packageInfoQueue.Dequeue(); } pathPackageZip = String.Format(".rar", Path.Combine(this.pathApplication, numberPackage.ToString())); ProcessStartInfo oProcessStartInfo = new ProcessStartInfo(AppConfig.PathWinRAR, String.Format("a -ep1 -r \"\" \"\"", pathPackageZip, pathPackage)); Process oProcess = Process.Start(oProcessStartInfo); oProcess.WaitForExit(); lockObject = new object(); lock (lockObject) { packageZipInfoQueue.Enqueue(pathPackageZip); } Directory.Delete(pathPackage, true); numberPackage++; } Thread.Sleep(100); } this.isEndCompressionDirectory = true; messageQueue.Enqueue(new MessageProcess("Fim do processo de zipar arquivos!", TypeMessage.Information)); } catch (Exception oException) { if (this.isExecute) { shootEventError(oException); this.Stop(); } } }
Listagem 9. Compacta a pasta criadas pela a tarefa anterior [moveFiles()]

Essa é a quarta tarefa, move as pastas compactadas para o pasta de destino, a pasta de destino é digitado pelo usuário na interface.

private void movePackageDestiny() { try { while (isEndCompressionDirectory == false || packageZipInfoQueue.Count > 0) { if (packageZipInfoQueue.Count > 0) { string sourcePathPackageZip = ""; object lockObject = new object(); lock (lockObject) { sourcePathPackageZip = packageZipInfoQueue.Dequeue(); } FileInfo oFileInfo = new FileInfo(sourcePathPackageZip); File.Move(sourcePathPackageZip, Path.Combine(this.pathDestiny, oFileInfo.Name)); this.valueCurrentPackage++; messageQueue.Enqueue(new MessageProcess(this.valueCurrentPackage, TypeMessage.CurrentProcess)); } Thread.Sleep(100); } this.isEndMovePackageDestiny = true; messageQueue.Enqueue(new MessageProcess("Fim do processo de mover arquivos para midia de backup!", TypeMessage.Information)); } catch (Exception oException) { if (this.isExecute) { shootEventError(oException); this.Stop(); } } }
Listagem 10. Movendo as pastas compactadas para o local de destino

Essa é a quinta tarefa, verifica se as tarefas anteriores terminaram e coleta as mensagens disparadas pelas tarefas, depois de coletadas as informações, a tarefa de monitoração dispara eventos para interface do usuário, informano progresso do processo.

public void monitoringProcess() { try { while (this.isEndCollectFiles == false || this.isEndMoveFiles == false || this.isEndCompressionDirectory == false || this.isEndMovePackageDestiny == false || messageQueue.Count > 0) { if (messageQueue.Count > 0) { MessageProcess oMessageProcess = null; object lockObject = new object(); lock (lockObject) { oMessageProcess = messageQueue.Dequeue(); } if (oMessageProcess.Type == TypeMessage.Information) { shootEventInformation(oMessageProcess.Message); } else if (oMessageProcess.Type == TypeMessage.Information) { shootEventWarning(oMessageProcess.Message); } else if (oMessageProcess.Type == TypeMessage.BeginProcess) { shootEventBeginProcess((long)oMessageProcess.Value); } else if (oMessageProcess.Type == TypeMessage.CurrentProcess) { shootEventCurrentProcess((long)oMessageProcess.Value); } } Thread.Sleep(200); } if (Directory.Exists(this.pathApplication)) { Directory.Delete(this.pathApplication, true); } shootEventEndProcess(); } catch (Exception oException) { if (this.isExecute) { shootEventError(oException); this.Stop(); } } }
Listagem 11. Monitorar os processos

Os métodos para disparar eventos para a interface do usuário, antes de disparar evento a aplicação verifica se o evento foi implementado pela a interface do usuário, caso não seja implentado o evento é null.

private void shootEventError(Exception oException) { if (OnError != null) { OnError(oException); } } private void shootEventWarning(string message) { if (OnWarning != null) { OnWarning(message); } } private void shootEventInformation(string message) { if (OnInformation != null) { OnInformation(message); } } private void shootEventBeginProcess(long totalPackage) { if (OnBeginProcess != null) { OnBeginProcess(totalPackage); } } private void shootEventCurrentProcess(long currentPackage) { if (OnCurrentProcess != null) { OnCurrentProcess(currentPackage); } } private void shootEventEndProcess() { if (OnEndProcess != null) { OnEndProcess(); } }
Listagem 12. Disparando eventos do processo

Interface do usuário para interagir com o processos de backup.

Figura 2. Tela de backup

Para atualizar o formulário teremos que verificar se outra thread não está acessando o mesmo componente. Esse é um problema de criar sistemas multitarefas. Para verificar se outra thread está acessando o componente utilizamos a propriedade InvokeRequired, caso essa propriedade true esse método é invocado novamente, utilizamos o método .Invoke().

using System; using System.Collections.Generic; using System.ComponentModel; using System.Data; using System.Drawing; using System.Linq; using System.Text; using System.Windows.Forms; namespace Thread1 { public partial class MainForm : Form { private delegate void EnabledDisabledHandle(bool isBegin); private event BackupFiles.BeginProcessHandle OnBeginProcess; private event BackupFiles.CurrentProcessHandle OnCurrentProcess; private event BackupFiles.ErrorHandle OnError; private event BackupFiles.MessageHandle OnInformation; private event BackupFiles.MessageHandle OnWarnig; private event EnabledDisabledHandle OnEnabledDisabled; private BackupFiles oBackupFiles; private int totalPackageBackup; public MainForm() { InitializeComponent(); OnBeginProcess += new BackupFiles.BeginProcessHandle(oBackupFiles_OnBeginProcess); OnCurrentProcess += new BackupFiles.CurrentProcessHandle(oBackupFiles_OnCurrentProcess); OnError += new BackupFiles.ErrorHandle(oBackupFiles_OnError); OnInformation += new BackupFiles.MessageHandle(oBackupFiles_OnInformation); OnWarnig += new BackupFiles.MessageHandle(oBackupFiles_OnWarning); OnEnabledDisabled += new EnabledDisabledHandle(enabledDisabled); } private void MainForm_Load(object sender, EventArgs e) { enabledDisabled(false); } void oBackupFiles_OnEndProcess() { oBackupFiles_OnCurrentProcess(this.totalPackageBackup); oBackupFiles_OnInformation("Fim do processo de backup!"); enabledDisabled(false); } void oBackupFiles_OnError(Exception oException) { if (!errorListBox.InvokeRequired) { errorListBox.Items.Add(oException.Message); errorListBox.SelectedIndex = errorListBox.Items.Count - 1; } else { errorListBox.Invoke(OnError, new object[1] { oException }); } } void oBackupFiles_OnInformation(string message) { if (!informationListBox.InvokeRequired) { informationListBox.Items.Add(message); informationListBox.SelectedIndex = errorListBox.Items.Count - 1; } else { informationListBox.Invoke(OnInformation, new object[1] { message }); } } void oBackupFiles_OnWarning(string message) { if (!warningListBox.InvokeRequired) { warningListBox.Items.Add(message); warningListBox.SelectedIndex = errorListBox.Items.Count - 1; } else { warningListBox.Invoke(OnWarnig, new object[1] { message }); } } void oBackupFiles_OnCurrentProcess(long currentPackage) { if (!progressBar1.InvokeRequired) { if (progressBar1.Maximum >= currentPackage) { progressBar1.Value = (int)currentPackage; } } else { progressBar1.Invoke(OnCurrentProcess, new object[1] { currentPackage }); } } void oBackupFiles_OnBeginProcess(long totalPackage) { if (!progressBar1.InvokeRequired) { progressBar1.Value = 0; progressBar1.Minimum = 0; progressBar1.Maximum = (int)totalPackage; this.totalPackageBackup = (int)totalPackage; } else { progressBar1.Invoke(OnBeginProcess, new object[1] { totalPackage }); } } private void startButton_Click(object sender, EventArgs e) { progressBar1.Value = 0; long packageLength = ((long)packageLengthNumericUpDown.Value * 1024) * 1024; oBackupFiles = new BackupFiles(packageLength, pathSourceTextBox.Text, pathDestinyTextBox.Text); oBackupFiles.OnError += new BackupFiles.ErrorHandle(oBackupFiles_OnError); oBackupFiles.OnEndProcess += new BackupFiles.EndProcessHandle(oBackupFiles_OnEndProcess); oBackupFiles.OnBeginProcess += new BackupFiles.BeginProcessHandle( oBackupFiles_OnBeginProcess); oBackupFiles.OnCurrentProcess += new BackupFiles.CurrentProcessHandle( oBackupFiles_OnCurrentProcess); oBackupFiles.OnWarning += new BackupFiles.MessageHandle(oBackupFiles_OnWarning); oBackupFiles.OnInformation += new BackupFiles.MessageHandle(oBackupFiles_OnInformation); enabledDisabled(true); oBackupFiles.Start(); } private void stopButton_Click(object sender, EventArgs e) { oBackupFiles.Stop(); enabledDisabled(false); } private void pathSourceButton_Click(object sender, EventArgs e) { if (folderBrowserDialog1.ShowDialog() == System.Windows.Forms.DialogResult.OK) { pathSourceTextBox.Text = folderBrowserDialog1.SelectedPath; } } private void pathDestinyButton_Click(object sender, EventArgs e) { if (folderBrowserDialog1.ShowDialog() == System.Windows.Forms.DialogResult.OK) { pathDestinyTextBox.Text = folderBrowserDialog1.SelectedPath; } } private void enabledDisabled(bool isBegin) { if (!warningListBox.InvokeRequired) { if (isBegin) { stopButton.Enabled = true; packageLengthNumericUpDown.Enabled = false; pathDestinyTextBox.Enabled = false; pathSourceTextBox.Enabled = false; pathDestinyButton.Enabled = false; pathSourceButton.Enabled = false; startButton.Enabled = false; progressBar1.Enabled = true; } else { stopButton.Enabled = false; packageLengthNumericUpDown.Enabled = true; pathDestinyTextBox.Enabled = true; pathSourceTextBox.Enabled = true; pathDestinyButton.Enabled = true; pathSourceButton.Enabled = true; startButton.Enabled = true; progressBar1.Enabled = false; } } else { warningListBox.Invoke(OnEnabledDisabled, new object[1] { isBegin }); } } } }
Listagem 13. Código do formulário

Esse é um sistema simples de backup, pode ser criadas mais funcionalidades, como restaurar os arquivos.

Ebook exclusivo
Dê um upgrade no início da sua jornada. Crie sua conta grátis e baixe o e-book

Artigos relacionados