Go-Dateidownloader mit Fortsetzung und parallelen Chunks bauen
Erstellen Sie einen ausführbaren Downloader, der vollständige Chunks zwischen Durchläufen speichert und die zusammengesetzte Datei prüft, bevor er die Zieldatei ersetzt. Außerdem betreiben Sie einen lokalen HTTP-Ursprungsserver mit Range-Unterstützung und bekannten Binärdaten, um den vollständigen Download ohne Abhängigkeit von einer Drittanbieter-URL zu testen.
Warum einen eigenen Dateidownloader bauen?
Vier Worker rufen unabhängige Chunks ab. Vollständige Chunks bleiben nach einem unterbrochenen Durchlauf erhalten, während unvollständige Chunks niemals als vollständig zählen. Die Zieldatei wird erst dann atomar ersetzt, wenn die vollständige Datei mit dem erwarteten Hashwert übereinstimmt.
HTTP-Range-Anfragen und Teilinhalte verstehen
Ein Range-Header ist eine Anfrage, keine Garantie. Jeder Chunk muss den Status 206, exakt den angeforderten Content-Range, denselben starken ETag und genau die erwartete Anzahl an Bytes erhalten. If-Range verhindert, dass verschiedene Objektversionen unbemerkt kombiniert werden; eine Ausweichantwort mit Status 200 gilt hier als Fehler.
Semantik von HTTP-Ranges und Validatoren (RFC 9110)
Grundlegenden Dateidownload in Go implementieren
Diese Befehle sind für Linux mit Bash, Go 1.26.8 im PATH und
sha256sum aus GNU Coreutils vorgesehen.
Installieren Sie Go bei Bedarf über die offiziellen Downloads. Go 1.26
ist weiterhin eine unterstützte Version; diese Anleitung wurde mit
1.26.8 getestet und trifft keine Aussage zu älteren Toolchains. Beide Programme verwenden nur die
Standardbibliothek.
Fügen Sie diesen Block ein, während Sie sich in einem Verzeichnis befinden, das Sie kontrollieren.
Er wechselt erst nach erfolgreicher Initialisierung in das neue Projekt. Ein bereits vorhandenes
Verzeichnis namens range-downloader führt zu einem Fehler; wählen Sie einen anderen
Namen oder prüfen Sie das Verzeichnis, statt ein bestehendes Projekt zu löschen, um die Einrichtung
zu wiederholen.
(
mkdir -- range-downloader &&
cd -- range-downloader &&
GOENV=off GOWORK=off GOTOOLCHAIN=local GOFLAGS= go mod init range-downloader
) && cd -- range-downloader
Die auf den jeweiligen Befehl begrenzten Einstellungen umgehen die gespeicherte Go-Konfiguration
und einen übergeordneten Go-Arbeitsbereich, setzen geerbte Build-Flags
zurück und verwenden die installierte Toolchain. Sie ändern weder Ihre Shell-Einstellungen noch
go.mod oder go.work im übergeordneten Projekt.
Wenn die Einrichtung nach dem Erstellen des Verzeichnisses fehlschlägt, bleibt Ihre Shell im
ursprünglichen Verzeichnis. Prüfen Sie das neue Verzeichnis vor einem erneuten Versuch.
Unterstützung für fortsetzbare Downloads ergänzen
Unter dem Ausgabepfad mit angehängtem „.parts“ werden URL, ETag, erwarteter Hashwert, Länge und Chunk-Größe gespeichert. Ein erneuter Durchlauf akzeptiert diesen Zustand nur, wenn alle Felder übereinstimmen. Er verwendet vollständige Chunk-Dateien mit der erwarteten Länge erneut und prüft den zusammengesetzten Inhalt anhand des vertrauenswürdigen Hashwerts. Bei einer Abweichung ist ein neuer Ausgabepfad erforderlich; verwenden Sie keinen beschädigten Zustand erneut.
Parallelen Download von Chunks implementieren
Ein fester Worker-Pool liest Aufträge aus einem ungepufferten Channel. Jede Antwort fließt durch einen Kopierpuffer von 32 KiB in eine temporäre Chunk-Datei. Der erste Worker-Fehler bricht die anderen Anfragen ab, und der Koordinator wartet vor seiner Rückkehr auf jeden Worker.
Einen Fortschrittsbalken mit Echtzeitaktualisierungen ergänzen
Diese Version gibt beim abschließenden Zusammensetzen Byte-Zähler aus, statt eine Abhängigkeit für einen Fortschrittsbalken hinzuzufügen. Heruntergeladene Bytes werden erst als verifiziert gemeldet, wenn der abschließende Hashwert übereinstimmt.
Fehlerbehandlung und Wiederholungslogik
Fehler führen dazu, dass der Prozess mit einem Exit-Status ungleich null endet. Führen Sie nach einem vorübergehenden Fehler denselben Befehl erneut aus, um vollständige Chunks wiederzuverwenden. Es gibt keine automatische Wiederholungsschleife, die bei einem Autorisierungsfehler oder einer geänderten Repräsentation immer weiter versuchen könnte, den Download auszuführen. Strg+C bricht Netzwerkanfragen ab und lässt vollständige Chunks verfügbar.
Leistung durch Verbindungspooling optimieren
Ein gemeinsam genutzter HTTP-Client verwendet Verbindungen erneut, mit vier inaktiven Verbindungen pro Host und einem Timeout von zwei Minuten pro Anfrage. Die Anzahl der Worker begrenzt die Parallelität der Netzwerkanfragen; der Festplattenbedarf umfasst die gespeicherten Chunks und eine zweite vollständige Kopie während des Zusammensetzens.
Vollständiges Beispiel: einen CLI-Downloader bauen
Speichern Sie dieses vollständige Programm als main.go. Beziehen Sie den
SHA-256-Hashwert unabhängig vom Download von einem vertrauenswürdigen Herausgeber. Verwenden Sie ein
übergeordnetes Verzeichnis, das Sie kontrollieren; kein anderer Prozess sollte die Ausgabedatei oder
die gespeicherten Chunks verändern.
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)
}
}
Setzen Sie für Ihren eigenen Download DOWNLOAD_URL,
OUTPUT_PATH und EXPECTED_SHA256 in dieser Shell.
Der Ursprungsserver muss eine uncodierte HEAD-Antwort mit bekannter Größe und einem starken ETag
liefern und anschließend die Chunk-Anfragen erfüllen. Ein erfolgreicher Download ersetzt bewusst
eine vorhandene Ausgabedatei. Schlägt die Verifizierung fehl, bleibt diese Datei erhalten. Wählen
Sie das Ziel entsprechend.
Fügen Sie den folgenden Block zum Kompilieren und Ausführen ein oder nutzen Sie zuerst den lokalen
Ursprungsserver im nächsten Abschnitt. Das explizite Kompilieren von
main.go hält die beiden eigenständigen Programme getrennt. Ein fehlgeschlagener
Build stoppt den Block, selbst wenn eine ältere ausführbare Datei namens
downloader vorhanden ist; fehlende Variablen führen innerhalb der Subshell zu
einem Fehler.
(
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}"
)
Einen lokalen Ursprungsserver mit Range-Unterstützung testen
Speichern Sie dieses zweite Programm als origin.go neben
main.go. Es liefert 8 MiB sich wiederholender Binärbytes, gefolgt von den fünf
Bytes tail\n, insgesamt also 8.388.613 Bytes. Jede Anfrage erhält einen eigenen
Reader; http.ServeContent verarbeitet HEAD, Range und
If-Range mit dem hier bereitgestellten starken ETag. Diese kleine Fixture hält ihren Inhalt im
Arbeitsspeicher.
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)
}
}
Wechseln Sie in einem zweiten Bash-Terminal in dasselbe Verzeichnis
range-downloader und fügen Sie diesen Block ein.
Port null wählt einen verfügbaren Loopback-Port. Lassen Sie den Ursprungsserver während des Downloads
laufen; drücken Sie nach Abschluss in diesem Terminal Strg+C.
(
GOENV=off GOWORK=off GOTOOLCHAIN=local GOFLAGS= GOOS= GOARCH= go build -o local-origin origin.go &&
./local-origin 127.0.0.1:0
)
Kopieren Sie die beiden Zuweisungszeilen, die der Ursprungsserver ausgibt, in Ihr erstes Terminal und legen Sie dort anschließend das Ziel fest:
export OUTPUT_PATH='example.bin'
Führen Sie den obigen Block zum Kompilieren und Ausführen des Downloaders aus. Er sollte mit dem
Exit-Status null enden, nachdem er beim Zusammensetzen Zählerstände bis
8388613/8388613 bytes ausgegeben hat. Prüfen Sie die tatsächliche Datei unabhängig davon:
(
printf '%s %s\n' "${EXPECTED_SHA256:?Set EXPECTED_SHA256}" "${OUTPUT_PATH:?Set OUTPUT_PATH}" |
sha256sum --check --status
)
Ein Exit-Status von null bei dieser Prüfung bedeutet, dass die gespeicherte Datei mit dem Hashwert der Fixture übereinstimmt. Beziehen Sie bei einer entfernten Datei den Hashwert über einen vertrauenswürdigen Kanal des Herausgebers; eine Prüfsumme aus demselben nicht vertrauenswürdigen Download belegt keine Authentizität. Um das Fortsetzen bei einem langsameren Ursprungsserver oder einer größeren Datei zu testen, drücken Sie während des Downloads Strg+C und führen Sie denselben Befehl erneut aus. Nur vollständig gespeicherte Chunks werden wiederverwendet; bei dieser kleinen Loopback-Fixture kann der Download bereits abgeschlossen sein, bevor Sie ihn unterbrechen können.
Bewährte Verfahren und häufige Fallstricke
Diese Implementierung lehnt unbekannte Längen, schwache oder fehlende ETags, codierte Antworten und Objekte größer als 1 TiB ab. Leere Dateien werden unterstützt, wenn HEAD einen starken ETag und eine Länge von null liefert. SHA-256 erkennt beschädigte gespeicherte Chunks und gemischte Versionen, selbst wenn sich ein Ursprungsserver fehlerhaft verhält.
Nach erfolgreichen Durchläufen bleibt das private Verzeichnis .parts zur
expliziten Bereinigung erhalten. Ein erzwungenes Beenden kann dessen Verzeichnis
.lock zurücklassen: Entfernen Sie diese Sperre erst, nachdem Sie bestätigt
haben, dass kein Downloader aktiv ist. Behalten Sie die ausschließliche Kontrolle über das
übergeordnete Ausgabeverzeichnis und die gespeicherten Chunks. Die Sperre koordiniert Durchläufe
dieses Programms, die denselben Pfad für die Teildateien verwenden; sie schützt nicht davor, dass
andere Programme diese Dateien verändern.
Die zusammengesetzte temporäre Datei wird neben der Zieldatei erstellt, sodass beide auf demselben
Dateisystem liegen. os.Rename ersetzt unter Linux
die Zieldatei nach der Verifizierung. Das garantiert keine dauerhafte Speicherung bei einem
Stromausfall. Eine Abweichung des Hashwerts repariert keine gespeicherten Chunks; wählen Sie einen
neuen Ausgabepfad und untersuchen Sie den Ursprungsserver oder den beschädigten Zustand.
Fazit
Prüfen Sie mit der lokalen Fixture den gesamten Ablauf. Verwenden Sie anschließend einen Ursprungsserver mit kompatiblen Range-Antworten und einem unabhängig davon vertrauenswürdigen Hashwert. Der Downloader mit vier Workern bewahrt vollständige Chunks auf und stellt die zusammengesetzte Datei erst nach der Verifizierung am Ziel bereit.
Der Robot /http/import von Transloadit übernimmt HTTP-Importe in Verarbeitungsabläufen.
