Créer en Go un téléchargeur reprenable à segments concurrents
Créez un exécutable de téléchargement unique qui conserve les segments terminés entre les exécutions et vérifie le fichier assemblé avant de remplacer la destination. Vous exécuterez aussi une origine HTTP locale prenant en charge les plages, avec des octets binaires connus, pour tester le téléchargement complet sans dépendre d’une URL tierce.
Pourquoi créer un téléchargeur de fichiers personnalisé ?
Quatre travailleurs récupèrent des segments indépendants. Les segments terminés survivent à une exécution interrompue, tandis que les segments partiels ne sont jamais considérés comme terminés. La destination n’est remplacée de manière atomique qu’une fois que le fichier complet correspond à l’empreinte attendue.
Comprendre les requêtes de plage HTTP et le contenu partiel
Un en-tête Range est une demande, pas une garantie. Chaque segment doit recevoir le statut 206, exactement le Content-Range demandé, le même ETag fort et exactement le nombre d’octets attendu. If-Range empêche de combiner silencieusement différentes versions de l’objet ; ici, une réponse 200 de repli est une erreur.
Sémantique des plages HTTP et des validateurs (RFC 9110)
Implémentation de base du téléchargement de fichiers en Go
Ces commandes ciblent Linux avec Bash, Go 1.26.8 dans votre PATH et sha256sum de GNU Coreutils.
Installez Go depuis les téléchargements officiels si nécessaire. Go 1.26 reste une
version prise en charge ; ce tutoriel a été testé avec la
1.26.8 et n’affirme rien quant aux chaînes d’outils plus anciennes. Les deux programmes n’utilisent
que la bibliothèque standard.
Collez ce bloc depuis un répertoire que vous contrôlez. Il n’entre dans le nouveau projet qu’une
fois l’initialisation réussie. Un répertoire range-downloader existant constitue une erreur ; choisissez
un autre nom ou inspectez-le au lieu de supprimer un projet existant pour recommencer la configuration.
(
mkdir -- range-downloader &&
cd -- range-downloader &&
GOENV=off GOWORK=off GOTOOLCHAIN=local GOFLAGS= go mod init range-downloader
) && cd -- range-downloader
Les paramètres limités à la commande contournent la configuration Go enregistrée et tout
espace de travail Go englobant, effacent les options de compilation héritées et
utilisent la chaîne d’outils installée. Ils ne modifient ni les paramètres de votre shell, ni le
go.mod ou le go.work du projet parent.
Si la configuration échoue après la création du répertoire, votre shell reste dans son répertoire
d’origine ; inspectez le nouveau répertoire avant de réessayer.
Ajouter la prise en charge de la reprise des téléchargements
Le chemin de sortie suffixé par « .parts » stocke l’URL, l’ETag, l’empreinte attendue, la longueur et la taille des segments. Une nouvelle exécution n’accepte cet état que si tous les champs correspondent. Elle réutilise les fichiers de segments terminés ayant la longueur attendue et vérifie le contenu assemblé par rapport à l’empreinte de confiance. En cas de non-correspondance, un nouveau chemin de sortie est nécessaire ; ne réutilisez pas un état corrompu.
Implémenter le téléchargement concurrent des segments
Un ensemble fixe de travailleurs consomme un canal de tâches non tamponné. Chaque réponse est transmise en flux, via un tampon de copie de 32 KiB, dans un fichier de segment temporaire. La première erreur d’un travailleur annule les autres requêtes, et le coordinateur attend chaque travailleur avant de rendre la main.
Ajouter une barre de progression avec mises à jour en temps réel
Cette version affiche le nombre d’octets pendant l’assemblage final au lieu d’ajouter une dépendance de barre de progression. Les octets téléchargés ne sont signalés comme vérifiés qu’une fois que l’empreinte finale correspond.
Gestion des erreurs et logique de nouvelle tentative
Les erreurs se propagent jusqu’à un code de sortie non nul du processus. Après un échec transitoire, relancez la même commande pour réutiliser les segments terminés. Il n’existe aucune boucle de nouvelle tentative automatique qui pourrait continuer à réessayer après un échec d’autorisation ou une représentation modifiée. Ctrl-C annule les requêtes réseau et laisse les segments terminés disponibles.
Optimiser les performances avec un pool de connexions
Un client HTTP partagé réutilise les connexions, avec quatre connexions inactives par hôte et un délai d’expiration de deux minutes par requête. Le nombre de travailleurs limite la concurrence réseau ; l’espace disque utilisé comprend les segments sauvegardés et une seconde copie complète pendant l’assemblage.
Exemple complet : créer un téléchargeur en ligne de commande
Enregistrez ce programme complet sous main.go. Obtenez l’empreinte SHA-256 auprès d’un éditeur
de confiance, indépendamment du téléchargement. Utilisez un répertoire parent que vous contrôlez ;
aucun autre processus ne doit modifier la sortie ni les segments sauvegardés.
package main
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"os"
"os/signal"
"path/filepath"
"strings"
"sync"
"time"
)
const chunkSize int64 = 4 << 20
type identity struct {
URL, ETag, SHA256 string
Size, ChunkSize int64
}
func probe(ctx context.Context, client *http.Client, url, digest string) (identity, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodHead, url, nil)
if err != nil {
return identity{}, err
}
req.Header.Set("Accept-Encoding", "identity")
resp, err := client.Do(req)
if err != nil {
return identity{}, err
}
defer resp.Body.Close()
tag := resp.Header.Get("ETag")
if resp.StatusCode != 200 || resp.ContentLength < 0 || resp.ContentLength > 1<<40 ||
len(tag) < 2 || !strings.HasPrefix(tag, "\"") || !strings.HasSuffix(tag, "\"") ||
resp.Header.Get("Content-Encoding") != "" {
return identity{}, errors.New("need a known size (at most 1 TiB), strong ETag and unencoded HEAD 200")
}
return identity{url, tag, digest, resp.ContentLength, chunkSize}, nil
}
func fetchChunk(ctx context.Context, client *http.Client, id identity, dir string, start int64) error {
end := min(start+id.ChunkSize, id.Size) - 1
name := filepath.Join(dir, fmt.Sprintf("%d.part", start))
if info, err := os.Lstat(name); err == nil {
if info.Mode().IsRegular() && info.Size() == end-start+1 {
return nil
}
return errors.New("invalid saved chunk; use a new output path")
} else if !errors.Is(err, os.ErrNotExist) {
return err
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, id.URL, nil)
if err != nil {
return err
}
req.Header.Set("Accept-Encoding", "identity")
req.Header.Set("Range", fmt.Sprintf("bytes=%d-%d", start, end))
req.Header.Set("If-Range", id.ETag)
resp, err := client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
expected := fmt.Sprintf("bytes %d-%d/%d", start, end, id.Size)
if resp.StatusCode != http.StatusPartialContent || resp.Header.Get("Content-Range") != expected ||
resp.Header.Get("ETag") != id.ETag || resp.Header.Get("Content-Encoding") != "" ||
(resp.ContentLength != -1 && resp.ContentLength != end-start+1) {
return errors.New("server rejected range or changed representation")
}
tmp, err := os.CreateTemp(dir, ".chunk-")
if err != nil {
return err
}
defer os.Remove(tmp.Name())
defer tmp.Close()
n, err := io.CopyBuffer(tmp, io.LimitReader(resp.Body, end-start+2), make([]byte, 32<<10))
if err != nil {
return err
}
if n != end-start+1 {
return errors.New("incorrect chunk length")
}
if err := tmp.Sync(); err != nil {
return err
}
if err := tmp.Close(); err != nil {
return err
}
if err := ctx.Err(); err != nil {
return err
}
return os.Rename(tmp.Name(), name)
}
func download(ctx context.Context, client *http.Client, url, output, digest string, workers int) error {
sum, err := hex.DecodeString(digest)
if err != nil || len(sum) != sha256.Size || workers < 1 || workers > 16 {
return errors.New("provide a SHA-256 hex digest and 1–16 workers")
}
digest = strings.ToLower(digest)
id, err := probe(ctx, client, url, digest)
if err != nil {
return err
}
dir := output + ".parts"
fresh := false
if err := os.Mkdir(dir, 0700); err == nil {
fresh = true
} else if !errors.Is(err, os.ErrExist) {
return err
}
info, err := os.Lstat(dir)
if err != nil {
return err
}
if !info.IsDir() || info.Mode().Perm()&0077 != 0 {
return errors.New("parts directory must be private")
}
lock := filepath.Join(dir, ".lock")
if err := os.Mkdir(lock, 0700); err != nil {
return errors.New("parts directory locked; another download may be active")
}
defer os.Remove(lock)
manifest := filepath.Join(dir, "identity.json")
if fresh {
data, err := json.Marshal(id)
if err != nil {
return err
}
if err := os.WriteFile(manifest, data, 0600); err != nil {
return err
}
} else {
data, err := os.ReadFile(manifest)
if err != nil {
return err
}
var saved identity
if err := json.Unmarshal(data, &saved); err != nil {
return err
}
if saved != id {
return errors.New("saved download identity changed; use a new output path")
}
}
ctx, cancel := context.WithCancel(ctx)
defer cancel()
var wg sync.WaitGroup
var once sync.Once
var firstErr error
jobs := make(chan int64)
for i := 0; i < workers; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for start := range jobs {
if ctx.Err() != nil {
return
}
if err := fetchChunk(ctx, client, id, dir, start); err != nil {
once.Do(func() { firstErr = err; cancel() })
return
}
}
}()
}
send:
for start := int64(0); start < id.Size; start += id.ChunkSize {
select {
case jobs <- start:
case <-ctx.Done():
break send
}
}
close(jobs)
wg.Wait()
if firstErr != nil {
return firstErr
}
if err := ctx.Err(); err != nil {
return err
}
tmp, err := os.CreateTemp(filepath.Dir(output), ".download-")
if err != nil {
return err
}
defer os.Remove(tmp.Name())
defer tmp.Close()
hash := sha256.New()
for start := int64(0); start < id.Size; start += id.ChunkSize {
if err := ctx.Err(); err != nil {
return err
}
part, err := os.Open(filepath.Join(dir, fmt.Sprintf("%d.part", start)))
if err != nil {
return err
}
n, copyErr := io.Copy(io.MultiWriter(tmp, hash), io.LimitReader(part, id.ChunkSize+1))
closeErr := part.Close()
if copyErr != nil {
return copyErr
}
if closeErr != nil {
return closeErr
}
if n != min(id.ChunkSize, id.Size-start) {
return errors.New("saved chunk length changed")
}
fmt.Fprintf(os.Stderr, "Assembled: %d/%d bytes\n", start+n, id.Size)
}
if hex.EncodeToString(hash.Sum(nil)) != digest {
return errors.New("SHA-256 mismatch; use a new output path")
}
if err := tmp.Sync(); err != nil {
return err
}
if err := tmp.Close(); err != nil {
return err
}
if err := ctx.Err(); err != nil {
return err
}
return os.Rename(tmp.Name(), output)
}
func main() {
if len(os.Args) != 4 {
fmt.Fprintln(os.Stderr, "usage: downloader URL OUTPUT SHA256")
os.Exit(2)
}
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt)
defer stop()
transport := http.DefaultTransport.(*http.Transport).Clone()
transport.MaxIdleConnsPerHost = 4
defer transport.CloseIdleConnections()
client := &http.Client{Transport: transport, Timeout: 2 * time.Minute}
if err := download(ctx, client, os.Args[1], os.Args[2], os.Args[3], 4); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
Pour votre propre téléchargement, définissez DOWNLOAD_URL, OUTPUT_PATH et EXPECTED_SHA256 dans ce shell.
L’origine doit fournir une réponse HEAD non encodée avec une taille connue et un ETag fort, puis
honorer les requêtes de segments. Un téléchargement réussi remplace délibérément un fichier de
sortie existant. Un échec de vérification préserve ce fichier. Choisissez la destination en
conséquence.
Collez le bloc de compilation et d’exécution ci-dessous, ou utilisez d’abord l’origine locale de la
section suivante. Compiler main.go explicitement permet de garder séparés les deux programmes
autonomes. Un échec de compilation arrête le bloc même si un ancien exécutable downloader existe ;
les variables manquantes provoquent un échec dans le sous-shell.
(
GOENV=off GOWORK=off GOTOOLCHAIN=local GOFLAGS= GOOS= GOARCH= go build -o downloader main.go &&
./downloader "${DOWNLOAD_URL:?Set DOWNLOAD_URL}" "${OUTPUT_PATH:?Set OUTPUT_PATH}" "${EXPECTED_SHA256:?Set EXPECTED_SHA256}"
)
Essayer une origine locale prenant en charge les plages
Enregistrez ce second programme sous origin.go à côté de main.go. Il sert 8 MiB d’octets
binaires répétés suivis des cinq octets tail\n, soit un total de 8 388 613 octets. Chaque
requête obtient son propre lecteur ;
http.ServeContent gère HEAD, Range et If-Range
à l’aide de l’ETag fort fourni ici. Ce petit jeu de test conserve son contenu en mémoire.
package main
import (
"bytes"
"crypto/sha256"
"fmt"
"net"
"net/http"
"os"
"time"
)
func main() {
if len(os.Args) != 2 {
fmt.Fprintln(os.Stderr, "usage: local-origin 127.0.0.1:PORT")
os.Exit(2)
}
data := append(bytes.Repeat([]byte{0x00, 0x80, 0xff, 0x0a}, 2<<20), []byte("tail\n")...)
digest := fmt.Sprintf("%x", sha256.Sum256(data))
listener, err := net.Listen("tcp", os.Args[1])
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
defer listener.Close()
mux := http.NewServeMux()
mux.HandleFunc("/file", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("ETag", "\""+digest+"\"")
http.ServeContent(w, r, "fixture.bin", time.Time{}, bytes.NewReader(data))
})
fmt.Printf("DOWNLOAD_URL=http://%s/file\nEXPECTED_SHA256=%s\n", listener.Addr(), digest)
server := &http.Server{Handler: mux, ReadHeaderTimeout: 5 * time.Second}
if err := server.Serve(listener); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
Dans un second terminal Bash, entrez dans le même répertoire range-downloader et collez ce bloc.
Le port zéro sélectionne un port de bouclage disponible. Laissez l’origine tourner pendant le
téléchargement ; appuyez sur Ctrl+C dans ce terminal une fois terminé.
(
GOENV=off GOWORK=off GOTOOLCHAIN=local GOFLAGS= GOOS= GOARCH= go build -o local-origin origin.go &&
./local-origin 127.0.0.1:0
)
Copiez les deux lignes d’affectation affichées par l’origine dans votre premier terminal, puis définissez-y la destination :
export OUTPUT_PATH='example.bin'
Exécutez le bloc de compilation et d’exécution du téléchargeur ci-dessus. Il devrait se terminer
avec le code de sortie zéro après avoir affiché des compteurs d’assemblage se terminant par
8388613/8388613 bytes. Vérifiez le fichier réel de manière indépendante :
(
printf '%s %s\n' "${EXPECTED_SHA256:?Set EXPECTED_SHA256}" "${OUTPUT_PATH:?Set OUTPUT_PATH}" |
sha256sum --check --status
)
Un code de sortie zéro pour cette vérification signifie que le fichier enregistré correspond à l’empreinte du jeu de test. Pour un fichier distant, obtenez l’empreinte par un canal de confiance de l’éditeur ; une somme de contrôle provenant du même téléchargement non fiable n’en établit pas l’authenticité. Pour tester la reprise avec une origine plus lente ou un fichier plus volumineux, appuyez sur Ctrl+C pendant le téléchargement et relancez la même commande. Seuls les segments entièrement enregistrés sont réutilisés ; ce petit jeu de test en boucle locale peut se terminer avant que vous ne puissiez l’interrompre.
Bonnes pratiques et pièges courants
Cette implémentation rejette les longueurs inconnues, les ETag faibles ou absents, les réponses encodées et les objets de plus de 1 TiB. Les fichiers vides sont pris en charge lorsque HEAD fournit un ETag fort et une longueur nulle. SHA-256 détecte les segments sauvegardés corrompus et les versions mélangées, même si une origine se comporte mal.
Les exécutions réussies conservent le répertoire privé .parts pour un nettoyage explicite. Un
arrêt forcé peut laisser derrière lui son répertoire .lock : ne supprimez ce verrou qu’après
avoir confirmé qu’aucun téléchargeur n’est actif.
Gardez le répertoire parent de sortie et les segments sauvegardés sous votre contrôle exclusif. Le
verrou coordonne les exécutions de ce programme qui utilisent le même répertoire « .parts » des
segments sauvegardés ; il ne protège pas contre d’autres programmes qui modifieraient ces fichiers.
Le fichier temporaire assemblé est créé à côté de la destination afin qu’ils partagent le même
système de fichiers.
os.Rename remplace la destination sous Linux après vérification.
Ce n’est pas une garantie de durabilité en cas de coupure de courant. Une empreinte non concordante
ne répare pas les segments sauvegardés ; choisissez un nouveau chemin de sortie et examinez
l’origine ou l’état corrompu.
Conclusion
Utilisez le jeu de test local pour valider le parcours complet, puis remplacez-le par une origine renvoyant des réponses de plage compatibles et une empreinte de confiance obtenue indépendamment. Le téléchargeur à quatre travailleurs conserve les segments terminés et ne publie le fichier assemblé qu’après vérification.
