Diseño de APIs con interfaces, io y context

El diseño de librerías idiomáticas en Go se basa en una premisa fundamental: la abstracción debe ocurrir donde se consumen los servicios, no donde se implementan. Esto se traduce en el uso de interfaces mínimas en los puntos de acoplamiento (los parámetros de las funciones) y el uso de tipos concretos en los retornos.

Cuando diseñas una API, defines qué comportamiento esperas (una interfaz pequeña como io.Reader o http.Handler) en lugar de qué datos quieres recibir. Esto permite que tu código sea infinitamente extensible: cualquier estructura que implemente el método requerido será aceptada, ya sea un buffer en memoria, un socket de red o un archivo en disco. Esta flexibilidad se complementa con el uso de context.Context como primer parámetro en funciones de larga duración, permitiendo que el llamador controle el ciclo de vida (cancelación o timeout) de la operación sin que tú tengas que alterar la firma de tus funciones para pasar señales de parada.

Si aplicas este patrón, obtienes componentes altamente testeables y composibles mediante el wrapping (decoradores). Si lo aplicas mal, terminas con un código rígido, fuertemente acoplado a implementaciones específicas y con una jerarquía de tipos compleja que hace que el uso de la librería sea un dolor de cabeza para otros desarrolladores.

package main

import (
	"bytes"
	"context"
	"fmt"
	"io"
	"time"
)

// DataStream define el comportamiento mínimo para nuestro proceso.
// Al incluir io.Reader y io.Closer, estamos usando la composición de interfaces.
type DataStream interface {
	io.Reader
	io.Closer
}

// ProcessStats es un tipo concreto. Devolvemos esto para que el usuario
// no tenga que lidiar con una interfaz y pueda acceder a sus campos directamente.
type ProcessStats struct {
	Size     int64
	Duration time.Duration
}

// LoggingWriter es un decorador (wrapper). Implementa io.Writer
// añadiendo una funcionalidad de logging sin modificar la lógica original.
type LoggingWriter struct {
	destination io.Writer
}

func (lw LoggingWriter) Write(p []byte) (n int, err error) {
	fmt.Printf("[LOG] Escribiendo %d bytes...\n", len(p))
	return lw.destination.Write(p)
}

// RunPipeline es el núcleo de nuestra API.
// 1. Acepta interfaces (DataStream, io.Writer) para maximizar la extensibilidad.
// 2. Acepta context.Context como primer argumento para permitir la cancelación.
// 3. Devuelve un tipo concreto (ProcessStats) para facilitar el uso al cliente.
func RunPipeline(ctx context.Context, src DataStream, dst io.Writer) (ProcessStats, error) {
	start := time.Now()
	// Nos aseguramos de liberar el recurso al terminar.
	defer src.Close()

	// io.Copy es el ejemplo perfecto de abstracción: no le importa si dst es 
	// un LoggingWriter o un os.File, solo que implemente Write.
	n, err := io.Copy(dst, src)
	if err != nil {
		return ProcessStats{}, fmt.Errorf("error en el pipeline: %w", err)
	}

	// Verificamos si el contexto fue cancelado antes de finalizar con éxito.
	select {
	case <-ctx.Done():
		return ProcessStats{}, ctx.Err()
	default:
	}

	return ProcessStats{
		Size:     n,
		Duration: time.Since(start),
	}, nil
}

// MockSource es una implementación concreta para pruebas o ejemplos.
type MockSource struct {
	reader *bytes.Reader
}

func (m *MockSource) Read(p []byte) (n int, err error) { return m.reader.Read(p) }
func (m *MockSource) Close() error                  { return nil }

func main() {
	// El cliente controla la vida de la operación mediante el contexto.
	ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
	defer cancel()

	content := []byte("Datos críticos de producción")
	src := &MockSource{reader: bytes.NewReader(content)}

	// Componemos el destino: un buffer real envuelto en nuestro decorador.
	destBuffer := &bytes.Buffer{}
	wrappedDest := LoggingWriter{destination: destBuffer}

	stats, err := RunPipeline(ctx, src, wrappedDest)
	if err != nil {
		fmt.Printf("Error: %v\n", err)
		return
	}

	fmt.Printf("Resultado: %d bytes procesados en %v. Contenido: %s\n",
		stats.Size, stats.Duration, destBuffer.String())
}

En el ejemplo anterior, RunPipeline no conoce la existencia de MockSource ni de LoggingWriter; solo conoce los comportamientos Read, Write y Close. Esta es la esencia de la potencia de Go: el acoplamiento es mínimo.

Al usar LoggingWriter, hemos aplicado composición de comportamiento. LoggingWriter envuelve a cualquier io.Writer (en este caso, un *bytes.Buffer) y añade una funcionalidad extra. Esto es exactamente lo que hace net/http con los middlewares.

Un detalle crucial es el retorno de ProcessStats. Al devolver un struct concreto en lugar de una interfaz, evitamos el boxing (envoltorio de datos en la pila/heap) innecesario y permitimos que el compilador optimice mejor el código. El usuario recibe los datos que necesita de forma directa y eficiente.

Por último, el uso de context.Context en RunPipeline es la forma correcta de manejar la concurrencia y los límites de tiempo. Si el proceso de io.Copy fuera una operación de red lenta, el cliente podría cancelar el ctx y RunPipeline respondería respetando esa señal, evitando fugas de goroutines o bloqueos indefinidos.

El error frecuente

Un error clásico al trabajar con interfaces es devolver un tipo concreto que es nil envuelto en una interfaz que no es nil. Esto causa un pánico cuando el llamante intenta usar el método.

// MAL: Esto causará un panic al llamar a r.Read()
func GetBadReader() io.Reader {
    var r *MockSource = nil 
    return r // La interfaz NO es nil, contiene un tipo pero el valor es nil
}

func main() {
    r := GetBadReader()
    buf := make([]byte, 10)
    _, err := r.Read(buf) // PANIC: runtime error: invalid memory address or nil pointer dereference
}

Para evitarlo, siempre se debe retornar nil explícitamente si no hay un valor válido, o asegurarse de que el tipo concreto no sea un punilero antes de que se convierta en interfaz.

228

Dejar un comentario

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *

Scroll al inicio