En Go, la potencia no reside en la complejidad, sino en la capacidad de composición. El paquete io es la máxima expresión de este principio. Sus interfaces fundamentales, io.Reader y io.Writer, no son simples contratos; son el sistema de tuberías que permite que un flujo de datos proveniente de una conexión TCP pueda ser procesado, filtrado y escrito en un archivo sin que el código que realiza la lógica de negocio tenga que saber qué es una conexión o un archivo.
Para entender esto, hay que mirar la firma de Read(p []byte) (n int, err error). Es una decisión de diseño magistral por una razón de rendimiento: el caller (el que llama) es quien proporciona el buffer p. Esto permite que el buffer se reutilice en un bucle, evitando que el recolector de basura (GC) trabaje de más con millones de pequeñas asignaciones de memoria.
io.Reader y io.Writer funcionan mediante el flujo de bytes: un Reader llena un buffer y un Writer lo vacía. Esto permite el streaming, es decir, procesar volúmenes de datos masivos con una huella de memoria constante y minúscula. Deberías usar estas interfaces siempre que trabajes con flujos de datos, ya sea que el origen sea un os.File, un net.Conn, o un simple bytes.Buffer. Si intentas cargar todo el contenido de un archivo en un []byte antes de procesarlo, estás ignorando la filosofía de Go y arriesgándote a un error de out of memory en producción.
Si implementas mal estas interfaces—por ejemplo, si ignoras el valor de retorno n (la cantidad de bytes realmente leídos) o si no manejas correctamente el error io.EOF—romperás la cadena de composición. Un Reader que mienta sobre cuántos bytes ha escrito en el buffer hará que los componentes que lo siguen (como un json.Decoder) procesen basura o fallen de forma errática.
package main
import (
"bytes"
"fmt"
"io"
"strings"
)
func main() {
// Simulamos múltiples fuentes de datos (pueden ser archivos, sockets, etc.)
fuente1 := strings.NewReader("Datos críticos: ")
fuente2 := strings.NewReader("información confidencial.")
// io.MultiReader concatena múltiples Readers como si fueran uno solo.
// Es una forma de composición pura: no copiamos los datos, solo delegamos la lectura.
multi := io.MultiReader(fuente1, fuente2)
// io.LimitReader nos permite poner un tope al flujo.
// Útil cuando solo queremos procesar los primeros N bytes de una respuesta.
limitado := io.LimitReader(multi, 22)
// io.TeeReader es un "espejo". Todo lo que leas de 'limitado'
// se escribirá automáticamente en 'logBuffer'.
var logBuffer bytes.Buffer
espejo := io.TeeReader(limitado, &logBuffer)
// Usamos un buffer de bytes como destino final.
destino := &bytes.Buffer{}
// io.Copy es el pegamento. Toma un Reader y lo conecta a un Writer.
// Internamente, gestiona un buffer temporal para mover los datos eficientemente.
n, err := io.Copy(destino, espejo)
if err != nil {
fmt.Printf("Error durante la copia: %v\n", err)
return
}
fmt.Printf("Bytes procesados: %d\n", n)
fmt.Printf("Resultado final: %s\n", destino.String())
fmt.Printf("Log capturado (TeeReader): %s\n", logBuffer.String())
}
Desglose del diseño
En el ejemplo anterior, observa cómo la complejidad aumenta de forma lineal, no exponencial.
Primero, multi (creado con io.MultiReader) actúa como un mediador. No lee los datos; simplemente, cuando alguien le pide bytes, él le pregunta a fuente1 y, cuando esta termina, le pregunta a fuente2.
Luego, limitado encapsula a multi. Su única responsabilidad es contar cuántos bytes han pasado. Si multi tiene 100 bytes pero limitado solo permite 22, el flujo se detiene exactamente en el byte 22, devolviendo un io.EOF.
La magia ocurre con espejo (io.TeeReader). Esta estructura es un decorador: toma un Reader (nuestro limitado) y un Writer (nuestro logBuffer). Cada vez que io.Copy llama al método Read de espejo, este lee los bytes del límite, los escribe en el log y, finalmente, los devuelve al llamador. Es una forma extremadamente eficiente de hacer “logging” de un stream sin necesidad de almacenar todo en memoria primero.
Finalmente, io.Copy es la pieza que cierra el circuito. No necesita que nosotros gestionemos un bucle for manual ni la creación de buffers intermedios; su implementación interna está optimizada para mover bytes de un Reader a un Writer de la forma más rápida posible, gestionando los errores y la señal de fin de archivo (EOF) por nosotros.
El error frecuente
Un error clásico cuando implementas tu propio io.Reader o usas uno manualmente es ignorar el parámetro n.
// MAL: Esto causará que proceses datos antiguos o basura
buf := make([]byte, 1024)
n, err := r.Read(buf)
// Si n es 50, pero usas buf[:1024], los 1000 bytes restantes
// son basura de la iteración anterior o ceros.
procesar(buf)
if err != nil { ... }
// BIEN: Siempre usa el slice re-encortado con n
n, err := r.Read(buf)
if n > 0 {
procesar(buf[:n]) // Solo procesamos lo que realmente se leyó
}
if err != nil { ... }
Si no cortas el slice con buf[:n], estarás trabajando con datos residuales de llamadas anteriores, lo cual es un bug difícil de rastrear que solo aparece cuando el tamaño de los paquetes de datos varía.
N° 80