Crea un descargador reanudable en Go con fragmentos concurrentes
Crea un único ejecutable de descarga que conserve los fragmentos completados entre ejecuciones y verifique el archivo ensamblado antes de reemplazar el destino. También ejecutarás un servidor de origen HTTP local con soporte para rangos y bytes binarios conocidos para probar la descarga completa sin depender de una URL de terceros.
¿Por qué crear un descargador de archivos personalizado?
Cuatro workers descargan fragmentos independientes. Los fragmentos completados se conservan si se interrumpe una ejecución, mientras que los parciales nunca cuentan como completos. El destino se reemplaza de forma atómica solo después de que el archivo completo coincida con el resumen criptográfico esperado.
Comprende las solicitudes de rango HTTP y el contenido parcial
Un encabezado Range es una solicitud, no una garantía. Cada fragmento debe recibir el estado 206, exactamente el Content-Range solicitado, el mismo ETag fuerte y exactamente la cantidad de bytes esperada. If-Range evita combinar silenciosamente distintas versiones del objeto; aquí, una respuesta 200 como alternativa es un error.
Semántica de rangos y validadores HTTP (RFC 9110)
Implementación básica de descarga de archivos en Go
Estos comandos están dirigidos a Linux con Bash, Go 1.26.8 en tu PATH y
sha256sum de GNU Coreutils.
Instala Go desde las descargas oficiales si lo necesitas. Go 1.26
sigue siendo una versión con soporte; este tutorial se probó con
1.26.8 y no hace afirmaciones sobre cadenas de herramientas anteriores. Ambos programas usan
únicamente la biblioteca estándar.
Pega este bloque desde un directorio que controles. Solo entra en el nuevo proyecto después de que
la inicialización se complete correctamente. Si ya existe un directorio
range-downloader, se produce un error; elige otro nombre o inspecciónalo en lugar de
eliminar un proyecto existente para repetir la configuración.
(
mkdir -- range-downloader &&
cd -- range-downloader &&
GOENV=off GOWORK=off GOTOOLCHAIN=local GOFLAGS= go mod init range-downloader
) && cd -- range-downloader
Los ajustes limitados a cada comando omiten la configuración guardada de Go y cualquier
espacio de trabajo de Go que contenga el proyecto, eliminan las
opciones de compilación heredadas y usan la cadena de herramientas instalada. No cambian los
ajustes de tu shell ni los archivos go.mod o
go.work del proyecto padre.
Si la configuración falla después de crear el directorio, tu shell permanece en su directorio
original; inspecciona el nuevo directorio antes de volver a intentarlo.
Añade soporte para descargas reanudables
La ruta de salida con el sufijo «.parts» almacena la URL, el ETag, el resumen criptográfico esperado, la longitud y el tamaño de los fragmentos. Una nueva ejecución acepta ese estado solo si todos los campos coinciden. Reutiliza los archivos de fragmentos completos con la longitud esperada y verifica el contenido ensamblado con el resumen criptográfico de confianza. Una discrepancia requiere una nueva ruta de salida; no reutilices un estado corrupto.
Implementa la descarga concurrente de fragmentos
Un grupo fijo de workers consume un canal de tareas sin búfer. Cada respuesta se transmite a través de un búfer de copia de 32 KiB a un archivo de fragmento temporal. El primer error de un worker cancela las demás solicitudes, y el coordinador espera a que todos los workers terminen antes de retornar.
Añade una barra de progreso con actualizaciones en tiempo real
Esta versión imprime recuentos de bytes durante el ensamblado final en lugar de añadir una dependencia para una barra de progreso. Los bytes descargados no se indican como verificados hasta que el resumen criptográfico final coincida.
Gestión de errores y lógica de reintentos
Los errores se propagan hasta que el proceso termina con un código de salida distinto de cero. Vuelve a ejecutar el mismo comando después de un fallo transitorio para reutilizar los fragmentos completados. No hay un bucle de reintentos automático que pueda seguir reintentando ante un fallo de autorización o una representación modificada. Ctrl-C cancela las solicitudes de red y deja disponibles los fragmentos completados.
Optimiza el rendimiento con un pool de conexiones
Un único cliente HTTP compartido reutiliza las conexiones, con cuatro conexiones inactivas por host y un tiempo de espera de dos minutos por solicitud. La cantidad de workers limita la concurrencia de red; el uso de disco incluye los fragmentos guardados y una segunda copia completa durante el ensamblado.
Ejemplo completo: crea un descargador de línea de comandos
Guarda este programa completo como main.go. Obtén el resumen criptográfico
SHA-256 de un publicador de confianza por una vía independiente de la descarga. Usa un directorio
padre que controles; ningún otro proceso debe modificar la salida ni los fragmentos guardados.
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)
}
}
Para tu propia descarga, establece DOWNLOAD_URL,
OUTPUT_PATH y EXPECTED_SHA256 en este shell.
El origen debe proporcionar una respuesta HEAD sin codificación, con un tamaño conocido y un ETag
fuerte, y luego respetar las solicitudes de fragmentos.
Una descarga correcta reemplaza deliberadamente un archivo de salida existente.
Si la verificación falla, ese archivo se conserva. Elige el destino teniendo esto en cuenta.
Pega el bloque de compilación y ejecución que aparece a continuación, o usa primero el origen local
de la siguiente sección. Compilar main.go explícitamente mantiene separados
los dos programas independientes. Si la compilación falla, el bloque se detiene aunque exista un
ejecutable downloader anterior; la ausencia de variables provoca un error dentro
del subshell.
(
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}"
)
Prueba un origen local con soporte para rangos
Guarda este segundo programa como origin.go junto a
main.go. Sirve 8 MiB de bytes binarios repetidos seguidos de los cinco bytes
tail\n, para un total de 8.388.613 bytes. Cada solicitud obtiene su propio
lector; http.ServeContent gestiona HEAD, Range e If-Range
con el ETag fuerte proporcionado aquí. Este pequeño servidor de prueba mantiene su contenido en
memoria.
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)
}
}
En una segunda terminal Bash, entra en el mismo directorio range-downloader y pega este
bloque. El puerto cero selecciona un puerto disponible en la interfaz de bucle local. Mantén el
origen en ejecución mientras descargas; pulsa Ctrl+C en esta terminal cuando termines.
(
GOENV=off GOWORK=off GOTOOLCHAIN=local GOFLAGS= GOOS= GOARCH= go build -o local-origin origin.go &&
./local-origin 127.0.0.1:0
)
Copia las dos líneas de asignación que imprime el origen en tu primera terminal y luego establece allí el destino:
export OUTPUT_PATH='example.bin'
Ejecuta el bloque anterior de compilación y ejecución del descargador. Debería terminar con un
código de salida cero después de imprimir recuentos de ensamblado que finalicen en
8388613/8388613 bytes. Verifica el archivo real de forma independiente:
(
printf '%s %s\n' "${EXPECTED_SHA256:?Set EXPECTED_SHA256}" "${OUTPUT_PATH:?Set OUTPUT_PATH}" |
sha256sum --check --status
)
Un código de salida cero en esta comprobación significa que el archivo guardado coincide con el resumen criptográfico del contenido de prueba. Para un archivo remoto, obtén el resumen a través de un canal de un publicador de confianza; una suma de comprobación procedente de la misma descarga no confiable no demuestra autenticidad. Para probar la reanudación con un origen más lento o de mayor tamaño, pulsa Ctrl+C durante la descarga y vuelve a ejecutar el mismo comando. Solo se reutilizan los fragmentos guardados por completo; la descarga de este pequeño contenido de prueba en bucle local puede finalizar antes de que puedas interrumpirla.
Buenas prácticas y errores comunes
Esta implementación rechaza longitudes desconocidas, ETags débiles o ausentes, respuestas codificadas y objetos de más de 1 TiB. Admite archivos vacíos cuando HEAD proporciona un ETag fuerte y una longitud cero. SHA-256 detecta fragmentos guardados corruptos y versiones mezcladas incluso si un origen se comporta incorrectamente.
Las ejecuciones correctas conservan el directorio privado .parts para su
limpieza explícita. Una terminación forzada puede dejar su directorio
.lock: elimina ese bloqueo solo después de confirmar que no hay ningún
descargador activo. Mantén el directorio padre de la salida y los fragmentos guardados bajo tu
control exclusivo. El bloqueo coordina las ejecuciones de este programa que usan la misma ruta de
fragmentos; no protege contra la modificación de esos archivos por otros programas.
El archivo temporal ensamblado se crea junto al destino para que ambos compartan un sistema de
archivos. os.Rename reemplaza el destino en Linux
tras la verificación. Esto no garantiza la durabilidad ante una pérdida de energía. Una
discrepancia en el resumen criptográfico no repara los fragmentos guardados; elige una nueva ruta
de salida e investiga el origen o el estado corrupto.
Conclusión
Usa el servidor de prueba local para confirmar el proceso completo y luego sustitúyelo por un origen con respuestas de rango compatibles y un resumen criptográfico obtenido por una vía independiente de confianza. El descargador de cuatro workers conserva los fragmentos completos y publica el archivo ensamblado solo después de verificarlo.
