Modelos mentales: archivos, streams y capas
En Go, casi todo lo que “entra” o “sale” se puede tratar como un stream: un flujo de bytes que se lee o se escribe de forma secuencial. Un archivo en disco, la entrada estándar, una conexión de red o un buffer en memoria pueden exponerse como interfaces del paquete io.
io.Reader: produce bytes (Read(p []byte)).io.Writer: consume bytes (Write(p []byte)).io.ReadCloser/io.WriteCloser: añadeClose()para liberar recursos.io.Seeker: permite moverse dentro del stream (típico de archivos).
La idea clave para código mantenible y testeable: separar IO de lógica de negocio. Tu lógica debería operar sobre io.Reader/io.Writer (o estructuras ya parseadas), y dejar a otra capa la responsabilidad de abrir archivos, manejar rutas y permisos.
Lectura y escritura de archivos con os e io
Abrir, crear y cerrar correctamente
El paquete os te da acceso al sistema de archivos. Siempre que abras un recurso, ciérralo con defer tan pronto como sea posible.
package main
import (
"fmt"
"io"
"os"
)
func copyFile(dstPath, srcPath string) error {
src, err := os.Open(srcPath)
if err != nil {
return fmt.Errorf("open src: %w", err)
}
defer src.Close()
dst, err := os.Create(dstPath) // truncará si existe
if err != nil {
return fmt.Errorf("create dst: %w", err)
}
defer dst.Close()
if _, err := io.Copy(dst, src); err != nil {
return fmt.Errorf("copy: %w", err)
}
return nil
}
io.Copy es un patrón eficiente: usa buffers internos y evita cargar todo en memoria. Para archivos grandes, es preferible a os.ReadFile/os.WriteFile.
Lectura completa vs streaming
| Necesidad | Opción | Cuándo usar |
|---|---|---|
| Archivo pequeño | os.ReadFile | Configuraciones, plantillas pequeñas, fixtures |
| Archivo grande | os.Open + io.Reader | Logs, CSV/JSONL, backups |
| Transformación en cadena | io.Reader→procesador→io.Writer | Filtros, conversores, pipelines |
Buffers y patrones eficientes con bufio
bufio reduce llamadas al sistema operativo y mejora rendimiento al leer/escribir en bloques. Además, ofrece utilidades como lectura por líneas.
- Escuche el audio con la pantalla apagada.
- Obtenga un certificado al finalizar.
- ¡Más de 5000 cursos para que explores!
Descargar la aplicación
Leer por líneas (ideal para logs/JSONL)
package main
import (
"bufio"
"fmt"
"io"
)
func countNonEmptyLines(r io.Reader) (int, error) {
sc := bufio.NewScanner(r)
count := 0
for sc.Scan() {
if len(sc.Bytes()) > 0 {
count++
}
}
if err := sc.Err(); err != nil {
return 0, fmt.Errorf("scan: %w", err)
}
return count, nil
}
Nota práctica: Scanner tiene un límite de tamaño de token (por defecto ~64K). Si procesas líneas muy largas, ajusta el buffer:
sc := bufio.NewScanner(r)
buf := make([]byte, 0, 64*1024)
sc.Buffer(buf, 1024*1024) // hasta 1MB por línea
Escritura buffered
package main
import (
"bufio"
"fmt"
"io"
)
func writeLines(w io.Writer, lines []string) error {
bw := bufio.NewWriter(w)
for _, s := range lines {
if _, err := fmt.Fprintln(bw, s); err != nil {
return fmt.Errorf("write line: %w", err)
}
}
if err := bw.Flush(); err != nil {
return fmt.Errorf("flush: %w", err)
}
return nil
}
JSON en Go: serialización, deserialización y validación
El paquete encoding/json permite convertir structs a JSON (marshal) y JSON a structs (unmarshal). Para sistemas reales, necesitas además: validación, control de campos desconocidos y manejo de errores con contexto.
Definir un modelo con tags
package model
type User struct {
ID string `json:"id"`
Email string `json:"email"`
Age int `json:"age"`
}
Validación sin librerías externas
Una estrategia simple es añadir un método Validate() en el modelo. Esto mantiene la validación cerca de los datos y facilita testearla.
package model
import "fmt"
func (u User) Validate() error {
if u.ID == "" {
return fmt.Errorf("id is required")
}
if u.Email == "" {
return fmt.Errorf("email is required")
}
if u.Age < 0 {
return fmt.Errorf("age must be >= 0")
}
return nil
}
Decodificación robusta: rechazar campos desconocidos
Por defecto, json.Unmarshal ignora campos extra. En pipelines de datos conviene detectarlos para evitar “basura silenciosa”. Usa json.Decoder con DisallowUnknownFields.
package codec
import (
"encoding/json"
"fmt"
"io"
)
func DecodeStrict[T any](r io.Reader, dst *T) error {
dec := json.NewDecoder(r)
dec.DisallowUnknownFields()
if err := dec.Decode(dst); err != nil {
return fmt.Errorf("decode json: %w", err)
}
// Opcional: asegurar que no haya datos extra después del JSON
if dec.More() {
return fmt.Errorf("decode json: extra data")
}
return nil
}
Codificación (pretty vs compacto)
package codec
import (
"encoding/json"
"fmt"
"io"
)
func EncodeJSON(w io.Writer, v any, pretty bool) error {
enc := json.NewEncoder(w)
if pretty {
enc.SetIndent("", " ")
}
if err := enc.Encode(v); err != nil {
return fmt.Errorf("encode json: %w", err)
}
return nil
}
Manejo de errores por capas: envolver, clasificar y contextualizar
En un programa con IO, los errores pueden venir de muchas fuentes: permisos, rutas, datos corruptos, validación, etc. Un patrón útil es:
- Capa IO: abre archivos/streams y envuelve errores con contexto (
fmt.Errorf("...: %w", err)). - Capa de parsing: convierte bytes a estructuras y devuelve errores de formato.
- Capa de dominio: valida reglas del negocio y devuelve errores semánticos.
Además, puedes definir errores centinela para clasificar fallos y decidir acciones (reintentar, omitir registro, abortar).
package errs
import "errors"
var (
ErrInvalidData = errors.New("invalid data")
)
package domain
import (
"errors"
"fmt"
"myapp/errs"
"myapp/model"
)
func ValidateUser(u model.User) error {
if err := u.Validate(); err != nil {
return fmt.Errorf("%w: %v", errs.ErrInvalidData, err)
}
if !errors.Is(errs.ErrInvalidData, errs.ErrInvalidData) {
// ejemplo de uso de errors.Is en capas superiores
}
return nil
}
Construir un procesador de datos: filtrar y convertir en streaming
Vamos a construir un mini pipeline que:
- Lee un archivo JSONL (una entidad JSON por línea) desde un
io.Reader. - Decodifica cada línea a
User. - Valida y filtra (por ejemplo,
Age >= 18). - Escribe el resultado como JSONL a un
io.Writer.
Este enfoque es eficiente porque procesa registro por registro, sin cargar todo el archivo en memoria.
Paso 1: definir una función pura de transformación
La lógica de negocio idealmente no sabe nada de archivos. Solo recibe un User y decide qué hacer.
package domain
import "myapp/model"
func AcceptAdult(u model.User) bool {
return u.Age >= 18
}
Paso 2: construir el pipeline IO (Reader → Writer)
package pipeline
import (
"bufio"
"bytes"
"encoding/json"
"fmt"
"io"
"myapp/domain"
"myapp/errs"
"myapp/model"
)
type Stats struct {
Read int
Written int
Rejected int
}
func FilterUsersJSONL(r io.Reader, w io.Writer) (Stats, error) {
sc := bufio.NewScanner(r)
// Ajuste por si hay líneas grandes
sc.Buffer(make([]byte, 0, 64*1024), 1024*1024)
bw := bufio.NewWriter(w)
defer bw.Flush()
var st Stats
for sc.Scan() {
st.Read++
line := sc.Bytes()
line = bytes.TrimSpace(line)
if len(line) == 0 {
continue
}
var u model.User
dec := json.NewDecoder(bytes.NewReader(line))
dec.DisallowUnknownFields()
if err := dec.Decode(&u); err != nil {
st.Rejected++
// En pipelines a veces conviene continuar; aquí devolvemos error con contexto
return st, fmt.Errorf("line %d: decode: %w", st.Read, err)
}
if err := u.Validate(); err != nil {
st.Rejected++
return st, fmt.Errorf("line %d: %w: %v", st.Read, errs.ErrInvalidData, err)
}
if !domain.AcceptAdult(u) {
st.Rejected++
continue
}
b, err := json.Marshal(u)
if err != nil {
return st, fmt.Errorf("line %d: marshal: %w", st.Read, err)
}
if _, err := bw.Write(append(b, '\n')); err != nil {
return st, fmt.Errorf("write: %w", err)
}
st.Written++
}
if err := sc.Err(); err != nil {
return st, fmt.Errorf("scan: %w", err)
}
if err := bw.Flush(); err != nil {
return st, fmt.Errorf("flush: %w", err)
}
return st, nil
}
Observa cómo la función recibe io.Reader y io.Writer: esto permite testearla con strings.NewReader y bytes.Buffer sin tocar el disco.
Paso 3: conectar el pipeline con el sistema de archivos
Esta capa se encarga de rutas, permisos y creación de archivos. Mantén esta parte pequeña.
package main
import (
"fmt"
"os"
"myapp/pipeline"
)
func main() {
in, err := os.Open("users.jsonl")
if err != nil {
panic(fmt.Errorf("open input: %w", err))
}
defer in.Close()
out, err := os.Create("adults.jsonl")
if err != nil {
panic(fmt.Errorf("create output: %w", err))
}
defer out.Close()
st, err := pipeline.FilterUsersJSONL(in, out)
if err != nil {
panic(err)
}
_ = st // úsalo para métricas/logs
}
Pruebas fáciles gracias a la separación IO vs dominio
Al depender de interfaces io.Reader/io.Writer, puedes escribir tests sin archivos temporales. Ejemplo (esqueleto):
package pipeline_test
import (
"bytes"
"strings"
"testing"
"myapp/pipeline"
)
func TestFilterUsersJSONL(t *testing.T) {
input := strings.NewReader("{" + "\"id\":\"1\",\"email\":\"a@a.com\",\"age\":20" + "}\n" +
"{" + "\"id\":\"2\",\"email\":\"b@b.com\",\"age\":10" + "}\n")
var out bytes.Buffer
st, err := pipeline.FilterUsersJSONL(input, &out)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if st.Written != 1 {
t.Fatalf("expected 1 written, got %d", st.Written)
}
}
Operaciones comunes del sistema de archivos
Permisos, rutas y directorios
- Crear directorios:
os.MkdirAll(path, 0o755) - Unir rutas de forma portable:
filepath.Join(a, b) - Recorrer directorios:
filepath.WalkDir - Archivos temporales:
os.CreateTemp,os.MkdirTemp
package main
import (
"fmt"
"io"
"os"
"path/filepath"
)
func writeReport(dir string, name string, r io.Reader) (string, error) {
if err := os.MkdirAll(dir, 0o755); err != nil {
return "", fmt.Errorf("mkdir: %w", err)
}
path := filepath.Join(dir, name)
f, err := os.Create(path)
if err != nil {
return "", fmt.Errorf("create: %w", err)
}
defer f.Close()
if _, err := io.Copy(f, r); err != nil {
return "", fmt.Errorf("write report: %w", err)
}
return path, nil
}
Lectura/escritura atómica (patrón con archivo temporal)
Para evitar archivos corruptos si el proceso se interrumpe, escribe a un temporal y luego renombra (en la mayoría de sistemas, el rename es atómico dentro del mismo filesystem).
package main
import (
"fmt"
"os"
"path/filepath"
)
func writeFileAtomic(path string, data []byte, perm os.FileMode) error {
dir := filepath.Dir(path)
tmp, err := os.CreateTemp(dir, ".tmp-*")
if err != nil {
return fmt.Errorf("create temp: %w", err)
}
tmpName := tmp.Name()
defer os.Remove(tmpName)
if _, err := tmp.Write(data); err != nil {
tmp.Close()
return fmt.Errorf("write temp: %w", err)
}
if err := tmp.Chmod(perm); err != nil {
tmp.Close()
return fmt.Errorf("chmod temp: %w", err)
}
if err := tmp.Close(); err != nil {
return fmt.Errorf("close temp: %w", err)
}
if err := os.Rename(tmpName, path); err != nil {
return fmt.Errorf("rename: %w", err)
}
return nil
}
Diseño recomendado: paquetes y responsabilidades
Una estructura típica para este tipo de capítulo (sin imponer una arquitectura rígida) separa:
- model: structs y validación básica (
Validate()). - domain: reglas (filtros, transformaciones, decisiones).
- codec: parseo/serialización (JSON estricto, formatos).
- pipeline: orquestación streaming (
io.Reader→io.Writer), métricas, errores con contexto. - cmd/app o main: wiring de IO real (archivos, flags, stdin/stdout).
Con este enfoque, la mayor parte del código queda desacoplada del sistema de archivos, lo que facilita pruebas unitarias, reutilización y evolución del pipeline (por ejemplo, cambiar de archivo a stdin sin tocar la lógica).