Manipulação de Arquivos e I/O Idiomático em Go
Como io.Reader e io.Writer permitem ler e transmitir arquivos grandes com memória constante, com filepath.WalkDir e streams.
Publicada em · 8 min de leitura
Manipulação de Arquivos e I/O Idiomático em Go
A arquitetura de Entrada e Saída (I/O) em Go é amplamente reconhecida como uma das mais elegantes e poderosas da engenharia de software moderna. Em vez de criar hierarquias complexas de classes ou abstrações pesadas, os criadores da linguagem estruturaram todo o ecossistema de I/O em torno de interfaces minúsculas, ortogonais e universalmente composíveis: io.Reader e io.Writer.
Compreender a mecânica exata dessas interfaces, o uso correto de buffering com bufio, a composição de streams com io.TeeReader e io.Pipe, e os padrões de escrita atômica no sistema de arquivos é o divisor de águas entre código amador que esgota a memória com Out-Of-Memory (OOM) e sistemas de produção capazes de processar gigabytes de dados com consumo de memória estritamente constante $O(1)$.
1. As Interfaces Fundamentais: O Coração do I/O
Tudo em Go que produz bytes implementa io.Reader, e tudo que consome bytes implementa io.Writer. Arquivos em disco (os.File), sockets TCP (net.Conn), corpos de requisição HTTP (http.Request.Body), buffers em memória (bytes.Buffer) e saídas padrão (os.Stdout) obedecem aos mesmos contratos.
type Reader interface {
Read(p []byte) (n int, err error)
}
type Writer interface {
Write(p []byte) (n int, err error)
}
type Closer interface {
Close() error
}
A Regra de Ouro do Método Read
Muitos desenvolvedores caem na armadilha clássica de tratar o erro imediatamente antes de processar os bytes lidos. A especificação oficial do Go define uma regra crítica:
Um leitor pode retornar bytes válidos (
n > 0) juntamente com um erro de fim de arquivo (io.EOF) ou outro erro não crítico.
// ❌ INCORRETO: Ignora bytes retornados quando err != nil
n, err := reader.Read(buffer)
if err != nil {
return err // Se err == io.EOF mas n > 0, os últimos bytes foram PERDIDOS!
}
process(buffer[:n])
// ✅ IDIOMÁTICO E CORRETO: Sempre processe n > 0 ANTES de avaliar o erro
n, err := reader.Read(buffer)
if n > 0 {
process(buffer[:n])
}
if err != nil {
if errors.Is(err, io.EOF) {
// Fim legítimo do fluxo de dados
break
}
return fmt.Errorf("falha na leitura de stream: %w", err)
}
2. Streaming: io.Copy vs o Risco Fatal de io.ReadAll
Um dos erros mais comuns e danosos em aplicações Go de grande escala é o uso indiscriminado de io.ReadAll (ou os.ReadFile) para manipular arquivos ou requisições HTTP de tamanho imprevisível.
Por que io.ReadAll derruba servidores?
io.ReadAll lê o stream inteiro para um slice na memória RAM (Heap). Se um cliente mal-intencionado fizer o upload de um arquivo de 5 GB, sua aplicação alocará sucessivos blocos de memória duplicando os slices conforme crescem, desencadeando o OOM Killer do Linux que envia um SIGKILL e derruba a sua instância.
A Abordagem Sênior: Streaming com io.Copy e io.CopyBuffer
O método io.Copy(dst, src) transfere dados diretamente da fonte para o destino utilizando um buffer interno fixo de 32 KB alocado em pool. A pegada de memória é estritamente $O(1)$, independentemente se o arquivo tem 10 megabytes ou 500 gigabytes.
package main
import (
"fmt"
"io"
"os"
)
// StreamCopy copia dados mantendo consumo constante de memória
func StreamCopy(srcPath, dstPath string) (int64, error) {
src, err := os.Open(srcPath)
if err != nil {
return 0, fmt.Errorf("falha ao abrir arquivo fonte: %w", err)
}
defer src.Close()
dst, err := os.Create(dstPath)
if err != nil {
return 0, fmt.Errorf("falha ao criar arquivo destino: %w", err)
}
defer dst.Close()
// io.Copy usa internamente um buffer de 32KB em loop
bytesWritten, err := io.Copy(dst, src)
if err != nil {
return 0, fmt.Errorf("falha durante a cópia em stream: %w", err)
}
// Força persistência nos setores do disco
if err = dst.Sync(); err != nil {
return 0, fmt.Errorf("falha ao sincronizar disco: %w", err)
}
return bytesWritten, nil
}
3. Composição de Streams: Decorators e Pipelines
Graças à ortogonalidade das interfaces, você pode criar pipelines complexos de transformação, auditoria e cálculo de hashes sem intermediários temporários no disco e sem alocações desnecessárias na Heap.
3.1. io.TeeReader: Calculando Hashes em Voo
O io.TeeReader(r, w) retorna um novo io.Reader que grava em w tudo o que lê de r. Isso permite calcular o hash SHA-256 de um upload diretamente enquanto ele é transmitido para o S3 ou gravado em disco.
package main
import (
"crypto/sha256"
"encoding/hex"
"io"
"os"
)
func SaveAndHash(src io.Reader, destinationPath string) (string, int64, error) {
dst, err := os.Create(destinationPath)
if err != nil {
return "", 0, err
}
defer dst.Close()
hasher := sha256.New()
// Tudo que for lido do tee será gravado no hasher e repassado a dst
tee := io.TeeReader(src, hasher)
bytesWritten, err := io.Copy(dst, tee)
if err != nil {
return "", 0, err
}
hashString := hex.EncodeToString(hasher.Sum(nil))
return hashString, bytesWritten, nil
}
3.2. io.LimitReader: Proteção contra Ataques de DoS
Proteja seus endpoints de endpoints maliciosos limitando estritamente a quantidade máxima de bytes que sua aplicação aceita ler de um stream não confiável:
// Limita a leitura a exatamente 10 Megabytes
limitedReader := io.LimitReader(req.Body, 10*1024*1024)
data, err := io.ReadAll(limitedReader)
3.3. io.Pipe: Conectando Produtores e Consumidores Concorrentes
io.Pipe cria um pipe síncrono em memória (*io.PipeReader e *io.PipeWriter). Ele permite que uma goroutine escreva dados enquanto outra goroutine os consome imediatamente, sem buffers intermediários de disco ou arrays gigantescos na memória:
package main
import (
"compress/gzip"
"io"
"os"
)
// CompressStream comprime dados diretamente em pipeline concorrente
func CompressStream(src io.Reader, dst io.Writer) error {
pr, pw := io.Pipe()
// Goroutine produtora: comprime para o PipeWriter
go func() {
gw := gzip.NewWriter(pw)
var err error
defer func() {
if err != nil {
pw.CloseWithError(err)
} else {
gw.Close()
pw.Close()
}
}()
_, err = io.Copy(gw, src)
}()
// Goroutine principal consumidora: lê do PipeReader e envia para dst
_, err := io.Copy(dst, pr)
return err
}
4. Buffering de Alto Desempenho com bufio
Fazer chamadas de sistema (syscalls sys_read / sys_write) para cada byte ou pequena string causa uma sobrecarga imensa de troca de contexto no kernel (context switch). O pacote bufio envolve qualquer io.Reader ou io.Writer com buffers de memória, acumulando dados para realizar operações de I/O em blocos grandes.
4.1. bufio.Writer: A Obrigação do Flush()
Sempre que usar bufio.Writer, os dados residem na memória RAM até que o buffer (padrão 4 KB) encha. Se você fechar o arquivo subjacente sem chamar Flush(), os últimos dados serão silenciosamente descartados!
f, err := os.Create("relatorio.txt")
if err != nil {
return err
}
defer f.Close()
writer := bufio.NewWriter(f)
defer writer.Flush() // CRÍTICO: Garante que dados remanescentes vão para o disco
writer.WriteString("Linha 1: Dados transacionais\n")
4.2. bufio.Scanner vs bufio.Reader
bufio.Scanner é a ferramenta perfeita para leitura sequencial delimitada por linhas (\n), mas possui um limite de tamanho de token padrão de 64 KB (bufio.MaxScanTokenSize). Linhas que excederem esse limite dispararão o erro bufio.ErrTooLong.
package main
import (
"bufio"
"fmt"
"os"
)
// ProcessarLogGrande lê arquivos linha a linha com buffer redimensionável
func ProcessarLogGrande(caminho string) error {
file, err := os.Open(caminho)
if err != nil {
return err
}
defer file.Close()
scanner := bufio.NewScanner(file)
// Permitir linhas de até 1 Megabyte caso o log contenha payloads JSON gigantes
const maxLineLength = 1024 * 1024
buf := make([]byte, 64*1024)
scanner.Buffer(buf, maxLineLength)
var linhaNum int
for scanner.Scan() {
linhaNum++
texto := scanner.Text() // ou scanner.Bytes() para zero-allocations
_ = texto
}
if err := scanner.Err(); err != nil {
return fmt.Errorf("erro durante escaneamento na linha %d: %w", linhaNum, err)
}
return nil
}
5. Manipulação Segura de Arquivos e o Padrão de Escrita Atômica
Ao gravar arquivos cruciais de configuração, banco de dados ou estado, gravar diretamente no arquivo de destino pode corrompê-lo caso o servidor sofra uma pane elétrica ou o processo seja morto no meio da escrita.
O Padrão de Escrita Atômica (Safe Atomic Write)
A técnica padrão da indústria consiste em:
- Criar um arquivo temporário no mesmo diretório/ponto de montagem (
os.CreateTemp). - Gravar os dados via streaming.
- Chamar
f.Sync()para forçar a descarga do cache do sistema operacional para os setores físicos da mídia de armazenamento. - Fechar o descritor de arquivo.
- Invocar
os.Rename, que sob especificações POSIX é uma operação atômica no sistema de arquivos.
package storage
import (
"fmt"
"io"
"os"
"path/filepath"
)
// SafeAtomicWrite escreve de forma atômica e resiliente a falhas elétricas
func SafeAtomicWrite(filename string, data io.Reader, perm os.FileMode) (err error) {
dir := filepath.Dir(filename)
// Cria arquivo temporário no mesmo diretório para garantir o mesmo volume/mount
tmpFile, err := os.CreateTemp(dir, "atomic-*.tmp")
if err != nil {
return fmt.Errorf("falha ao criar arquivo temporário: %w", err)
}
tmpName := tmpFile.Name()
// Garantia de limpeza em caso de falha antes da renomeação
defer func() {
if tmpFile != nil {
_ = tmpFile.Close()
_ = os.Remove(tmpName)
}
}()
// Cópia em streaming
if _, err = io.Copy(tmpFile, data); err != nil {
return fmt.Errorf("falha ao gravar dados: %w", err)
}
// Força escrita nos pratos do HD ou células do SSD
if err = tmpFile.Sync(); err != nil {
return fmt.Errorf("falha no sync: %w", err)
}
if err = tmpFile.Chmod(perm); err != nil {
return fmt.Errorf("falha no chmod: %w", err)
}
if err = tmpFile.Close(); err != nil {
return fmt.Errorf("falha ao fechar arquivo: %w", err)
}
tmpFile = nil // Anula para desativar a remoção no defer
// Renomeação atômica POSIX
if err = os.Rename(tmpName, filename); err != nil {
_ = os.Remove(tmpName)
return fmt.Errorf("falha na renomeação atômica: %w", err)
}
return nil
}
6. Navegação em Diretórios: filepath.WalkDir
Antes do Go 1.16, usava-se filepath.Walk, que executava uma syscall os.Lstat para cada arquivo da árvore, gerando gargalos severos de I/O em discos magnéticos e rede. Desde o Go 1.16, filepath.WalkDir deve ser sempre utilizado, pois ele lê as informações de tipo de arquivo diretamente das entradas de diretório (fs.DirEntry) sem syscalls adicionais:
package main
import (
"fmt"
"io/fs"
"path/filepath"
)
func EncontrarArquivosDeLog(raiz string) ([]string, error) {
var logs []string
err := filepath.WalkDir(raiz, func(path string, d fs.DirEntry, err error) error {
if err != nil {
// Tratamento de permissão negada em subdiretórios
return err
}
// Pula diretórios ocultos (ex: .git, .cache)
if d.IsDir() && d.Name() == ".git" {
return filepath.SkipDir
}
if !d.IsDir() && filepath.Ext(path) == ".log" {
logs = append(logs, path)
}
return nil
})
return logs, err
}
7. Checklist de Produção para I/O em Go
| Cenário | ❌ Prática Amadora | ✅ Padrão Sênior Idiomático |
|---|---|---|
| Arquivos Grandes / Uploads | io.ReadAll(r) ou os.ReadFile() | io.Copy(dst, src) ou io.CopyBuffer |
| Limitação de Entrada | Ler tudo e validar len(data) > limit | io.LimitReader(r, maxBytes) |
Tratamento de Read | Ignorar bytes se err != nil | Sempre processar n > 0 antes de checar err |
| Escrita com Buffer | Fechar arquivo sem Flush() | defer writer.Flush() logo após criar bufio.NewWriter |
| Persistência de Dados Críticos | Gravar direto e confiar no SO | Escrever em temp file, chamar Sync() e os.Rename |
| Varredura de Diretórios | filepath.Walk | filepath.WalkDir (usa fs.DirEntry sem syscall extra) |
| Manipulação Concorrente | Gravar em arquivo temporário no disco | io.Pipe() direto na memória com goroutines |
Links Relacionados
- Ponteiros, Memória e Escape Analysis: Entendendo onde buffers e ponteiros residem na Heap vs Stack.
- Trabalhando com JSON: Streaming eficiente de payloads com
json.NewDecoderejson.NewEncoder. - Cliente HTTP Profissional: Evitando vazamento de sockets através do consumo e fechamento de
resp.Body.