Crea un descargador Go reanudable con bloques concurrentes
Crea un único ejecutable de descarga que guarde los bloques completados entre ejecuciones. Requiere un hash SHA-256 confiable, un servidor de origen con una ETag fuerte y soporte para rangos de bytes, y un directorio local privado de salida en un sistema tipo Unix.
¿Por qué crear un descargador de archivos personalizado?
Cuatro trabajadores descargan bloques independientes. Los bloques completados se conservan si se interrumpe una ejecución, mientras que los bloques parciales nunca se consideran completos. El archivo de destino se reemplaza de forma atómica solo después de que el hash del archivo completo coincida con el esperado.
Comprende las solicitudes de rangos HTTP y el contenido parcial
Un encabezado Range es una solicitud, no una garantía. Cada bloque debe recibir el estado 206, exactamente el Content-Range solicitado, la misma ETag fuerte y exactamente la cantidad de bytes esperada. If-Range evita combinar silenciosamente distintas versiones del objeto; aquí, una respuesta alternativa 200 se considera un error.
Semántica de los rangos HTTP y los validadores (RFC 9110)
Implementa una descarga básica de archivos en Go
Usa Go 1.22 o una versión posterior. Crea un directorio de proyecto vacío e inicializa el módulo. El programa usa únicamente la biblioteca estándar.
mkdir range-downloader
cd range-downloader
go mod init range-downloader
go mod edit -go=1.22
Añade soporte para descargas reanudables
La ruta de salida con el sufijo «.parts» almacena la URL, la ETag, el hash esperado, la longitud y el tamaño de bloque. Una nueva ejecución acepta ese estado solo si todos los campos coinciden. Reutiliza los archivos de bloques completos con la longitud esperada y verifica el contenido ensamblado comparándolo con el hash confiable. Si no coincide, se requiere una nueva ruta de salida; no reutilices un estado corrupto.
Implementa la descarga concurrente de bloques
Un grupo fijo de trabajadores consume un canal de tareas sin búfer. Cada respuesta se transmite a través de un búfer de copia de 32 KiB hacia un archivo de bloque temporal. El primer error de un trabajador cancela las demás solicitudes, y el coordinador espera a que todos los trabajadores terminen antes de retornar.
Añade una barra de progreso con actualizaciones en tiempo real
Esta versión muestra 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 hash final coincida.
Manejo 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 bloques completados. No hay un bucle de reintentos automático que pueda seguir reintentando ante un fallo de autorización o un cambio en la representación. Ctrl-C cancela las solicitudes de red y deja los bloques completados disponibles.
Optimiza el rendimiento con un grupo 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 trabajadores limita la concurrencia de red; el uso de disco incluye los bloques 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 hash SHA-256 de un publicador confiable por una vía independiente de la descarga. Usa un directorio superior que controles; ningún otro proceso debe modificar el archivo de salida ni los bloques 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)
}
}
Compila el programa y luego proporciona la URL de descarga, la ruta de salida y el hash esperado mediante variables de entorno. Estas comprobaciones fallan de inmediato si falta algún valor obligatorio.
GOTOOLCHAIN=local go build -o downloader .
./downloader "${DOWNLOAD_URL:?Set DOWNLOAD_URL}" "${OUTPUT_PATH:?Set OUTPUT_PATH}" "${EXPECTED_SHA256:?Set EXPECTED_SHA256}"
Buenas prácticas y errores comunes
Esta implementación rechaza las longitudes desconocidas, las ETag débiles o ausentes, las respuestas codificadas y los objetos de más de 1 TiB. Admite archivos vacíos cuando HEAD proporciona una ETag fuerte y una longitud de cero. SHA-256 detecta bloques guardados corruptos y mezclas de versiones incluso si el servidor de origen se comporta de forma incorrecta.
Las ejecuciones exitosas conservan el directorio privado de bloques para que el operador lo elimine explícitamente. Una terminación forzada puede dejar su directorio .lock: elimina ese bloqueo solo después de confirmar que no hay ningún descargador activo. El cambio de nombre atómico requiere que el archivo temporal ensamblado y el destino compartan un sistema de archivos; el comportamiento de reemplazo descrito aquí está pensado para sistemas tipo Unix. Esto no garantiza la durabilidad ante una pérdida de energía.
Conclusión
Ahora tienes un descargador completo por rangos con concurrencia acotada, estado persistente para reanudar las descargas y verificación antes de publicar.
