Upload de Arquivos com Spring Boot

O upload de arquivos é uma funcionalidade muito comum em aplicações web modernas, seja para permitir que usuários enviem imagens, documentos ou qualquer outro tipo de arquivo. O Spring Boot, com sua simplicidade e poder, oferece uma maneira eficiente e segura de implementar essa funcionalidade.

Neste artigo, vamos abordar como configurar um endpoint para upload de arquivos usando Spring Boot, armazenar os arquivos no servidor e como retornar este arquivo para o cliente.

Antes de mais nada, precisamos criar um projeto Spring Boot. Para isso, utilizaremos o Spring Initializr. No nosso exemplo, poucas dependências são necessárias: apenas o Spring Web, o Thymeleaf e, opcionalmente, o Spring Boot DevTools, que pode facilitar o processo de desenvolvimento.

<dependency>
    <groupId>org.springframework.boot</groupId>
	<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-devtools</artifactId>
    <scope>runtime</scope>
    <optional>true</optional>
</dependency>

O Spring Boot já possui suporte nativo para upload de arquivos multipart. No entanto, é fundamental definir o tamanho máximo permitido para os arquivos. Essa configuração é feita no arquivo application.properties, adicionando as seguintes propriedades.

spring.servlet.multipart.max-file-size=20MB
spring.servlet.multipart.max-request-size=20MB

Implementando o upload de arquivos

Agora, vamos criar um controller com um endpoint responsável por exibir um formulário HTML.

@Controller
public class FileController {

    @GetMapping("/file-form")
    public String fileForm(){

        return "file-form";
    }

}
<!DOCTYPE html>
<html lang="pt-br" xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Upload de Arquivos</title>
</head>
<body>

    <h1>Upload de Arquivo</h1>

    <form method="POST" th:action="@{/upload}" enctype="multipart/form-data">

        <div>
            <label for="arquivo">Arquivo</label>
            <input type="file" id="arquivo" name="arquivo">
        </div>
        <button type="submit">Enviar</button>

    </form>
    
</body>
</html>

Nenhuma novidade até aqui, mas é importante lembrar que o formulário deve incluir o atributo enctype="multipart/form-data", caso contrário, o arquivo não será enviado.

Em seguida, vamos criar o endpoint responsável por receber o arquivo enviado pelo formulário.

@Value("${upload.dir}")
private String UPLOAD_DIR;

@PostMapping("/upload")
public String upload(
    @RequestParam("arquivo") MultipartFile arquivo,
    Model model
){

    if(arquivo.isEmpty()){
        model.addAttribute("mensagem", "Arquivo não enviado.");
        return "erro";
    }

    try {
        Path uploadPath = Paths.get(this.UPLOAD_DIR);
        if (Files.notExists(uploadPath)) {
            Files.createDirectories(uploadPath);
        }

        File dst = new File(uploadPath+"/"+arquivo.getOriginalFilename());
        
        arquivo.transferTo(dst);

    } catch (IOException e) {
        model.addAttribute("mensagem", e.getMessage());
        return "erro";
    }
    return "sucesso";
}

Nas linhas 1 e 2 do código, definimos o atributo UPLOAD_DIR, que recebe o valor da propriedade upload.dir configurada no arquivo application.properties. É nela que configuramos o caminho da pasta ondeiremos armazenar os arquivos enviados.

Na linha 5, definimos o método que trata a requisição POST responsável pelo envio do arquivo. Como parâmetro, ele recebe um objeto MultipartFile, anotado com @RequestParam("arquivo"), onde ‘arquivo’ corresponde ao valor do atributo name do campo de upload no formulário. Esse objeto representa o arquivo enviado e é por meio dele que realizamos sua manipulação no backend.

Na linha 10, utilizamos o método isEmpty() da classe MultipartFile para verificar se um arquivo foi enviado. Caso não tenha sido, o método retornará true e, nesse caso, será exibida uma página de erro

Na linha 16, obtemos o caminho do diretório de upload e o convertemos em um objeto Path. Em seguida, na linha 17, utilizamos o método Files.notExists() para verificar se esse diretório não existe. Caso não exista, ele é criado com o método Files.createDirectories().

Na linha 21, criamos um objeto File apontando para o local onde o arquivo será salvo. Utilizamos o método getOriginalFilename() do objeto MultipartFile para obter o nome original do arquivo enviado pelo formulário. No entanto, uma prática mais recomendada é gerar um nome único — seja de forma aleatória, um UUID, seguindo algum padrão, ou o id do registro associado — para evitar problemas de sobrescrita de arquivos no servidor.

Na linha 23, utilizamos o método transferTo() do objeto MultipartFile para salvar o arquivo no local definido anteriormente na linha 21.

Lendo o arquivo enviado

Em alguns casos, não é necessário salvar o arquivo em um diretório, mas sim processá-lo diretamente. Por exemplo, quando é enviado um arquivo CSV contendo dados que precisam ser cadastrados na aplicação. A seguir, veremos um exemplo desse cenário.

@Autowired
private PessoaService pessoaService;

@PostMapping("/upload-csv")
public String uploadCsv(
    @RequestParam("arquivo") MultipartFile arquivo,
    Model model
){

    if(arquivo.isEmpty()){
        model.addAttribute("mensagem", "Arquivo não enviado.");
        return "erro";
    }

    try {
        Scanner scan = new Scanner(arquivo.getInputStream());

        while(scan.hasNextLine()){
            String[] values = scan.nextLine().split(";");
            String nome = values[0].trim();
            String sobrenome = values[1].trim();
            this.pessoaService.salvar( new Pessoa(nome, sobrenome) );    
        }
        scan.close();
    } catch (IOException e) {
        model.addAttribute("mensagem", e.getMessage());
        return "erro";
    }
    return "sucesso";
}

O código acima é bastante semelhante ao exemplo anterior, com a diferença de que, na linha 16, obtemos o InputStream do objeto MultipartFile utilizando o método getInputStream(). Esse stream é então passado para o Scanner, que fará a leitura do conteúdo do arquivo.

Com o objeto Scanner criado a partir do InputStream do arquivo enviado, utilizamos um laço while (linha 18) para percorrer todas as linhas do arquivo. Para cada linha lida com nextLine(), realizamos a separação dos dados usando o método split() com ";" como delimitador. Em seguida, atribuimos cada valor às variáveis correspondentes (linhas 20 e 21) e, a partir desses dados, instanciamos um objeto Pessoa, que é então enviado para o método salvar() do service.

Realizando o download de um arquivo

Agora que já aprendemos a salvar um arquivo enviado, vamos ver como disponibilizá-lo. A seguir, apresentamos o método no controller responsável por essa funcionalidade.

@GetMapping("/files/**")
public ResponseEntity<Resource> getFile(
    HttpServletRequest request
){
    String filePath = request.getRequestURI().replaceAll("^/files", "");
    Path path = Paths.get(this.UPLOAD_DIR + "/" + filePath);
        
    try {
        Resource resource = new UrlResource(path.toUri());
        String contentType = Files.probeContentType(path);
        return ResponseEntity
            .ok()
            .header(HttpHeaders.CONTENT_TYPE, contentType)
            .body(resource);
     } catch (IOException e) {
        e.printStackTrace();
        return ResponseEntity.notFound().build();
    }
}

No exemplo acima, criamos um método no controller que mapeia o endpoint /files/** para requisições GET e retorna um ResponseEntity<Resource>. Isso significa que qualquer requisição GET que comece com /files/ será tratada por esse método. A lógica é que o caminho do arquivo dentro da pasta de upload seja informado na URL após /files/. Por exemplo, para acessar o arquivo <pasta-upload>/foto.png, utilizamos a URL /files/foto.png. Da mesma forma, para acessar o arquivo <pasta-upload>/images/foto2.jpg, usamos a URL /files/images/foto2.jpg.

Como o caminho do arquivo na URL pode conter barras, não podemos utilizar o mapeamento com @GetMapping("/files/{path}"). Isso ocorre porque, em casos como /files/images/foto2.png, a barra após images é interpretada como delimitador de parâmetros, o que faz com que a URL não corresponda ao padrão definido no @GetMapping, resultando em um erro 404.

Para resolver esse problema, utilizamos o mapeamento @GetMapping("/files/**"), que captura qualquer requisição que comece com /files/. O caminho do arquivo, nesse caso, precisa ser extraído manualmente. Para isso, o método getFile recebe como parâmetro o HttpServletRequest, que oferece diversas informações sobre a requisição. Na linha 5, obtemos a URI completa utilizando o método getRequestURI() e, em seguida, removemos o prefixo /files usando replaceAll(), ficando apenas com o caminho relativo do arquivo dentro da pasta de upload.

Com o caminho relativo obtido, concatenamos esse valor ao diretório de upload definido no atributo this.UPLOAD_DIR nos exemplos anteriores. Em seguida, na linha 6, criamos um objeto Path que representa o caminho completo do arquivo no sistema.

Na linha 9, criamos um objeto Resource a partir do Path do arquivo. Em seguida, na linha 10, obtemos o mime-type do arquivo para definir corretamente o tipo de conteúdo na resposta.

Na linha 11, criamos e retornamos um objeto ResponseEntity utilizando ok(), definindo o header Content-Type com o mime-type obtido do arquivo e, no corpo da resposta (body), o Resource que representa o arquivo.

Validando o Upload de arquivos

Até aqui, partimos do pressuposto de que o usuário está enviando um arquivo com o tipo e tamanho corretos. No entanto, sabemos que essa é uma suposição arriscada — validar as entradas do usuário é sempre fundamental. Como já vimos em um artigo anterior, a biblioteca Bean Validation é uma excelente opção para implementar essas validações. A seguir, veremos um exemplo prático de como aplicá-la no upload de arquivos.

Primeiro, vamos criar um formulário contendo um campo para o nome e outro para o envio de uma foto.

<form method="POST" th:action="@{/upload-validation}" th:object="${formPessoa}" enctype="multipart/form-data">

    <div>
        <label for="nome">Nome</label>
        <input type="text" id="nome" name="nome">
        <div th:if="${ #fields.hasErrors('nome') }" class="error" th:errors="*{nome}"></div>
    </div>
    <div>
        <label for="arquivo">Foto</label>
        <input type="file" id="foto" name="foto">
        <div th:if="${#fields.hasErrors('foto')}" class="error" th:errors="*{foto}"></div>
    </div>
    <button type="submit">Enviar</button>
</form>

Perceba que adicionamos uma div logo abaixo dos campos do formulário para exibir as mensagens de erro.

Agora, vamos criar o método no controller responsável por exibir o formulário.

@GetMapping("/file-form-validation")
public String fileFormValidation(Model model){
    model.addAttribute("formPessoa", new FormPessoa(null,null));
    return "file-form-validation";
}

No código acima, criamos um método que mapeia o endpoint /file-form-validation e retorna a página com o formulário. Antes disso, adicionamos ao Model um objeto FormPessoa vazio, que será utilizado para o binding dos dados no formulário. Segue o record FormPessoa.

public record FormPessoa(

    @NotBlank
    String nome,

    @FileType(types = {"image/png","image/jpeg"})
    MultipartFile foto
) {

}

Observe as linhas 6 e 7. O campo MultipartFile foto está anotado com @FileType, uma anotação personalizada que ainda vamos criar. Ela possui um atributo types, que define os mime-types dos arquivos permitidos.

@Target(ElementType.FIELD)
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = FileTypeValidator.class)
public @interface FileType {

    String message() default "O Arquivo dever ser dos seguintes tipos: {types}";

    String[] types();

    Class<?>[] groups() default {};

    Class<? extends Payload>[] payload() default {}; 

}

O código acima define a anotação personalizada @FileType. Ela é configurada com as anotações @Target e @Retention, que indicam que será aplicada em campos e estará disponível em tempo de execução. A anotação @Constraint informa que a validação será realizada pela classe FileTypeValidator. Além disso, definimos os atributos: message, que permite personalizar a mensagem de erro; types, que recebe os mime-types permitidos; e os atributos padrão groups e payload, exigidos pela especificação da Bean Validation.

public class FileTypeValidator implements ConstraintValidator<FileType, MultipartFile>{

    private String[] types;

    @Override
    public void initialize(FileType constraintAnnotation) {
        this.types = constraintAnnotation.types();
    }

    @Override
    public boolean isValid(MultipartFile value, ConstraintValidatorContext context) {
        
        return  Arrays.asList(this.types).contains(value.getContentType());

    }
}

A classe FileTypeValidator implementa a interface ConstraintValidator, que conecta a anotação personalizada @FileType ao tipo MultipartFile, que será validado. No método initialize(), capturamos os mime-types permitidos informados na anotação. Já o método isValid() recebe o campo anotado — no caso, o arquivo enviado — e realiza a validação. Na linha 13, obtemos o Content-Type do arquivo utilizando o método getContentType(). Em seguida, convertemos o array de tipos permitidos em uma lista com Arrays.asList() e usamos o método contains() para verificar se o tipo do arquivo está entre os mime-types válidos.

Também podemos criar uma anotação personalizada para validar se o tamanho do arquivo está dentro do limite permitido. Veja como essa anotação ficaria.

@Target(ElementType.FIELD)
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = FileSizeValidator.class)
public @interface FileSize {

    String message() default "O Arquivo dever ter no máximo: {size} bytes";

    int size();

    Class<?>[] groups() default {};

    Class<? extends Payload>[] payload() default {}; 

}

Acima a anotação para validar o tamanho do arquivo. Ela é semelhante a anterior com a diferença de nela temos o atributo size que estipula o tamanho máximo do arquivo em bytes. Segue a implementação da validação na classe FileSizeValidator.

public class FileSizeValidator implements ConstraintValidator<FileSize, MultipartFile> {

    private int size;

    @Override
    public void initialize(FileSize constraintAnnotation) {
        this.size = constraintAnnotation.size();
    }

    @Override
    public boolean isValid(MultipartFile value, ConstraintValidatorContext context) {
        
        return value.getSize() <= this.size;
    }

}

O funcionamento dessa classe também é bastante simples. No método initialize(), recuperamos o valor do atributo size definido na anotação. Já no método isValid(), obtemos o tamanho do arquivo enviado utilizando o método getSize() do MultipartFile e comparamos esse valor com o limite configurado na anotação. Feito isso, basta aplicar a anotação no campo que desejamos validar.

@FileType(types = {"image/png","image/jpeg"})
@FileSize(size= 100 * 1024 ) // 100kb
MultipartFile foto

O Spring Boot torna o processo de upload de arquivos muito mais simples, oferecendo recursos que facilitam sua manipulação de forma prática e eficiente. Espero que esta introdução tenha sido útil e contribuído para seu aprendizado. T++!