Crie um downloader retomável em Go com chunks concorrentes
Crie um único executável de download que salva os chunks concluídos entre execuções e verifica o arquivo montado antes de substituir o destino. Você também vai executar uma origem HTTP local com suporte a intervalos e bytes binários conhecidos para testar o download completo sem depender de uma URL de terceiros.
Por que criar um downloader de arquivos personalizado?
Quatro workers buscam chunks independentes. Os chunks concluídos sobrevivem a uma execução interrompida, enquanto chunks parciais nunca contam como concluídos. O destino só é substituído atomicamente depois que o arquivo completo corresponde ao digest esperado.
Entendendo requisições HTTP de intervalo e conteúdo parcial
Um cabeçalho Range é uma solicitação, não uma garantia. Cada chunk precisa receber o status 206, exatamente o Content-Range solicitado, o mesmo ETag forte e exatamente o número esperado de bytes. O If-Range impede que versões diferentes do objeto sejam combinadas silenciosamente; aqui, um 200 de fallback é um erro.
Semântica de intervalos e validadores HTTP (RFC 9110)
Implementação básica de download de arquivos em Go
Estes comandos têm como alvo Linux com Bash, Go 1.26.8 no seu PATH e sha256sum do GNU Coreutils.
Instale o Go pelos downloads oficiais se necessário. O Go 1.26 continua sendo uma
versão com suporte; este passo a passo foi testado com
1.26.8 e não faz nenhuma afirmação sobre toolchains mais antigas. Os dois programas usam apenas a
biblioteca padrão.
Cole este bloco a partir de um diretório que você controla. Ele só entra no novo projeto depois que
a inicialização é concluída com sucesso. Um diretório range-downloader existente é um erro; escolha outro
nome ou inspecione-o em vez de remover um projeto existente para repetir a preparação.
(
mkdir -- range-downloader &&
cd -- range-downloader &&
GOENV=off GOWORK=off GOTOOLCHAIN=local GOFLAGS= go mod init range-downloader
) && cd -- range-downloader
As configurações com escopo do comando ignoram a configuração salva do Go e qualquer
workspace do Go que envolva o diretório, limpam flags de build herdadas e usam a
toolchain instalada. Elas não alteram as configurações do seu shell nem o go.mod ou o go.work do projeto pai.
Se a preparação falhar depois de criar o diretório, seu shell permanece no diretório original;
inspecione o novo diretório antes de tentar novamente.
Adicionando suporte a downloads retomáveis
O caminho de saída acrescido de “.parts” armazena a URL, o ETag, o digest esperado, o comprimento e o tamanho do chunk. Uma nova execução só aceita esse estado se todos os campos corresponderem. Ela reutiliza arquivos de chunk completos com o tamanho esperado e verifica o conteúdo montado em relação ao digest confiável. Uma divergência exige um novo caminho de saída; não reutilize estado corrompido.
Implementando o download concorrente de chunks
Um pool fixo de workers consome um canal de tarefas sem buffer. Cada resposta é transmitida por um buffer de cópia de 32 KiB para um arquivo de chunk temporário. O primeiro erro de um worker cancela as outras requisições, e o coordenador espera todos os workers terminarem antes de retornar.
Adicionando uma barra de progresso com atualizações em tempo real
Esta versão imprime contagens de bytes durante a montagem final em vez de adicionar uma dependência de barra de progresso. Os bytes baixados só são informados como verificados quando o digest final corresponde.
Tratamento de erros e lógica de nova tentativa
Os erros se propagam até um código de saída do processo diferente de zero. Execute o mesmo comando novamente após uma falha transitória para reutilizar os chunks concluídos. Não há um loop automático de novas tentativas que poderia continuar tentando de novo após uma falha de autorização ou uma representação alterada. Ctrl-C cancela as requisições de rede e mantém os chunks concluídos disponíveis.
Otimizando o desempenho com pool de conexões
Um único cliente HTTP compartilhado reutiliza conexões, com quatro conexões ociosas por host e um tempo limite de dois minutos por requisição. O número de workers limita a concorrência de rede; o uso de disco inclui os chunks salvos e uma segunda cópia completa durante a montagem.
Exemplo completo: criando um downloader de CLI
Salve este programa completo como main.go. Obtenha o digest SHA-256 de um publicador confiável, de
forma independente do download. Use um diretório pai que você controla; nenhum outro processo deve
modificar a saída nem os chunks salvos.
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 o seu próprio download, defina DOWNLOAD_URL, OUTPUT_PATH e EXPECTED_SHA256 neste shell.
A origem precisa fornecer uma resposta HEAD sem codificação, com tamanho conhecido e um ETag forte, e
depois atender às requisições de chunks. Um download bem-sucedido substitui deliberadamente um
arquivo de saída existente. Uma verificação com falha preserva esse arquivo. Escolha o destino
tendo em mente esse comportamento de substituição e preservação.
Cole o bloco de build e execução abaixo ou use antes a origem local da próxima seção. Compilar
main.go explicitamente mantém os dois programas independentes separados. Uma falha de build interrompe o
bloco mesmo quando existe um executável downloader mais antigo; variáveis ausentes causam falha dentro do
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}"
)
Experimente uma origem local com suporte a intervalos
Salve este segundo programa como origin.go ao lado de main.go. Ele serve 8 MiB de bytes binários
repetidos seguidos pelos cinco bytes tail\n, totalizando 8.388.613 bytes. Cada requisição recebe seu
próprio leitor; http.ServeContent trata HEAD, Range e If-Range
usando o ETag forte fornecido aqui. Esta pequena fixture mantém seu conteúdo em memória.
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)
}
}
Em um segundo terminal Bash, entre no mesmo diretório range-downloader e cole este bloco.
A porta zero seleciona uma porta de loopback disponível. Mantenha a origem em execução enquanto faz o
download; pressione Ctrl+C neste terminal ao terminar.
(
GOENV=off GOWORK=off GOTOOLCHAIN=local GOFLAGS= GOOS= GOARCH= go build -o local-origin origin.go &&
./local-origin 127.0.0.1:0
)
Copie as duas linhas de atribuição impressas pela origem para o seu primeiro terminal e, em seguida, defina o destino lá:
export OUTPUT_PATH='example.bin'
Execute o bloco de build e execução do downloader acima. Ele deve encerrar com status zero depois de
imprimir as contagens de montagem terminando em 8388613/8388613 bytes. Verifique o arquivo real de forma independente:
(
printf '%s %s\n' "${EXPECTED_SHA256:?Set EXPECTED_SHA256}" "${OUTPUT_PATH:?Set OUTPUT_PATH}" |
sha256sum --check --status
)
Um status de saída zero nessa verificação significa que o arquivo salvo corresponde ao digest da fixture. Para um arquivo remoto, obtenha o digest por um canal confiável do publicador; um checksum vindo do mesmo download não confiável não comprova a autenticidade. Para testar o comportamento de retomada em uma origem mais lenta ou maior, pressione Ctrl+C durante o download e execute o mesmo comando novamente. Apenas chunks totalmente salvos são reutilizados; esta pequena fixture de loopback pode terminar antes que você consiga interrompê-la.
Boas práticas e armadilhas comuns
Esta implementação rejeita comprimentos desconhecidos, ETags fracos ou ausentes, respostas codificadas e objetos maiores que 1 TiB. Arquivos vazios são suportados quando o HEAD fornece um ETag forte e comprimento zero. O SHA-256 detecta chunks salvos corrompidos e versões misturadas mesmo que uma origem se comporte de forma incorreta.
Execuções bem-sucedidas mantêm o diretório privado .parts para limpeza explícita. Um encerramento
forçado pode deixar para trás o diretório .lock correspondente: remova esse lock somente depois de
confirmar que nenhum downloader está ativo. Mantenha o diretório pai da saída e os chunks salvos sob
seu controle exclusivo. O lock coordena as execuções deste programa que usam o mesmo caminho de
partes; ele não protege contra outros programas que modifiquem esses arquivos.
O arquivo temporário montado é criado ao lado do destino para que ambos compartilhem o mesmo sistema
de arquivos. os.Rename substitui o destino no Linux após a verificação.
Isso não é uma garantia de durabilidade contra queda de energia. Uma divergência de digest não repara
os chunks salvos; escolha um novo caminho de saída e investigue a origem ou o estado corrompido.
Conclusão
Use a fixture local para confirmar o fluxo completo e depois troque-a por uma origem com respostas de intervalo compatíveis e um digest confiável obtido de forma independente. O downloader de quatro workers mantém os chunks completos e só publica o arquivo montado após a verificação.
O Robot /http/import da Transloadit faz importações HTTP em fluxos de trabalho de processamento.
