Pular para o conteúdo

Trilha de Go · parte 6 de 37

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

Tamanho da fonte:
Largura da coluna:

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
}
Anatomia io.Reader e io.Writer
figura 1 · Anatomia io.Reader e io.Writer

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.

Streaming io.Copy vs io.ReadAll
figura 2 · Streaming io.Copy vs io.ReadAll

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:

  1. Criar um arquivo temporário no mesmo diretório/ponto de montagem (os.CreateTemp).
  2. Gravar os dados via streaming.
  3. Chamar f.Sync() para forçar a descarga do cache do sistema operacional para os setores físicos da mídia de armazenamento.
  4. Fechar o descritor de arquivo.
  5. 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 / Uploadsio.ReadAll(r) ou os.ReadFile()io.Copy(dst, src) ou io.CopyBuffer
Limitação de EntradaLer tudo e validar len(data) > limitio.LimitReader(r, maxBytes)
Tratamento de ReadIgnorar bytes se err != nilSempre processar n > 0 antes de checar err
Escrita com BufferFechar arquivo sem Flush()defer writer.Flush() logo após criar bufio.NewWriter
Persistência de Dados CríticosGravar direto e confiar no SOEscrever em temp file, chamar Sync() e os.Rename
Varredura de Diretóriosfilepath.Walkfilepath.WalkDir (usa fs.DirEntry sem syscall extra)
Manipulação ConcorrenteGravar em arquivo temporário no discoio.Pipe() direto na memória com goroutines

↑ Voltar ao topo

Atalhos de teclado

Abrir busca
/
Alternar tema claro/escuro
t
Aula anterior na série
←
Próxima aula na série
→
Lista de atalhos
?