Dans l’article précédent, MultipartReader garde la mémoire sous 2 Mio pour un upload de 1 Gio. Reste une question : comment ça marche réellement sous le capot ?

La réponse tient en une phrase :

NextPart() ne lit que les en-têtes, et c’est io.Copy qui tire les octets du fichier, un morceau à la fois.

📦 Ce que contient le corps de la requête

Le client envoie un seul flux d’octets.
Le fichier est au milieu, encadré par des marqueurs.

Corps multipart : séparateur, en-têtes, octets du fichier, séparateur de fin --BOUNDARY Content-Disposition + ligne vide octets du fichier (1 Gio) --BOUNDARY-- les octets arrivent peu à peu sur le socket
Le fichier n'est qu'une tranche du flux, délimitée par une ligne séparatrice (`--BOUNDARY`) que le client choisit et annonce dans le Content-Type.

🪟 NextPart renvoie une fenêtre, pas des données

NextPart() consomme uniquement la zone bleue : le séparateur et les en-têtes, jusqu’à la ligne vide. Le Part renvoyé ne contient aucun octet du fichier. C’est un io.Reader posé sur le début des données, qui s’arrête tout seul au prochain séparateur.

part, err := reader.NextPart() // en-têtes lus, fichier pas encore touché
if err != nil {
	return err
}
_, err = io.Copy(dst, part) // les octets du fichier passent ici

🔁 Qui tire sur quoi

Rien n’est lu tant que io.Copy ne demande rien : c’est un mécanisme de tirage (pull). Chaque tour de boucle fait la même chose :

  1. io.Copy appelle part.Read(p) avec un tampon de 32 Ko ;
  2. le Part demande des octets à son bufio.Reader interne, qui lit le socket si son tampon est vide ;
  3. les octets remontent, io.Copy les écrit dans le fichier, puis retour en 1.
Les couches entre le socket et le fichier : io.Copy tire sur la chaîne, les octets remontent socket MaxBytes plafond bufio ~4 Ko Part cherche séparateur io.Copy fichier 1. Read(p) tire sur toute la chaîne 2. les octets remontent et sont écrits, puis retour en 1
Si le disque est lent, io.Copy appelle Read moins souvent : le serveur lit moins vite et TCP freine le client.

C’est cette boucle qui explique les 1,8 Mio de pic mémoire.

🔚 Comment le Part sait que le fichier est fini ❓

Le Part ne connaît pas la taille du fichier : il avance en cherchant le séparateur --BOUNDARY. À chaque Read, trois cas :

  • Séparateur absent du tampon : il rend les octets lus, mais garde en réserve les derniers. Le séparateur peut arriver coupé en deux entre deux lectures (…--BOU puis NDARY), et ces octets pourraient en être le début.
  • Séparateur trouvé : il rend les octets qui le précèdent. Au Read suivant, il répond io.EOF : le fichier est fini.
  • Tampon vide : il attend que le socket livre la suite, puis recommence.

🔂 Et la boucle sur NextPart ?

Une boucle for sur NextPart() existe, mais elle porte sur les parts, pas sur les octets : c’est le cas d’un formulaire avec plusieurs champs.

for {
	part, err := reader.NextPart()
	if errors.Is(err, io.EOF) {
		break
	}
	if err != nil {
		return err
	}
	if part.FileName() != "" {
		return store(part)
	}
}

⚠️ Pièges

  • Les parts se lisent dans l’ordre : un second NextPart() saute ce qui reste du premier sans le lire, ces octets sont perdus.
  • Un champ texte placé après le fichier n’est lisible qu’une fois le fichier entièrement consommé.
  • Le streaming s’arrête où vous le coupez : stocker la part dans un []byte avant de l’écrire ramène le problème du ReadAll.

🔗 Liens