0 Compartilhamentos 275 Views

Compressão e Descompressão de dados em Java

6 de maio de 2008

Neste artigo veremos como trabalhar com arquivos compactados em aplicações Java. Vamos conhecer o pacote java.util.zip, presente na plataforma Java SE desde a versão 1.1, que oferece recursos para a criação e manipulação de arquivos no formato Zip e Gzip, além também do formato Jar, que é, basicamente, um arquivo Zip que contem classes Java e outros recursos dentro dele.

O artigo foi baseado no Java 5 (versão 1.5), mas deve funcionar com a versão 1.4.2.

Visão geral da API

É no pacote java.util.zip que se encontram todas as classes da API padrão do Java para ler e criar arquivos compactados.

O pacote possui ao todo um conjunto de 14 classes, 2 exceções e 1 interface. Deste conjunto, vamos apenas nos concentrar nas classes mais importantes e necessárias para o objetivo do artigo, que é a criação e a extração de arquivos compactados, portanto, vamos conhecer e estudar as classes ZipFile, ZipInputStream, ZipOutputStream e ZipEntry.

As duas primeiras classes são as responsáveis por lerem e extrairem o conteúdo dos arquivos Zip, no entanto, elas não são usadas em conjunto, mas sim separadamente. Ambas têm a mesma função, porém com características diferentes. A classe seguinte, ZipOutputStream, é responsável por criar e gravar o Zip e a última representa uma entrada do Zip.

Todo arquivo Zip é composto de uma ou mais entradas. Cada entrada corresponde a um arquivo ou diretório que originalmente foi compactado e armazenado no Zip e é representado pela classe ZipEntry. Esta classe possui métodos que permitem recuperar informações de cada entrada armazenada no Zip. Veja a Tabela 1:

Tabela 1. Métodos para recuperar informações da classe ZipEntry

MÉTODO RETORNO

  • getName() – Nome da entrada no zip.
  • getCompressedSize() – Tamanho do dado compactado da entrada ou -1 se desconhecido.
  • getSize() – Tamanho original da entrada ou -1 se desconhecido.
  • getTime() – Data e hora de modificação da entrada.
  • isDirectory() – Booleano que indica se é um diretório.
  • getMethod() – Método de compressão usado para armazenar a entrada. Pode ser comparado com as constantes STORED e DEFLATED da própria classe.

Mais características das classes mencionadas e o modo de se recuperar um entrada do Zip serão apresentadas ao longo do artigo.

Para facilitar o entendimento do artigo e simplificar o uso de termos, chamaremos os arquivos Zip apenas de Zip.

Listando o conteúdo do Zip

Ler e descompactar o conteúdo de um Zip é apenas uma questão de abrir um stream para o arquivo e ler os dados retornados, da mesma maneira que se lê arquivos comuns, não compactados.

As classes ZipFile e ZipInputStream oferecem facilidades de leitura e extração de arquivos Zip, sendo possível recuperar cada um dos arquivos ou diretórios armazenados. Como já mencionado, elas não trabalham em conjunto, mas sim separadamente, pois ambas desempenham a mesma funcionalidade, porém com características diferentes.

A classe ZipFile dispensa o uso explícito de streams para abrir o Zip e mantém um cache interno das entradas, assim, caso o Zip necessite ser aberto novamente, a operação será mais rápida que no primeiro acesso. Entretanto o uso de ZipFile não é aconselhável caso o Zip seja alterado constantemente por outras aplicações, pois o cache de entradas pode ficar desatualizado e causar inconsistência. Já a classe ZipInputStream usa streams para abrir o Zip e não mantém um cache das entradas.

O exemplo da Listagem 1 abre um Zip existente em disco usando a classe ZipFile, e exibe na saída padrão o nome de todas as entradas (arquivos e diretórios) armazenadas nele. O Zip a ser aberto é determinado ao instanciar ZipFile, cujo construtor pode receber um string com o caminho do Zip, ou um java.io.File.

Listagem 1: ListagemZip.java

As entradas do Zip são recuperadas com o método entries() de ZipFile, que retorna um java.util.Enumeration permitindo navegar por uma coleção de objetos ZipEntry.

Com a atualização da API para o Java 5, foi adotado o uso de Generics no retorno deste método, que na verdade retorna um java.util.Enumeration, ou seja, ele define que o retorno é um Enumeration com objetos ZipEntry ou seus subtipos. Este é o único caso de utilização de Generics na API, portanto, todo o resto segue o padrão de utilização do Java 1.4. Então, quem usa Java 5 pode fazer desta forma:

As entradas são recuperadas uma a uma, a cada iteração do laço while, com nextElement(), e o nome da entrada é obtido com getName() e exibido na saída padrão:

A outra forma de trabalhar com o Zip, usando a classe ZipInputStream, está demonstrada na Listagem 2, que faz o mesmo que o primeiro exemplo – abre o Zip e lista as entradas na saída padrão.

Listagem 2: ListagemZip2.java

Inicialmente, abrimos um FileInputStream para o Zip. Em seguida criamos um objeto ZipInputStream, que recebe um InputStream em seu construtor. É este objeto que lê o Zip e devolve os dados nele armazenados.

O método getNextEntry() retorna um objeto do tipo ZipEntry, que representa uma entrada (arquivo ou diretório) armazenada no Zip. Como no primeiro exemplo, a cada iteração do while, é obtida a próxima entrada do Zip e exibido o seu nome na saída padrão.

A aplicação de exemplo

Para exemplificar a extração e a criação de arquivos Zip mais completamente, criamos uma aplicação gráfica, no estilo do aplicativo Winzip, porém mais simples, mostrada em execução na Figura 1.

Figura 1: Aplicação de exemplo listando o conteúdo de um Zip.

A aplicação usa uma classe principal, com métodos que encapsulam as funcionalidades de manipulação de Zips, que criamos para simplificar as operações e também poder ser reusada em outros casos e aplicações. É esta classe que descreveremos aqui. O restante das classes não será apresentado, mas está disponível para download.

A Listagem 3 exibe o código da classe jm.Zipper. No programa de exemplo foram usados os recursos de ZipFile para a extração do Zip, mas mostraremos paralelamente como utilizar ZipInputStream para o mesmo fim.

A Tabela 2 resume os métodos públicos dessa classe.

Tabela 2. Métodos públicos da classe de exemplo Zipper

MÉTODO DESCRIÇÃO

  • listarEntradasZip( File ) – Abre o arquivo zip informado e retorna um List de objetos ZipEntry.
  • criarZip( File, File[] ) – Cria um arquivo zip com nome e path informado no File fornecido, contendo os arquivos e diretórios no array de objetos File. Adiciona subdiretórios e arquivos, recursivamente.
  • criarZip(java.io.OutputStream, File[]) – Sobrecarga de criarZip(), porém recebendo um stream (em vez de um File) para gravação dos bytes do zip.
  • extrairZip( File, File ) – Extrai o zip informado no primeiro argumento para o diretório informado no segundo. (Lembrando que objetos File podem representar tanto arquivos como diretórios).
  • extrairZip( File ) – Extrai o último zip utilizado pela classe Zipper, no diretório informado como argumento.
  • getArquivoZipAtual() – Retorna a referência para o último arquivo zip usado ou null se não houver um arquivo.
  • fecharZip() – Encerra as referências para o último zip em uso. É usado no programa quando fechamos o arquivo aberto.

A escolha do uso de ZipFile em vez de ZipInputStream na aplicação não foi com feita base em aspectos técnicos ou performáticos, mas foi meramente por um problema detectado durante o desenvolvimento do programa. Se você tentar abrir um arquivo que não seja do formato ZIP com a classe ZipInputStream, nenhuma exceção é lançada e nenhum byte é lido. A classe ZipFile, ao contrário, lança uma exceção caso um arquivo fora do padrão seja aberto, o que, no nosso caso, é importante saber, para que o programa fique consistente e não haja problemas quando o usuário tenta abrir outro tipo de arquivo.

Extração

Para explicar a extração, tomaremos como base o método extrairZip( File, File ). Inicialmente o método verifica se existe o diretório onde o zip será extraído, senão o cria. Caso o diretório especificado seja inválido, é lançada uma exceção:

Em seguida é criado um objeto ZipFile, vinculado ao Zip em disco. A partir deste objeto, recuperamos as entradas do Zip com o método entries():

Então extraímos o conteúdo para cada entrada recuperada. Para isso, criamos um objeto File com o diretório onde ele será extraído e o nome do arquivo, baseado no nome da entrada.

Se a entrada representar um diretório inexistente, então criamos o diretório em disco e pulamos para a próxima entrada. Se o File representar um arquivo, verificamos se existe a estrutura de diretórios a qual ele pertence e então a criamos, se não existir. Esse cuidado é necessário porque as entradas extraídas do Zip podem conter, além do nome, um caminho.
Consulte o quadro “java.io.File e Streams” para mais detalhes sobre essas classes.

É dentro do bloco try, logo em seguida ao trecho anterior, que acontece a extração do arquivo. Para cada entrada, por meio do objeto de ZipFile, obtemos um InputStream, informando a entrada a ser recuperada. E criamos um FileOutputStream para gravar o arquivo em disco. Repare que o método getInputStream() recebe como argumento o ZipEntry que vai ser recuperado, então o método retorna o InputStream para e leitura dos dados da entrada especificada, permitindo, desta forma, a leitura aleatória das entradas do Zip.

No while é feita a leitura dos dados provenientes do InputStream, que então são gravados em disco, através do stream de saída. Ao final da gravação, fechamos os dois streams abertos, dentro do bloco finally. Finalizadas a extração e a gravação em disco de todas as entradas, fechamos o zip com o método close() de ZipFile.

Extraindo com ZipInputStream

A Listagem 4 demonstra como fazer a extração com ZipInputStream. Com ZipInputStream, os dados podem ser lidos diretamente através do método read(), o qual retorna apenas os dados da entrada atual. Veja que esta classe lê o Zip seqüencialmente, entrada a entrada. Para passar à próxima entrada invocamos o método getNextEntry(). Nesse exemplo abrimos um ZipInputStream vinculado a um Zip em disco (usando um BufferedOutputStream).

Listagem 4: Extração de zip com ZipInputStream

Criando um Zip

Ainda com base no programa de exemplo, vamos analisar como podemos criar arquivos Zip com Java. Relembrando, a Listagem 3 exibe a classe Zipper que foi criada para encapsular e facilitar o uso das funcionalidades da API de Zip no nosso programa de exemplo, que é baseado no aplicativo Winzip, porém mais simples.

O método criarZip( File, File[] ) da classe Zipper é usada pelo programa de exemplo para cria um Zip em disco, armazenando e compactando os arquivos e diretórios selecionados, incluindo toda a raiz de subdiretórios e arquivos. Este método recebe dois argumentos: o File para o Zip a ser criado e um array de File que contém os arquivos e diretórios a serem compactados e armazenados no Zip.

Acompanhando a Listagem 3, no código do método, podemos ver o que ele faz. Primeiro verifica se o arquivo informado possui a extensão .zip, senão cria um novo File para conter a extensão desejada, baseado no File informado.

Depois do passo anterior é criado um stream para o arquivo, que será usado para gravar os bytes em disco.

Não é exatamente no método atual que está a lógica de criação do Zip, mas sim na sua sobrecarga, criarZip( OutputStream, File[] ), que recebe o objeto de OutputStream para o Zip e o mesmo array de File recebido como argumento.

Esta sobrecarga foi criada para dar maior usabilidade à classe, pois utilizando esta opção podemos não só gravar o Zip em disco, mas também gravar os dados em qualquer stream de saída, como por exemplo no stream da resposta HTTP, sendo possível enviar ao navegador web um Zip, sem a necessidade de gravá-lo em disco. Mais tarde voltaremos a falar sobre geração de Zip na web.

Voltando à criação do Zip, vamos olhar dentro do método criarZip( OutputStream, File[] ), que é onde o Zip é realmente criado. A primeira instrução no método verifica se o segundo argumento, o array de File, veio como null ou sem itens. Caso verdadeiro ele lança uma exceção informando o erro. Em seguida é criada uma lista, com a classe ArrayList, com o nome da variável listaEntradasZip, que vai armazenar todos os objetos ZipEntry que forem armazenados no Zip, que no final será o retorno do método.

Esta é uma informação a ser retornada apenas para não ter que se abrir e ler o Zip novamente para obter a lista de ZipEntry que o Zip criado contém. No caso da nossa aplicação de exemplo, depois de criado o Zip, será exibida na tela uma lista das entradas armazenadas.

Continuando no código do método chegamos ao primeiro ponto que interessa de fato na criação do Zip.

É por meio da classe ZipOutputStream que gravamos os dados que serão armazenados e compactados dentro do Zip.

O construtor da classe recebe o OutputStream para onde serão gravados os bytes do Zip. O laço for a seguir percorre cada item do array de File recebido como argumento e, para cada File, pega o caminho onde se encontra o File e passa ao método privado adicionarArquivoNoZip(), junto com o stream e o File a ser adicionado no Zip. É dentro deste método que o File será gravado no stream do Zip. O método foi criado para ser reusado, principalmente recursivamente, para incluir subdiretórios e outros arquivos existentes dentro de um diretório informado.

O método adicionarArquivoNoZip() recebe três argumentos: o ZipOutputStream, onde serão gravados os arquivos no Zip, o File a ser gravado e o caminho onde se encontra este File.

Dentro do método, a primeira instrução verifica se o File informado é um diretório. Caso positivo, então é obtida a lista de arquivos e diretórios que existem dentro deste diretório e através do laço for, cada File listado será incluído no Zip, através de uma chamada recursiva ao método adicionarArquivoNoZip(). O retorno deste método será adicionado à lista de entradas adicionadas, que vai compor a lista final completa das entradas do Zip.

Caso o File não seja um diretório, o fluxo segue normalmente pelo método, e o primeiro passo é calcular o nome completo da entrada no Zip.

Este passo foi considerado, pois não desejamos que a entrada seja adicionada ao zip com o seu caminho completo, por exemplo C:arquivosimagensfoto1.jpg, mas sim contendo apenas o caminho relativo ao local onde o arquivo foi originalmente selecionado, por exemplo imagensfoto1.jpg.

Calculado este caminho, então é criado um ZipEntry que será adicionado ao Zip através do método putNextEntry() de ZipOutputStream.

Quando se adiciona uma entrada a um Zip, deve-se fazer desta forma. Em seguida é chamado o método setMethod(), que define se a entrada deve ser adicionada de forma compactada ou apenas armazenada (sem compactação). Este método aceita um argumento do tipo int, que pode ser representado por duas constantes da classe ZipOutpuStream: DEFLATED (compactada) e STORED (sem compactação).

Seguindo no código, é criado um stream para ler o conteúdo do arquivo a ser adicionado ao Zip, que é lido dentro do laço while e gravado ao ZipOutputStream, através do seu método write(), que segue o padrão dos streams de saída.

Por fim a entrada é adicionada à lista de entradas e depois os streams de leitura do arquivo adicionado são fechados e a lista parcial de entradas adicionadas é retornada.

Neste ponto a execução volta ao método criarZip(), caso não seja uma chamada recursiva.

Ao final, o objeto de ZipOutputStream é fechado, após a inclusão de todas as entradas no Zip. E está finalizada a criação do zip.

Resumindo a criação do Zip: primeiro criamos um ZipOutputStream para o OutputStream onde vamos gravar o Zip (seja em arquivo ou outro meio), depois, para cada entrada a ser adicionada ao Zip, criamos um ZipEntry com o nome (e caminho) da entrada, adicionamos a entrada ao ZipOutputStream com putNextEntry(), então lemos os dados do arquivo a ser adicionado e gravamos no Zip com o método write().

Exemplo:

Gerando zip na web

Dois problemas recorrentes do protocolo HTTP na internet hoje podem ser solucionados com arquivos Zip.

1) A largura de banda limitada que não permite downloads mais rápidos pode ser, em parte, solucionado com a compactação do conteúdo de arquivos a serem baixados;
2) Outro problema é que o protocolo HTTP não permite download de mais de um arquivo simultaneamente, ou seja, com apenas um request, e isso pode ser solucionado adicionando os vários arquivos em um único Zip e enviando este para o cliente.

Nesta seção vamos demonstrar uma simples aplicação web, com uma página JSP que navega no sistema de arquivos (Imagem 2) da máquina em que se localiza o servidor web e permite fazer o download de um ou mais arquivos de uma só vez, recebido na forma de um Zip, via um Servlet.

Figura 2: Tela do programa web que gera zip

O mais interessante desta aplicação é que o Zip é gerado dinamicamente e enviado ao cliente sem a necessidade de criar um arquivo temporário ou intermediário em disco, ou seja, tudo é feito em memória e enviado ao cliente (geração on-the-fly), evitando acesso ao disco.

O código do JSP que navega no sistema de arquivo está fora do escopo e não será listado no artigo, mas está disponível para download. O que nos interesse de fato é o código do Servlet, que recebe uma requisição com o caminho dos arquivos selecionados para download – através do parâmetro HTTP “arquivo” – gera o Zip e o envia na resposta HTTP.

A Listagem 6 exibe todo o código da classe DownloadServlet, que é um HttpServlet.

Listagem 6: Servlet de download de ZIP

O Servlet atende apenas a requisições GET e POST, pois implementa apenas os métodos doGet() e doPost(). Dentro do método obtemos um array de strings, que será o caminho absoluto dos arquivos e diretórios selecionados no JSP. A partir deste array verificamos se foi selecionado pelo menos um item, senão, uma mensagem de erro é enviada e exibida de volta no JSP.

Caso haja sido feita a seleção, o programa faz duas coisas importantes no ambiente web.

Primeiro define o conteúdo da resposta, ou seja, o content-type, que no caso é definido como application/octet-stream. Depois definimos um cabeçalho (Content-Disposition) que diz ao navegador que estamos enviando um arquivo anexado (attachment) e que o navegador deve abrir a janela perguntando se o usuário deseja abrir ou salvar o arquivo enviado.

Depois obtemos o stream para a resposta HTTP e então chamamos o método gerarZip() do próprio Servlet, que é quem vai gerar o Zip, através da classe Zipper, que criamos para o programa de exemplo anterior.

Repare que este método gerarZip() recebe dois argumentos: o stream para a resposta HTTP e o array com os arquivos selecionados. Neste método criamos um array de File, a partir do array de string, que será um dos argumentos do método criarZip() da classe Zipper.

Recorde que vimos o método criarZip() na seção anterior do artigo e vimos que este método gera um Zip diretamente em um OutputStream ao invés de em um arquivo em disco. O que ocorre é que gravamos os bytes do Zip diretamente na resposta HTTP que será recebido pelo navegador, que reconhecerá o arquivo e dará a opção de abrir ou salvar o arquivo recebido. Por fim é feito um flush() no stream para efetivar o envio dos bytes ao cliente.

O arquivo disponível para download no site contém um arquivo WAR (jm-zipper.war) dentro do diretório deploy, que pode ser instalado em qualquer container web ou J2EE disponível.

Coloque este WAR no local correto para o seu container (no caso do Tomcat, copie o WAR para o diretório webapps) e o inicie. Abra o seu navegador preferido e aponte para http://localhost:8080/jm-zipper/.

Caso o seu servidor esteja configurado em outra máquina ou porta, altere os valores necessários.

Trabalhando com GZIP

Alternativamente ao formato ZIP, podemos criar e manipular arquivos GZIP, porém este último formato não permite armazenamento de múltiplos arquivos, mas apenas de um arquivo por vez.

Para mais detalhes sobre as diferenças entre os formatos Zip e Gzip, consulte o quadro “ZIP versus GZIP”.

Para criar e extrair conteúdo do GZIP usamos as classes GZIPInputStream e GZIPOutputStream, respectivamente.

A Listagem 7 mostra um programa que cria um arquivo GZIP e depois extrai o conteúdo do mesmo GZIP criado. Perceba que a criação e extração é meramente uma questão de abrir um stream e gravar ou obter os dados.

Listagem 7: Manipulando arquivos GZIP

ZIP versus GZIP

Os usuários do Windows estão familiarizados com o formato ZIP, que serve tanto para armazenar arquivos, como para comprimir dados.

Os usuários Linux/Unix usam também o formato GZIP, que apenas comprime dados, não servido como arquivador, pois este formato não permite mais de um arquivo armazenado nele. Geralmente usuários Linux usam duas ferramentas: o tar, para gerar um arquivo que arquiva (armazena) diversos outros arquivos e o gzip para compactar o arquivo tar gerado, criando arquivos com a extensão no padrão tar.gz.

java.io.File e Streams

A classe java.io.File é uma representação abstrata para o caminho de um arquivo ou diretório, independente do sistema operacional ou sistema de arquivos utilizado.

Esta classe possui muitos métodos úteis para a obtenção de informações do arquivo em disco, verificar se ele existe, se é arquivo ou diretório, criar a estrutura de diretórios representada, criar um arquivo vazio em disco, listar os diretórios e arquivos etc.

Para mais detalhes da classe, consulte a documentação em: http://java.sun.com/j2se/1.4.2/docs/api/java/io/File.html.

Os streams (ou fluxos) são a base da comunicação dos dados. Eles são como os dutos que transportam os bytes de um lado para o outro.

Stream é um conceito genérico para o transporte de dados, que é utilizado em diversas necessidades como acesso a arquivo em disco, comunicação de rede via sockets etc.

Basicamente existem dois tipos de streams: os de entrada, chamados de input stream; e os de saída, chamados output stream.

Os streams de entrada servem para ler ou receber dados oriundos de alguma fonte, já os de saída, consequentemente, servem para enviar ou gravar dados para outro destino.

Em comum, as classes que implementam os streams de entrada fornecem o método read() para leitura dos dados e as classes que implementam os stream de saída fornecem o método write() para gravar os dados.

Para estar dentro do padrão de streams do Java, toda classe que é um stream de entrada deve implementar a interface java.io.InputStream, direta ou indiretamente.

O mesmo vale para as classes de stream de saída, que devem implementar a interface java.io.OutputStream.

As classes BufferedInputStream e BufferedOutputStream implementam o conceito de buffer para a leitura e gravação de bytes via streams.

O uso dessas classes torna os acesso mais performáticos, pois trabalham com os bytes de dados em memória e evita o acesso direto a todo instante ao disco, por exemplo.

Mais informações, consulte o tutorial oficial da Sun: http://java.sun.com/docs/books/tutorial/essential/TOC.html#io

Conclusões

O artigo demonstrou através de exemplos a utilização do pacote java.util.zip para a leitura, extração e criação de arquivos ZIP, além de uma aplicação de exemplo no estilo Winzip e como podemos gerar arquivos zip on-the-fly na web.

Para finalizar, foi demonstrado também como se trabalhar com arquivos GZIP.
Agora você tem o conhecimento necessário para trabalhar com compactação Zip de arquivos, que poderá ajudar muito a enriquecer suas aplicações.

É interessante que o leitor baixe o conteúdo disponível para download, pois assim você pode verificar todos os fontes, além de abrir o projeto na IDE Eclipse (versão utilizada 3.1) ou uma outra de sua preferência – será necessário criar um novo projeto para outra IDE. Dentro do diretório deploy é disponibilizado o JAR com os binários do artigo e o programa de exemplo. Para iniciar o programa, dê um duplo-clique com o mouse no JAR ou execute a seguinte linha de comando:

java -jar jm-zipper.jar

Você pode se interessar

Como baixar o novo Windows Terminal
Dicas
8 visualizações
Dicas
8 visualizações

Como baixar o novo Windows Terminal

Carlos L. A. da Silva - 26 de junho de 2019

Ferramenta foi turbinada e já está disponível em versão de prévia para usuários do window

Libra: o que sabemos sobre a criptomoeda do Facebook
Artigos
7 visualizações
Artigos
7 visualizações

Libra: o que sabemos sobre a criptomoeda do Facebook

Carlos L. A. da Silva - 25 de junho de 2019

O anúncio oficial do Libra promete um futuro surpreendente para a maior rede social do mundo.

WebAssembly // Dicionário do Programador
Vídeos
1,655 compartilhamentos6,807 visualizações
Vídeos
1,655 compartilhamentos6,807 visualizações

WebAssembly // Dicionário do Programador

Thais Cardoso de Mello - 24 de junho de 2019

Quer descobrir o que está por trás dessa tecnologia que já chega achando que pode sentar na janela do desenvolvimento web? Assista esse episódio e descubra!

Deixe um Comentário

Your email address will not be published.

Mais publicações

Promoções de Jogos do Final de Semana (21/06)
Notícias
8 visualizações
8 visualizações

Promoções de Jogos do Final de Semana (21/06)

Carlos L. A. da Silva - 21 de junho de 2019
Histórias do Hotmail
Artigos
9 visualizações
9 visualizações

Histórias do Hotmail

Carlos L. A. da Silva - 21 de junho de 2019
Top 5 linguagens de programação para IA e Machine Learning
Vídeos
7 visualizações
7 visualizações

Top 5 linguagens de programação para IA e Machine Learning

Thais Cardoso de Mello - 20 de junho de 2019
8 jogos que foram cancelados para surgirem jogos diferentes
Artigos
7 visualizações
7 visualizações

8 jogos que foram cancelados para surgirem jogos diferentes

Carlos L. A. da Silva - 17 de junho de 2019