CodeGym /Cours /Spring REST & MVC /Multipart dans Spring MVC : fichiers et parties

Multipart dans Spring MVC : fichiers et parties

Spring REST & MVC
Niveau 26, Leçon 1
Disponible

1. Multipart dans Spring MVC : binding par parties

Le modèle multipart est déjà fixé : une requête, à l'intérieur plusieurs parts, le fichier et les métadonnées ont des rôles différents. Il est maintenant important de comprendre comment Spring MVC voit ces parties et pourquoi un endpoint d'upload ne nécessite aucun « mode magique » séparé.

Lorsque vous voyez pour la première fois un contrôleur avec MultipartFile, il est facile de penser : « C'est un mode spécial de Spring où les lois de la physique sont différentes ». En réalité, Spring fait exactement la même chose qu'avec @RequestBody : il extrait les données d'entrée de la requête et les injecte dans les arguments de la méthode. Seulement, dans un scénario multipart, la source de données n'est pas unique (tout le body), mais plusieurs parties, chacune ayant un nom et son propre contenu.

Il est utile de garder en tête une image technique simple : le conteneur de servlets (Tomcat/Jetty, etc.) sait parser une requête multipart en parties. Spring MVC connecte une couche qui transforme ces parties en objets pratiques, puis le mécanisme standard de résolution d'arguments du contrôleur (« argument resolution ») fait son travail. Si vous avez déjà compris comment Spring sélectionne @PathVariable, @RequestParam et @RequestBody, alors multipart est une continuation de cette même idée, pas un univers différent.

Voici un schéma à un niveau « suffisant pour ne pas avoir peur ». Nous ne plongeons pas dans les sources du framework, mais nous levons l'impression de « chamanisme ».

flowchart TD
    A["Requête HTTP : multipart/form-data"] --> B["Conteneur Servlet : parse les parts"]
    B --> C["Spring MultipartResolver : enveloppe la requête"]
    C --> D["DispatcherServlet + HandlerMapping"]
    D --> E["Méthode du contrôleur"]
    E --> F["@RequestPart -> extraire la part par nom"]
    F --> G["HttpMessageConverter -> DTO (si part JSON)"]
    E --> H["@RequestParam -> champ de formulaire / MultipartFile"]

L'idée principale : les parts sont des données d'entrée comme les paramètres de requête ou le body JSON. Simplement, elles ont une autre forme.

2. MultipartFile : une « clé USB » pratique pour le contrôleur

Lorsque vous écrivez un endpoint pour télécharger un fichier dans Spring MVC, dans 90 % des scénarios d'apprentissage (et de nombreux scénarios professionnels), vous commencez par MultipartFile. C'est un type Spring qui donne un accès pratique aux « données d'identité » principales du fichier : nom d'origine, Content-Type, taille, ainsi que les moyens de lire le contenu.

Il est pratique en tant que type au niveau de la couche web : vous pouvez rapidement comprendre ce que le client a envoyé et préparer les données pour un traitement ultérieur. Il est important de ne pas confondre « pratique dans le contrôleur » avec « je veux le trimballer dans toute l'application » — mais nous parlerons des limites des couches avec précaution, sans transformer la leçon en réunion d'architecture.

Un mini-exemple de la méthode d'upload la plus simple, où nous n'acceptons qu'un fichier, sans métadonnées :

import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.multipart.MultipartFile;

@PostMapping(
        path = "/demo/upload",
        consumes = MediaType.MULTIPART_FORM_DATA_VALUE // Important : attend multipart/form-data
)
void upload(@RequestParam("file") MultipartFile file) { // "file" — nom de la partie/champ du formulaire
    // À ce niveau, nous effectuons généralement une vérification de base (fichier vide, type, etc.)
}

Oui, ici c'est @RequestParam, pas @RequestPart. Et c'est normal : pour « un fichier sans structure complexe », cette variante est tout à fait fonctionnelle et lisible.

Pour ressentir en quoi MultipartFile est utile, il suffit de regarder les méthodes typiques :

String originalName = file.getOriginalFilename(); // Nom communiqué par le client (pas «vérité absolue»)
String contentType = file.getContentType();       // Content-Type de la partie (peut être null)
long size = file.getSize();                       // Taille en octets
boolean empty = file.isEmpty();                   // Vérification rapide : a-t-on envoyé quelque chose ou pas

Dans le code réel, vous regardez presque toujours au moins isEmpty() et contentType, car un fichier vide et un fichier de type inattendu sont le moyen le plus rapide de transformer votre API en stockage de fichiers gratuit pour tout Internet (et Internet, vous savez, aime les choses gratuites).

Il est important de se rappeler un « détail » qui devient ensuite un gros problème. file.getBytes() semble simple et pratique, mais il lit le fichier entièrement en mémoire. Dans les exemples pédagogiques, c'est acceptable, mais le cerveau doit marquer en rouge : « les bytes, c'est potentiellement beaucoup ». Même si vous n'optimisez pas tout maintenant, vous comprenez au moins pourquoi MultipartFile a aussi getInputStream().

3. Part : le niveau de l'API Servlet

Si MultipartFile est une « enveloppe pratique de Spring », alors Part est une interface de l'API Servlet. Dans Spring Boot 4 / Spring 7, ce sera jakarta.servlet.http.Part (important : pas javax.* comme dans les anciens tutoriels de l'époque des dinosaures et de Java 8).

Pourquoi connaître Part si on a MultipartFile ? Parce que parfois on veut travailler plus près du conteneur : obtenir les en-têtes de la partie, utiliser write(...), regarder les propriétés « brutes » de la part, etc. De plus, cela aide à comprendre : Spring n'a pas inventé le multipart de zéro — il a simplement enveloppé ce que le monde des servlets sait faire.

Un mini-exemple de la façon dont un contrôleur peut accepter un fichier sous forme de Part :

import jakarta.servlet.http.Part;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestPart;

@PostMapping(path = "/demo/upload")
void upload(@RequestPart("file") Part filePart) { // "file" — nom de la multipart-partie
    String name = filePart.getSubmittedFileName(); // Nom original fourni par le client
    long size = filePart.getSize();                // Taille de la partie (octets)

    // Part donne un accès plus «brut» : en-têtes, write(...), input stream, etc.
}

Du point de vue de la compréhension de Spring MVC, le point important est le suivant : Part est également lié au nom de la partie ("file"), comme MultipartFile. La différence n'est pas dans la façon de « trouver la partie », mais dans ce que vous obtenez en sortie.

Pour rendre la comparaison tangible, voici un petit tableau. Il ne prétend pas être un guide complet, mais il aide à choisir l'outil sans tirer à la courte paille.

Ce qu'on compare MultipartFile Part
D'où provient le type Spring (org.springframework.web.multipart) API Servlet (jakarta.servlet.http)
Pour qui est-ce plus pratique Pour un contrôleur applicatif Pour un accès plus «brut» à la partie
Nom de la partie @RequestParam("file") ou @RequestPart("file") plutôt @RequestPart("file")
Taille / type / nom méthodes pratiques aussi présents, mais l'interface est un peu plus «technique»
Modèle mental «fichier en tant qu'objet Spring» «partie d'une requête multipart en tant qu'objet conteneur»

Si vous faites une API REST classique avec Spring MVC, commencer par MultipartFile est presque toujours plus simple. Il faut connaître Part au moins pour ne pas paniquer lorsqu'il apparaît dans du code tiers ou dans la documentation.

4. @RequestPart : lier une partie au lieu du body

La confusion la plus fréquente chez les débutants dans les endpoints d'upload est celle-ci : « Puisque les métadonnées sont du JSON, j'ai besoin de @RequestBody ». L'intuition est compréhensible, mais le multipart est organisé différemment : tout le body de la requête est un conteneur multipart, et le JSON se trouve à l'intérieur de l'une des parties. Donc @RequestBody ne « trouve » pas votre DTO comme vous l'attendez.

Et c'est ici qu'apparaît @RequestPart. Il signifie littéralement : « Prends une partie spécifique de la requête multipart par son nom et lie-la à l'argument ». En ce sens, @RequestPart est un « @RequestBody, mais pour une partie individuelle ».

Le bonus le plus important de @RequestPart est qu'il permet d'appliquer à la partie les mêmes mécanismes qu'au body : conversion via HttpMessageConverter et Jackson (si la partie est en JSON), plus la validation via @Valid, si vous liez la partie à un DTO. Cela rend le contrat lisible : vous voyez directement dans la signature de la méthode qu'il y a un fichier binaire et une partie JSON structurée pour les métadonnées.

Regardez ce rythme « correct » typique :

import jakarta.validation.Valid;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestPart;
import org.springframework.web.multipart.MultipartFile;

@PostMapping(path = "/demo/upload")
void upload(
        @RequestPart("file") MultipartFile file, // Partie binaire avec le fichier
        @Valid @RequestPart("metadata") AttachmentUploadMetadataRequest metadata // Partie JSON, qui peut être validée
) {
    // Ici on peut directement se fier au fait que metadata a été parsé et que @Valid a déjà fonctionné
}

Notez deux points.

Premièrement : les noms des parties "file" et "metadata" font partie du contrat public, tout comme le chemin /api/v1/.... Changer le nom d'une part, c'est presque comme changer le nom d'un champ dans du JSON ou modifier une URI : ça fera mal aux clients.

Deuxièmement : @Valid fonctionne comme d'habitude. Si AttachmentUploadMetadataRequest ne passe pas la Bean Validation, ce n'est pas « quelque part au fond du service », mais à la frontière de l'API, comme nous l'avions convenu précédemment.

5. @RequestParam et @RequestPart : les limites

En pratique, vous aurez vite une question : « Si @RequestPart est aussi génial, pourquoi @RequestParam existe-t-il encore dans les méthodes multipart ? ». La réponse est assez pragmatique : @RequestParam est parfait pour les champs de formulaire « plats » (chaînes, nombres) et pour le fichier lui-même dans un scénario simple. Il dit en quelque sorte : « Donne-moi la valeur du paramètre/champ de formulaire avec ce nom », et pour un fichier, ce mécanisme fonctionne aussi.

Mais dès que vous voulez accepter des métadonnées structurées (JSON), @RequestPart l'emporte généralement, car il inclut « officiellement » la conversion de message de la partie en DTO. Cela rend le code plus simple : vous n'analysez pas le JSON manuellement, vous n'écrivez pas new ObjectMapper(...) dans le contrôleur (si vous faites cela, votre futur @ControllerAdvice pleure), et vous ne perdez pas la validation automatique.

Comparons avec des mini-exemples.

Si vous n'avez besoin que d'un fichier, @RequestParam semble tout à fait normal et ne soulève pas de questions pour le lecteur :

import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.multipart.MultipartFile;

void uploadOnlyFile(@RequestParam("file") MultipartFile file) {
    // Scénario le plus simple : un seul fichier sans structure «intelligente»
}

Si vous avez besoin d'un fichier et d'un champ texte simple (par exemple description sans JSON), on peut vivre comme ça aussi :

import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.multipart.MultipartFile;

void uploadWithDescription(
        @RequestParam("file") MultipartFile file,          // Fichier
        @RequestParam("description") String description    // Champ de formulaire simple
) {
    // Ici description arrive sous forme de chaîne, sans conversion JSON
}

Mais si metadata est un objet JSON (aujourd'hui c'est une « description », demain il pourrait y avoir un peu plus de champs), alors transformer le JSON en chaîne et le parser manuellement est un ticket quasi garanti pour le club « pourquoi mon ProblemDetail ne fonctionne pas pour les erreurs JSON ».

Avec @RequestPart, l'intention se lit mieux :

import jakarta.validation.Valid;
import org.springframework.web.bind.annotation.RequestPart;
import org.springframework.web.multipart.MultipartFile;

void uploadWithMetadata(
        @RequestPart("file") MultipartFile file, // Fichier
        @Valid @RequestPart("metadata") AttachmentUploadMetadataRequest metadata // Partie JSON -> DTO
) {
    // La validation du DTO se produit à la frontière de la méthode, comme avec @RequestBody
}

C'est la limite pratique : @RequestParam est bon pour le simple, @RequestPart quand la partie doit être traitée comme un mini-corps avec sa propre conversion (par exemple JSON).

6. Partie JSON : Jackson et @Valid dans @RequestPart

Maintenant, un détail important sans lequel tout pourrait sembler « ça ne se mappe pas dans le DTO pour une raison ». Lorsque vous dites à Spring : @RequestPart("metadata") AttachmentUploadMetadataRequest metadata, Spring doit comprendre le format de cette partie. Autrement dit, la partie metadata doit avoir un Content-Type: application/json. Alors le même pipeline Jackson que pour @RequestBody est activé.

Cela s'inscrit directement dans le modèle HttpMessageConverter que nous avons déjà étudié : une partie d'une requête multipart devient un « petit corps de message », et le convertisseur est sélectionné en fonction du Content-Type de la partie et du type Java de l'argument.

Le DTO lui-même peut être très simple. Dans notre projet, les métadonnées d'une pièce jointe sont volontairement modestes, afin que le client ne gère pas les champs gérés par le serveur (taille, type, heure de téléchargement, etc.). Pour la démonstration du binding, un seul champ nous suffit :

import jakarta.validation.constraints.Size;

public record AttachmentUploadMetadataRequest(
        @Size(max = 255) String description // Validation simple à la frontière de l'API
) {
}

Et maintenant, un point pratique important pour le client (Postman/curl/IDEA .http). Si vous envoyez metadata simplement comme une chaîne sans préciser application/json, Spring pourrait ne pas appliquer la conversion JSON comme vous l'attendez. Vous obtiendrez alors une erreur de conversion avant même la logique métier. C'est normal : le contrat de l'endpoint multipart inclut non seulement les noms des parties, mais aussi le type de chaque partie.

Exemple de ce qu'on envoie généralement (simplifié pour comprendre l'idée) via curl :

# Téléverser un fichier et des métadonnées JSON dans une seule requête multipart
curl -X POST "http://localhost:8080/demo/upload" \
  -F 'file=@sample-files/readme.txt;type=text/plain' \
  -F 'metadata={"description":"Petite note"};type=application/json'
# Important : type=application/json indique au serveur que metadata doit être lu comme du JSON, pas comme une «simple chaîne»

Ici, type=application/json est le signal clé : metadata est du JSON, et doit être lu comme tel, pas comme une chaîne aléatoire.

Côté serveur, si le JSON dans metadata est malformé (malformed JSON), l'erreur se produit lors de la désérialisation, c'est-à-dire avant même la validation. C'est la même classe de problèmes que « malformed JSON dans un @RequestBody normal », mais à l'intérieur d'une partie. Bonne nouvelle : votre gestion globale des erreurs sait déjà vivre dans le monde des « les erreurs surviennent avant la logique métier », donc vous ne devez pas faire de try/catch dans le contrôleur.

7. Signature de la méthode d'upload dans l'API Task Tracker

Pour que cela ne reste pas une théorie sur « un upload abstrait », atterrissons dans notre projet. Selon la carte des ressources, attachments est une sous-ressource de la tâche. Aujourd'hui, nous ne construisons pas un service de stockage complet, nous n'inventons pas de clés de stockage et nous ne discutons pas où et comment le fichier est stocké sur le disque. Notre objectif est plus simple : apprendre à accepter une requête multipart de manière à ce que le contrat soit lisible, validé et s'intègre dans le modèle global de gestion des erreurs.

Voici la signature minimale d'une méthode d'upload dans le style de notre projet (brève, sans logique superflue). Imaginez que cette méthode se trouve dans AttachmentController :

import jakarta.validation.Valid;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestPart;
import org.springframework.web.multipart.MultipartFile;

@PostMapping(
        path = "/api/v1/tasks/{taskId}/attachments",
        consumes = MediaType.MULTIPART_FORM_DATA_VALUE // Le contrôleur attend explicitement multipart/form-data
)
void upload(
        @PathVariable String taskId, // ID de la tâche parente dans l'URL
        @RequestPart("file") MultipartFile file, // Fichier comme partie distincte
        @Valid @RequestPart("metadata") AttachmentUploadMetadataRequest metadata // Partie JSON -> DTO + validation
) {
    // Ici généralement : vérification du fichier (vide/type), puis délégation à la couche service
}

Cette signature résout déjà la moitié des problèmes futurs, car le contrat est visible : il y a taskId dans le path (on adresse la ressource parente), une partie file et une partie metadata. Si le client mélange les noms des parties ou envoie un autre type de contenu, Spring ne pourra pas honnêtement lier la requête au contrat et retournera une erreur. Et c'est bien : la discipline d'API commence par le fait qu'une requête incorrecte ne se transforme pas en « comportement étrange ».

Pour réduire le risque de fautes de frappe dans les noms de parties (et c'est une source de douleur très populaire : "metdata" au lieu de "metadata"), il est parfois utile de sortir les noms des parties dans des constantes. Ce n'est pas une « architecture pour l'architecture » obligatoire, mais une simple protection contre le facteur humain :

public final class AttachmentParts {
    public static final String FILE = "file";         // Nom de la partie avec le fichier binaire
    public static final String METADATA = "metadata"; // Nom de la partie avec les métadonnées JSON

    private AttachmentParts() {
        // Protection contre la création d'instances : classe utilitaire avec des constantes
    }
}

Alors dans le contrôleur, on peut écrire @RequestPart(AttachmentParts.FILE) — et l'IDE vous aidera au moins à ne pas vous tromper.

Maintenant, nous avons sur la table toutes les primitives nécessaires : taskId dans le path, file et metadata dans les parties, consumes = multipart/form-data, une partie JSON avec @Valid. C'est suffisant pour construire non pas une méthode de démonstration, mais le POST /api/v1/tasks/{taskId}/attachments canonique pour le projet.

8. Erreurs typiques lors du travail avec MultipartFile, Part et @RequestPart

Erreur n°1 : essayer d'utiliser @RequestBody dans un endpoint multipart.
Elle naît généralement de l'expérience correcte « le JSON se lit via @RequestBody », mais une requête multipart est un conteneur de parties, pas un seul document JSON. Pour les métadonnées JSON, utilisez @RequestPart("metadata"), alors Spring appliquera la conversion à la partie spécifique et pourra valider le DTO.

Erreur n°2 : mélanger @RequestParam et le DTO JSON de sorte que tout se transforme en parsing manuel.
Parfois on fait @RequestParam("metadata") String metadataJson, puis on le parse manuellement, et à ce moment vous perdez la moitié des avantages de Spring MVC : erreurs de désérialisation automatiques, flux de gestion des erreurs unifié, @Valid et la lisibilité normale de la signature. Si la partie est structurée, il vaut mieux faire directement @RequestPart("metadata") AttachmentUploadMetadataRequest.

Erreur n°3 : oublier que la partie metadata doit avoir Content-Type: application/json.
Le serveur peut être écrit parfaitement, mais le client a envoyé metadata comme une « simple chaîne » (ou Postman n'a pas défini le type), et soudainement le DTO ne se mappe pas ou se mappe étrangement. Dans le contrat multipart, non seulement le nom de la partie est important, mais aussi son format. Et oui, c'est un cas où un seul en-tête décide réellement du sort de la requête.

Erreur n°4 : choisir Part « par défaut » parce qu'il semble plus sérieux.
Part n'est pas une « version améliorée de MultipartFile ». C'est juste un niveau d'abstraction différent. Si vous n'avez pas besoin de quelque chose de spécifique du monde servlet, MultipartFile est généralement plus simple pour le code applicatif et plus compréhensible pour l'équipe. Complexifier pour l'impression « enterprise » finit presque toujours mal, car votre cerveau est occupé par l'outil, pas par la tâche.

Erreur n°5 : considérer que getOriginalFilename() est un identifiant fiable.
Le nom original du fichier est ce que le client a dit. Il peut être null, contenir des caractères étranges, être identique pour différents fichiers. Pour le niveau contractuel, on peut l'utiliser comme « nom humain » (par exemple, l'afficher à l'utilisateur), mais on ne doit pas le percevoir comme une clé unique ou comme un chemin sûr sur le disque.

1
Mission
Spring REST & MVC, niveau 26, leçon 1
Bloqué
Un fichier avec `@RequestParam`
Un fichier avec `@RequestParam`
1
Mission
Spring REST & MVC, niveau 26, leçon 1
Bloqué
Fichier en tant que `Part`, metadata en tant que DTO
Fichier en tant que `Part`, metadata en tant que DTO
Commentaires
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION