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.

1
<form method="post" th:action="@{/products/store}" enctype="multipart/form-data">
2
...
3
<input type="file" name="file" />
4
<button type="submit">Enviar</button>
5
...
6
</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.

1
@PostMapping("/products/store")
2
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.

1
@Service
2
public class FileStorageService {
3
4
private final String fileBasePath = "./src/main/resources/static/public/";
5
private final Path path;
6
7
public FileStorageService(Environment env) {
8
this.path = Paths.get(env.getProperty("file.upload-dir", fileBasePath))
9
.toAbsolutePath().normalize();
10
11
try {
12
Files.createDirectories(this.path);
13
} catch(Exception ex) {
14
throw new RuntimeException("Could not create the directory where the uploaded files will be stored.", ex);
15
}
16
}
17
18
public String store(MultipartFile file) {
19
String fileName = file.getOriginalFilename();
20
try {
21
Path targetLocation = this.path.resolve(fileName);
22
Files.copy(file.getInputStream(), targetLocation);
23
} catch(Exception ex) {
24
throw new RuntimeException("Could not store file " + fileName + ". Please try again!", ex);
25
}
26
return fileName;
27
}
28
29
}

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 primeiro parâmetro o objeto que está sendo enviado pelo formulário e no segundo parâmetro o caminho final para onde esse arquivo deve ser copiado.

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 diretório padrão definido pela variável fileBasePath será usado.

Faça essas alterações e teste o upload de arquivos. Você deve perceber que o arquivo é salvo no diretório ./src/main/resources/static/public/ 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.

A pasta escolhida para armazenar os arquivos é a pasta public dentro da pasta static que é a pasta padrão para armazenar arquivos estáticos como imagens, CSS, JavaScript, etc. Essa pasta é automaticamente disponibilizada para acesso através da URL /public. Por exemplo, se você salvar um arquivo chamado teste.txt dentro da pasta public você pode acessá-lo através da URL http://localhost:8080/public/teste.txt. Caso queira salvar os arquivos em outro diretório, você precisará fazer mais configurações para disponibilizar esse diretório para acesso.

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.

1
public String store(MultipartFile file) {
2
String fileName = UUID.randomUUID().toString();
3
try {
4
Path targetLocation = this.path.resolve(fileName);
5
Files.copy(file.getInputStream(), targetLocation);
6
} catch(Exception ex) {
7
throw new RuntimeException("Could not store file " + fileName + ". Please try again!", ex);
8
}
9
return fileName;
10
}

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. Primeiramente precisamos de uma URL para conseguir acessar o arqivo. Como escolhemos armazenar os arquivos na pasta public dentro da pasta static podemos usar a URL /public para acessar os arquivos, visto que o Spring já usa essa pasta static e permite o acesso aos arquivos através da URL /public. A única configuração necessária é autorizar o acesso a URL /public porque lembre-se que por padrão o Spring Security bloqueia o acesso a todas as URLs.

Na primeira vez utilizamos o método requestMatchers().permitAll() para liberar o acesso das URLs que gostariamos de liberar. Porém existe uma outra forma para liberarmos o acesso a esses arquivos que nos ajuda também na organização visto que assim conseguimos separar a configuração dos arquivos estáticos da configuração das URLs da aplicação. Para isso, crie um novo Bean do tipo WebSecurityCustomizer:

1
@Bean
2
public WebSecurityCustomizer webSecurityCustomizer() {
3
return (web) -> web.ignoring().antMatchers("/public/**");
4
}

Você pode aproveitar e remover a configuração dos arquivos CSS e adicionar a configuração da URL usando virgula para separar as URLs:

1
@Bean
2
public WebSecurityCustomizer webSecurityCustomizer() {
3
return (web) -> web.ignoring().antMatchers("/public/**", "/css/**");
4
}

Agora você pode acessar os arquivos armazenados na pasta public através da URL /public. Por exemplo, se você salvar um arquivo chamado teste.txt dentro da pasta public você pode acessá-lo através da URL http://localhost:8080/public/teste.txt. Porém ainda falta um pedaço do quebra-cabeças, pois não queremos ficar acessando os arquivos de maneira manual, ou seja, digitando a URL no navegador. Queremos que o arquivo seja disponibilizado para acesso através de um link na página HTML.

Para isso, precisamos de mais 2 passos. O primeiro é guardar o caminho 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.

1
private String image;
2
3
public String getImage() {
4
return image;
5
}
6
7
public void setImage(String image) {
8
this.image = image;
9
}

O próximo passo é alterar o método store da classe ProductController para que ele guarde o caminho do arquivo no banco de dados:

1
if(!file.isEmpty()) {
2
String imageName = fileStorageService.store(file);
3
product.setImage(imageName);
4
}

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 retornama 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 o arquivo. Para isso, será necessário incluir uma nova tag HTML img. Nela, dentro do atributo src vamos usar a expressão ${product.image} que vai recuperar o valor do atributo image do produto e concatenar com a URL /public/ para formar a URL completa do arquivo.

1
<img th:src="@{/public/{image}(image=${product.image})}" />

Teste o upload de arquivos novamente e você deve perceber que o arquivo é salvo no diretório ./src/main/resources/static/public/ e o caminho do arquivo é salvo no banco de dados. Além disso, o arquivo é disponibilizado para acesso através da URL /public 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