Entrada/Salida y sistema de archivos con Go: datos, formatos y streams

Capítulo 7

Tiempo estimado de lectura: 9 minutos

+ Ejercicio

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ñade Close() 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

NecesidadOpciónCuándo usar
Archivo pequeñoos.ReadFileConfiguraciones, plantillas pequeñas, fixtures
Archivo grandeos.Open + io.ReaderLogs, CSV/JSONL, backups
Transformación en cadenaio.Reader→procesador→io.WriterFiltros, 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.

Continúa en nuestra aplicación.
  • Escuche el audio con la pantalla apagada.
  • Obtenga un certificado al finalizar.
  • ¡Más de 5000 cursos para que explores!
O continúa leyendo más abajo...
Download App

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.Readerio.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).

Ahora responde el ejercicio sobre el contenido:

¿Cuál enfoque mejora la testabilidad y mantenibilidad al procesar datos en streaming en Go?

¡Tienes razón! Felicitaciones, ahora pasa a la página siguiente.

¡Tú error! Inténtalo de nuevo.

Al depender de io.Reader/io.Writer, la lógica queda desacoplada del sistema de archivos y puede probarse con fuentes/destinos en memoria. La apertura de archivos, rutas y permisos se mantiene en una capa externa.

Siguiente capítulo

HTTP y APIs en Go: servicios web rápidos y mantenibles

Arrow Right Icon
Portada de libro electrónico gratuitaGo desde Cero: Programación Moderna, Rápida y Escalable
58%

Go desde Cero: Programación Moderna, Rápida y Escalable

Nuevo curso

12 páginas

Descarga la aplicación para obtener una certificación gratuita y escuchar cursos en segundo plano, incluso con la pantalla apagada.