Upload de arquivos com Spring Boot
O processo de enviar arquivos para o servidor (upload) é uma tarefa muito comum em aplicações web. Por exemplo, ao se cadastrar em uma rede social é comum que o usuário envie uma foto de perfil para o servidor, ou em um site de e-commerce, a pessoa responsável por cadastrar os produtos vai enviar uma ou mais fotos daquele produto para o servidor.
Apesar de ser uma funcionalidade muito comum isso não significa dizer que seja fácil implementar. A primeira parte importante é entender que existem diferentes abordagens para se implementar o upload de arquivos, como por exemplo, armazenar o arquivo em um sistema de arquivos local, ou em um banco de dados, ou ainda em um serviço de armazenamento na nuvem.
Neste artigo vamos ilustrar o armazenamento de arquivos em um sistema de arquivos local, ou seja, vamos armazenar os arquivos em um diretório do sistema operacional. Mas já tenha em mente que essa é a abordagem menos recomendada.
Serviços como o Amazon S3, Google Cloud Storage, Azure Blob Storage, entre outros, são serviços de armazenamento na nuvem que oferecem uma série de vantagens em relação ao armazenamento local, como por exemplo, escalabilidade, alta disponibilidade, segurança, entre outros. Se você precisa de uma solução própria, ainda existe sistemas como o MinIO que oferecem uma solução de armazenamento na nuvem open source.
Implementando o upload de arquivo único
Como o intuito é entender o processo, vamos começar com o upload de um único arquivo (mais simples), mas saiba que também é possível fazer o upload de vários arquivos de uma vez só (mais complexo).
Primeiramente é importante entender o processo de upload de arquivos no qual um arquivo está sendo enviado pelo cliente para o servidor que é o responsável por armazenar esse arquivo e disponibilizá-lo posteriormente para acesso.
A primeira modificação importante para permitir o upload de arquivos é adicionar o elemento do tipo file no formulário HTML. Esse elemento é responsável por permitir que o usuário selecione um arquivo do seu computador para ser enviado para o servidor. Além do elemento file também é necessário adicionar o atributo enctype="multipart/form-data" no formulário HTML para que o navegador saiba que o formulário está enviando arquivos e não apenas texto simples. Para exemplificar, vamos alterar o formulário de cadastro de produtos products/create.html.
<form method="post" th:action="@{/products/store}" enctype="multipart/form-data">
...
<input type="file" name="file" />
<button type="submit">Enviar</button>
...
</form>
O próximo passo é alterar o controlador para que ele seja capaz de receber o arquivo enviado pelo formulário. Para isso, vamos alterar o método store do controlador ProductController para que ele receba um objeto do tipo MultipartFile como parâmetro. Esse objeto é fornecido pelo Spring e representa o arquivo enviado pelo formulário.
@PostMapping("/products/store")
public String store(@Valid Product product, @RequestParam MultipartFile file, BindingResult result, RedirectAttributes redirectAttributes) {
Agora além dos dados do produto estamos recebendo também o arquivo enviado pelo formulário. Mas como o Spring sabe que o arquivo enviado pelo formulário deve ser atribuído ao parâmetro file? Isso é feito através do atributo name do elemento input do tipo file que deve ser igual ao nome do parâmetro do método do controlador como já visto anteriormente.
O próximo passo é salvar o arquivo em um diretório do sistema operacional. Para isso, vamos criar uma nova classe de serviço chamada FileStorageService que será responsável por salvar o arquivo no sistema de arquivos local.
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.StandardCopyOption;
import org.springframework.core.env.Environment;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;
//Necessário para o próximo passo de "Alterando o nome do arquivo")
import org.springframework.util.StringUtils;
import java.util.UUID;
@Service
public class FileStorageService {
private final Path path;
public FileStorageService(Environment env) {
String uploadDir = env.getProperty("file.upload-dir");
if (uploadDir == null) {
// Lança uma exceção se a propriedade não estiver definida
throw new IllegalArgumentException("A propriedade 'file.upload-dir' não está definida no application.properties!");
}
this.path = Paths.get(uploadDir).toAbsolutePath().normalize();
try {
Files.createDirectories(this.path);
} catch(Exception ex) {
throw new RuntimeException("Não foi possível criar o diretório de uploads.", ex);
}
}
public String store(MultipartFile file) {
String fileName = file.getOriginalFilename();
try {
Path targetLocation = this.path.resolve(fileName);
Files.copy(file.getInputStream(), targetLocation, StandardCopyOption.REPLACE_EXISTING);
} catch(Exception ex) {
throw new RuntimeException("Não foi possível salvar o arquivo " + fileName + ". Por favor, tente novamente!", ex);
}
return fileName;
}
}
Analisando a classe FileStorageService podemos perceber que ela possui apenas um método chamado store que recebe um objeto do tipo MultipartFile e usa o método copy da classe Files para copiar o arquivo para um diretório. No caso o método recebe em seu parâmetro o objeto que está sendo enviado pelo formulário.
O caminho final é definido através do atributo file.upload-dir que é definido no arquivo application.properties. Caso esse atributo não seja definido, o construtor lança uma exceção informando que o atributo não está definido. Vamos definir esse atributo no arquivo application.properties:
file.upload-dir=./uploads/
Definimos o diretório ./uploads/ como o diretório onde os arquivos serão salvos. Esse diretório será criado automaticamente caso ele não exista. Esse diretório é relativo ao diretório onde a aplicação está sendo executada. Você pode definir qualquer outro diretório, inclusive um diretório absoluto como por exemplo: C:/uploads/ no Windows ou /home/user/uploads/ no Linux.
É importante definir um diretório que não faça parte do diretório da aplicação, pois quando a aplicação for para produção, o diretório da aplicação pode ser substituído por uma nova versão da aplicação, o que resultaria na perda dos arquivos enviados pelos usuários.
Faça essas alterações e teste o upload de arquivos. Você deve perceber que o arquivo é salvo no diretório ./uploads/ que é a base do processo de upload, porém ainda existem diversos aspectos que precisam ser observados como por exemplo: os nomes dos arquivos, o tamanho máximo dos arquivos, o tipo de arquivo permitidos, entre outros. Por isso que foi mencionado anteriormente que essa é a abordagem menos recomendada. Podemos dar um gostinho de como poderíamos melhorar um pouquinho esse processo.
Alterando o nome do arquivo
O primeiro aspecto que podemos melhorar é o nome do arquivo. Por padrão o nome do arquivo é o nome original do arquivo enviado pelo formulário, mas isso pode ser um problema, pois o nome do arquivo pode conter caracteres especiais, espaços, entre outros. Para resolver esse problema podemos alterar o nome do arquivo para um nome único gerado pelo próprio servidor.
Para isso, vamos alterar o método store da classe FileStorageService para que ele gere um nome único para o arquivo. Para isso, vamos usar o método UUID.randomUUID() que gera um identificador único universal (UUID) que é um número de 128 bits usado para identificar informações de forma única. Esse identificador é gerado de forma aleatória e é muito difícil que dois identificadores sejam iguais.
public String store(MultipartFile file) {
// Pega a extensão do arquivo original (ex: .jpg, .png)
String extension = StringUtils.getFilenameExtension(file.getOriginalFilename());
// Gera um nome de arquivo único
String fileName = UUID.randomUUID().toString() + "." + extension;
try {
Path targetLocation = this.path.resolve(fileName);
Files.copy(file.getInputStream(), targetLocation, StandardCopyOption.REPLACE_EXISTING);
} catch(Exception ex) {
throw new RuntimeException("Não foi possível salvar o arquivo " + fileName + ". Por favor, tente novamente!", ex);
}
return fileName;
}
Teste o upload de arquivos novamente e você deve perceber que o nome do arquivo é um identificador único. Mas ainda existem outros aspectos que precisam ser melhorados como por exemplo: o tamanho máximo dos arquivos, o tipo de arquivo permitidos, entre outros além do que apesar de o arquivo estar sendo salvo em um diretório do sistema operacional, ele ainda não está sendo disponibilizado para acesso.
Disponibilizando o arquivo para acesso
Para disponibilizar o arquivo precisamos primeiramente saber qual é o caminho do arquivo no sistema de arquivos local. Veja que até então foi possível armazenar o arquivo no sistema de arquivos local, mas em nenhum lugar é guardado o caminho do arquivo. E vamos precisar desse caminho para disponibilizar o arquivo para acesso.
Como o arquivo está em uma pasta externa (ex: ./uploads/), o Spring Boot não tem como saber que ele existe. Por padrão, ele só serve arquivos das pastas static, public, etc. Precisamos dizer ao Spring: “Ei, quando um arquivo for solicitado, procure também naquela minha pasta externa. A forma mais simples e rápida de configurar este comportamento é usar o application.properties.
Adicione a seguinte linha ao seu application.properties:
spring.web.resources.static-locations=classpath:/META-INF/resources/,classpath:/resources/,classpath:/static/,classpath:/public/,file:C:/loja-web/uploads/
file.upload-dir=./uploads/
A propriedade spring.web.resources.static-locations define os locais onde o Spring Boot deve procurar por arquivos estáticos. Por padrão, o Spring Boot já procura nas pastas META-INF/resources/, resources/, static/ e public/. Ao adicionar file:C:/loja-web/uploads/, estamos dizendo ao Spring para também procurar arquivos na pasta C:/loja-web/uploads/. O ‘file:’ no início e a ’/’ no final são obrigatórios!
Essa configuração diz ao Spring para procurar arquivos estáticos em todos os locais padrão e, além disso, na sua pasta externa. Isso significa que um arquivo salvo como imagem.jpg na sua pasta externa será acessado diretamente pela raiz do seu site: http://localhost:8080/imagem.jpg.
Agora que o arquivo está acessível, precisamos guardar a referência dele no banco de dados.
1. Guardar o nome do arquivo no Banco de Dados
Para isso, vamos criar um novo atributo na classe Product chamado image do tipo String que vai guardar o caminho do arquivo.
private String image;
public String getImage() {
return image;
}
public void setImage(String image) {
this.image = image;
}
O próximo passo é alterar o método store da classe ProductController para que ele guarde o caminho do arquivo no banco de dados:
if(!file.isEmpty()) {
String imageName = fileStorageService.store(file);
product.setImage(imageName);
}
Veja que alteramos o código dentro da condicional primeiramente recuperando o valor retornado pelo método store da classe FileStorageService, que se você se lembrar retorna o nome gerado para o arquivo, e depois atribuindo esse valor ao atributo image do produto para ser salvo no banco de dados, agora resta alterar o repositório para que ele seja capaz de salvar o caminho do arquivo no banco de dados.
O segundo e último passo é alterar o arquivo products/show.html para que ele seja capaz de exibir a imagem.
2. Exibir o arquivo na página HTML
O último passo é exibir a imagem. No seu arquivo de visualização (ex: products/show.html), use a tag img. O src da imagem será o nome do arquivo salvo no banco, acessado pela raiz (/).
<img th:src="@{'/' + ${product.image}}" alt="Imagem do Produto" width="200" >
Teste o upload de arquivos novamente e você deve perceber que o arquivo é salvo no diretório ./uploads/ e o caminho do arquivo é salvo no banco de dados. Além disso, o arquivo é disponibilizado para acesso através da URL / e é exibido na página de detalhes do produto.
Como mencionado anteriormente, essa é a abordagem menos recomendada para o upload de arquivos, pois perceba que a medida que vamos adicionando novas funcionalidades novos problemas começam a surgir como por exemplo: e se quisermos alterar a pasta onde os arquivos são salvos? E se deletarmos um produto, o que acontece com a imagem?, entre outros. Por isso que é recomendado usar um serviço de armazenamento individual para armazenar os arquivos.
Para saber mais
- Serving static web content with Spring Boot (blog do Spring)
- Spring Security without the WebSecurityConfigurerAdapter (blog do Spring)
- Serve static resources with Spring (Baeldung)
- Spring Boot: serve static web content and resources (FrugalisMinds)
- Gist de exemplo de upload (pavankjadda)
- Upload a file to a server with Spring Boot (Eric Cabrel Tiogo)
- Spring Boot file upload REST API (HowToDoInJava)
- Spring file upload (Java Development Journal)
- Spring Boot file handling: file upload, part 1 (Medium)
- The file uploading guide (Uploadcare)
- Different ways of file uploads on web application servers (Codementor)