Danilo Correa
Posted on December 30, 2023
Olá, pessoal! Tudo bem? Neste artigo, abordarei um assunto que parece simples à primeira vista, mas muitas vezes passa despercebido na forma como o implementamos em nosso código. Antes de começar, quero ressaltar que não há uma abordagem certa ou errada. No entanto, é possível antecipar alguns problemas que podem surgir no futuro.
Imagine o seguinte cenário: você possui uma coleção de arquivos no S3 e sua aplicação precisa compactar esses arquivos. Existem várias maneiras de implementar essa lógica, e se você está começando na área de desenvolvimento, não se preocupe. Comece com o cenário mais simples e, com o tempo, aprimore o seu código.
A seguir, vou descrever a Versão 1 do nosso cenário mais simples e, em seguida, a Versão 2 com algumas melhorias.
Versão 1
Como mencionei, imagine que você tenha uma lista de 10 ou mais arquivos no S3, e sua aplicação ou script precisa criar um arquivo zip com todos esses arquivos. Para esse cenário, apresentarei os passos de forma simplificada. Compartilhe nos comentários se você utiliza algo semelhante em seu trabalho.
- Baixar cada arquivo do S3 de forma síncrona.
- Salvar cada arquivo em um diretório do nosso sistema de arquivos.
- Compactar todos os arquivos em um zip e salvar em nosso sistema de arquivos.
- Carregar (upload) o arquivo zip para o S3.
Este é um fluxo comum que funciona bem. Não há nada de errado aqui, mas podemos melhorar muitos aspectos.
Versão 2
Agora, a brincadeira começa a ficar interessante! Na Versão 2 do nosso script, podemos aprimorar basicamente o seguinte: todos os arquivos podem ser baixados de forma assíncrona, permitindo que nosso script não espere um arquivo ser baixado totalmente para iniciar o próximo. Além disso, em vez de criar um arquivo zip no sistema de arquivos local, podemos criar um arquivo zip diretamente no S3. Isso mesmo! Dessa forma, podemos adicionar o conteúdo baixado de forma assíncrona ao zip que já está no S3, eliminando a necessidade de salvar algo em nosso sistema de arquivos.
- Baixar o conteúdo de cada arquivo de forma assíncrona.
- Criar um arquivo zip diretamente no S3 usando o SDK da AWS.
- Para cada arquivo baixado de forma assíncrona, adicionar o conteúdo ao zip.
- Quando tudo estiver concluído, fechar o arquivo.
Parece fácil quando explicado assim, não é? Não é tão simples na prática, mas também não é algo impossível. Vamos ver como fazer isso em PHP, certo?
Para facilitar, criei um repositório (clique aqui) no GitHub com alguns scripts prontos. Você pode clonar o repositório em sua máquina e executar em um ambiente local usando Docker ou conectar-se ao seu próprio ambiente AWS.
Preparando ambiente
Para começar, faça o clone do repositório e instale todas as dependências.
git clone git@github.com:dcorrea777/zipfile-s3.git
Após clonar o repositório, instale todas as dependências do projeto e ajuste as variáveis de ambiente, se necessário, no arquivo .env.
# Instalando os pacotes
composer install
# Criando um arquivo .env a partir do .env.example
cp .env.example .env
Abra o arquivo .env e ajuste os valores de acordo com o seu ambiente. As variáveis de ambiente incluem configurações para o serviço que simula o S3 localmente, informações do bucket local e credenciais da AWS.
# Serviço que simula o S3 em seu ambiente local
STORAGE_ENDPOINT="http://localhost:9000"
# Aqui você escolhe qual tipo de driver você quer usar (local, s3)
STORAGE_DRIVER="local"
# Nome do bucket que é criado no ambiente local
STORAGE_NAME="zip"
AWS_REGION="us-east-1"
# Credenciais da AWS
STORAGE_ACCESS_KEY="fake"
STORAGE_SECRET_KEY="fake"
Após instalar os pacotes e ajustar as variáveis do seu arquivo .env, vamos executar o docker-compose.yml para subir o serviço do minio. Basicamente o minio é um s3 rondando localhost, com isso podemos se conectar usando o proprio SDK da AWS para fazer nossos testes.
# Subir o serviço do minio
docker-compose up -d
# Verificar se o serviço esta sendo executado
docker-compose ps
Após subir o container do minio, você precisará fazer algumas configurações locais. Acesse o endereço http://localhost:9001 e faça login com as credenciais que estão no arquivo docker-compose.yml.
Exemplo
MINIO_ROOT_USER=admin
MINIO_ROOT_PASSWORD=supersecret
Depois de fazer login, você precisa gerar uma credencial de acesso em seu ambiente local, então vá em: Access Keys (Menu lateral na esquerda) >> Create access keys (Botão na direita) e clique em Create, use essas credenciais no seu arquivo .env.
# .env
STORAGE_ACCESS_KEY="COLOQUE AQUI SUA ACCESS KEY"
STORAGE_SECRET_KEY="COLOQUE AQUI SUA SECRET KEY"
Para ambiente local, eu criei um arquivo upload-to-s3.php
que gera 10 arquivos .txt
com o tamanho de 2GB usando um comando do Linux. Lembrando que o docker-compose.yml apenas contém o serviço do minio. Estou partindo do princípio de que você tem o PHP instalado na sua máquina. Caso não tenha, eu escrevi outro artigo sobre como instalar o PHP na sua máquina usando o ASDF.
php upload-to-s3.php
Basicamente esse script gera os arquivos e os carrega no minio ou S3. Após executar o script, você pode acessar o minio e verificar se os arquivos existem no bucket zip.
Mão na massa!
Agora vamos para o que interessa. Antes de você executar o arquivo zip.php
, vou explicar cada arquivo e pasta e no final você pode executar para testar, ok? Antes de falar do arquivo zip.php
, que é o arquivo principal, vou comentar sobre a pasta config e seus arquivos.
Config > storage.php
Na pasta config, temos alguns arquivos de configuração para auxiliar na organização de nosso projeto. O arquivo storage.php basicamente é um arquivo de configuração, onde você escolhe se quer que seu código execute no minio ou no próprio S3 da AWS. Esse arquivo é carregado em nosso container de dependência.
Config > container.php
Como o nome já diz, é nosso arquivo de container de dependência. Todas as instâncias do nosso projeto estão aqui. Eu sei que a gente só tem uma, mas isso deixa o código mais organizado hahaha. Enfim, aqui estamos usando o pacote php-di para nosso container de dependência.
Config > bootstrap.php
E, por fim, nosso arquivo bootstrap.php
instancia nosso container de dependência e retorna a própria instância. Além disso, carrega as variáveis de ambiente usando o pacote phpdotenv.
Zip.php
O nosso arquivo zip.php, que está na raiz do projeto, é onde contém todo o código que gera o arquivo zip no S3.
$container = require_once __DIR__ . '/config/bootstrap.php';
$storage = $container->get(S3ClientInterface::class);
$objectsToZip = $container->get('files');
Aqui carregamos nosso container de dependência e pegamos a instância do AWS S3. Depois, carregamos todos os nomes de arquivos que fizemos upload no script upload-to-s3.php
.
$storage->registerStreamWrapper();
$zipStream = fopen("s3://zip/example.zip", 'w');
Agora vamos prestar bastante atenção, porque aqui é onde acontece o pulo do gato hahah. A primeira linha basicamente cria um wrapper de todas as funções de arquivo do PHP, dessa forma você pode usar as próprias funções built-in
do PHP para interagir com o S3. Basicamente, o fopen da segunda linha está criando um arquivo zip diretamente no S3 e não no sistema de arquivos local. Caso você tenha interesse em entender mais sobre isso, siga a documentação da própria AWS: S3 Stream Wrapper.
https://docs.aws.amazon.com/sdk-for-php/v3/developer-guide/s3-stream-wrapper.html
$zip = new ZipStream(
outputStream: $zipStream,
sendHttpHeaders: true,
);
No código acima, estamos criando uma instância do nosso zip usando a biblioteca maennchen/zipstream-php. Aqui é apenas uma instância, e perceba que estamos passando o stream do nosso arquivo zip que foi criado no S3 para ela como parâmetro.
$fulfilled = function (ResultInterface $result, $item, PromiseInterface $aggregatePromise) use ($zip, $objectsToZip) {
dump("Downloaded file: " . $objectsToZip[$item]);
$zip->addFileFromPsr7Stream(
fileName: $objectsToZip[$item],
stream: $result->get('Body'),
);
dump("Added in zip: " . $objectsToZip[$item]);
};
$before = function (CommandInterface $cmd, $iterKey) use ($objectsToZip) {
dump('Starting download: ' . $cmd->toArray()['Key']);
};
Aqui estamos criando duas callbacks que serão executadas como eventos quando o processo do SDK da AWS for executado. Basicamente, estamos utilizando a classe CommandPool
da AWS para executar de forma assíncrona vários comandos. Essas callbacks são executadas em determinada ação do nosso processo.
A callback $before
é acionada quando o processo de carregar o arquivo é iniciado. No nosso exemplo, vamos executar o método GetObject
, então toda vez que o método GetObject
for executado, essa callback é acionada.
A callback $fulfilled
é acionada quando um arquivo é carregado (Download for concluído). Toda vez que um arquivo for carregado, estou adicionando o conteúdo via stream $result->get('Body')
, para o método $zip->addFileFromPsr7Stream(stream: $result->get('Body'))
da nossa instância do zip. Com isso, toda vez que um arquivo é carregado, o mesmo já é adicionado no zip que já está no S3. Coisa linda, não é?
$commands = [];
foreach ($objectsToZip as $objectKey) {
$commands[] = $storage->getCommand('getObject', [
'Bucket' => 'zip',
'Key' => $objectKey,
]);
}
Nesse trecho, precisamos montar uma lista de comandos que vão ser passados para um Pool de Comandos no SDK da AWS. Com isso, podemos executar vários processos de forma assíncrona. Veja que estou criando um Pool de Comandos GetObject
e passando o nome do bucket e o nome do arquivo que quero baixar.
$pool = new CommandPool($storage, $commands, ['fulfilled' => $fulfilled, 'before' => $before]);
$promise = $pool->promise();
$promise->wait();
$zip->finish();
fclose($zipStream);
E, por fim, criamos uma instância de CommandPool
. Para o objeto CommandPool
, você precisa passar a instância do serviço que será executado (que é o S3 no nosso caso). Depois, você passa uma lista de comandos que serão executados, que no nosso caso é o getObject
. E então, você tem uma lista onde define vários tipos de eventos que serão acionados. Por exemplo, você tem fulfilled
, que é uma callback
que será executada após cada comando ser finalizado; você tem o before, que é uma callback
que é acionada antes de cada comando. Para ver todos os tipos de eventos que são suportados, acesse esta documentação:
https://docs.aws.amazon.com/sdk-for-php/v3/developer-guide/guide_commands.html
Após criar a instância do CommandPool
, nós executamos o método $promise->wait()
. Com isso, a lista de comandos é executada de forma assíncrona e o processo do PHP é bloqueado. Quando todos os comandos forem executados, o processo é desbloqueado e, por fim, a instância do zip é finalizada $zip->finish()
e o arquivo no S3 é fechado fclose($zipStream)
.
Agora, execute o script zip.php
em sua máquina e verifique se tudo ocorreu como o esperado. Caso ocorra algum erro ou tenha alguma dúvida, deixe-me saber nos comentários. Outro ponto que não comentei ao longo deste artigo é que todo conteúdo baixado e compactado é processado via stream. Isso permite que você execute esse processo em arquivos grandes, utilizando pouca memória em PHP. Como prova disso, execute o arquivo com o seguinte comando:
php -d memory_limit=128M zip.php
Estamos executando nosso script com apenas 128MB, mesmo lidando com arquivos que têm 2GB cada. Legal, não é?"
Conclusão
Partindo de um cenário simples em que executamos tudo de forma síncrona, esse segundo cenário trouxe-nos muitas melhorias. Agora podemos ter um script que pode ser executado na metade do tempo, sem a necessidade de armazenar nada em disco. Este é um caso de uso que implementei na empresa em que trabalhava. Todo esse processo era executado de forma assíncrona usando Lambda, Eventbridge e, é claro, PHP.
Ao executar isso em um ambiente Lambda, podemos enfrentar algumas limitações em relação ao tempo de execução e espaço em disco. No entanto, como no nosso segundo cenário não utilizamos o disco para baixar os arquivos, não enfrentamos mais esse problema.
Bom, acho que é isso, pessoal. Se gostou do artigo, não esqueça de compartilhar e deixar seu comentário =)
Posted on December 30, 2023
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.