La interfaz fs.FS [disponible desde Go 1.16] es una abstracción de solo lectura que define cómo se accede a un sistema de archivos. En lugar de depender directamente de las llamadas al sistema operativo mediante el paquete os, tu código interactúa con esta interfaz. Internamente, esto funciona mediante una contractualidad mínima: cualquier tipo que implemente el método Open(name string) (fs.File, error) puede ser tratado como un sistema de archivos.
Esta separación es fundamental para la portabilidad y el testeo. Cuando usas fs.FS, tu lógica de negocio no sabe si está leyendo un archivo físico en un SSD, un archivo embebido en el binario mediante embed.FS, un recurso en una zona de memoria para tests con testing/fstest, o un subdirectorio específico del sistema operativo mediante os.DirFS. El beneficio principal es el desacoplamiento: puedes cambiar la fuente de los datos sin tocar una sola línea de la lógica de procesamiento. Sin embargo, si intentas usar una implementación de fs.FS para escribir o crear archivos (como con os.Create), el compilador te detendrá o la operación fallará, ya que la interfaz está diseñada estrictamente para lectura.
package main
import (
"embed"
"fmt"
"io/fs"
"testing/fstest"
)
// Usamos embed para incluir archivos dentro del binario al compilar.
// Para que esto funcione, debe existir un archivo en la ruta indicada.
//go:embed config.json
var embeddedFiles embed.FS
// App representa un servicio que depende de archivos de configuración.
// Al usar fs.FS en lugar de strings con rutas, permitimos inyectar
// cualquier implementación (real, embebida o mock).
type App struct {
ConfigFS fs.FS
}
// LoadConfig lee la configuración sin preocuparse de dónde vienen los archivos.
func (a *App) LoadConfig() ([]byte, error) {
// fs.ReadFile es un helper de io/fs que abre, lee y cierra el archivo.
return fs.ReadFile(a.ConfigFS, "config.json")
}
func main() {
// 1. Escenario de Producción: Usando archivos embebidos.
// El binario ya contiene el contenido de config.json.
appWithEmbed := &App{ConfigFS: embeddedFiles}
data, err := appWithEmbed.LoadConfig()
if err != nil {
fmt.Printf("Error con embed: %v (nota: requiere que config.json exista)\n", err)
} else {
fmt.Printf("Configuración embebida: %s\n", string(data))
}
// 2. Escenario de Test: Usando un filesystem en memoria (Mock).
// No necesitamos archivos reales en el disco para testear la lógica.
mockFS := fstest.MapFS{
"config.json": &fstest.MapFile{
Data: []byte(`{"status": "mocked"}`),
},
}
appWithMock := &App{ConfigFS: mockFS}
mockData, err := appWithMock.LoadConfig()
if err != nil {
fmt.Printf("Error en mock: %v\n", err)
} else {
fmt.Printf("Configuración mock: %s\n", string(mockData))
}
}
Desglose del ejemplo
En el código anterior, la estructura App no recibe una ruta (como /etc/app/config.json), sino una interfaz fs.FS. Esto es lo que permite la magia de la inyección de dependencias en el sistema de archivos.
En el primer escenario, embeddedFiles es un tipo embed.FS. Gracias a la directiva //go:embed, el compilador lee el archivo config.json y lo guarda dentro del binario. Cuando llamamos a fs.ReadFile(a.ConfigFS, "config.json"), la implementación de embed.FS busca en su memoria interna en lugar de ir al sistema de archivos del SO.
En el segundo escenario, demonstramos la potencia para testing usando fstest.MapFS. Este tipo implementa fs.FS y nos permite definir archivos “al vuelo” en memoria. En un test unitario, esto evita la latencia del disco, el problema de permisos de archivos temporales y la necesidad de limpiar archivos después de la ejecución.
Finalmente, observa el uso de fs.ReadFile. Es una función de utilidad del paquete io/fs que simplifica el flujo habitual de Open -> Read -> Close. Al pasarle nuestra interfaz, esta sabe automáticamente si debe usar el método Open de embed.FS o el de MapFS.
El error frecuente
Un error muy común al trabajar con fs.FS es intentar usar rutas absolutas o con un slash inicial. Las implementaciones de fs.FS (como embed.FS o fstest.MapFS) trabajan con rutas relativas a la raíz del filesystem y utilizan siempre la barra diagonal / como separador, independientemente de si estás en Windows o Linux.
// ESTO FALLARÁ
// Si el archivo está en la raíz de la FS, la ruta debe ser "config.json",
// NO "/config.json". El slash inicial es inválido para estas abstracciones.
data, err := fs.ReadFile(embeddedFiles, "/config.json")
if err != nil {
// Error: lstat /config.json: no such file or directory
}
Si necesitas trabajar con un subdirectorio del sistema de archivos real pero quieres limitar el alcance de tu aplicación a esa carpeta, utiliza os.DirFS. Esto crea una capa de abstracción que “encapsula” una ruta del sistema operativo y la convierte en la raíz de un fs.FS, impidiendo que la aplicación acceda a carpetas superiores.
N° 115