CodeGym /Cursos /Go SELF /embed + io/fs: fs.Sub y acceso unificado a recursos

embed + io/fs: fs.Sub y acceso unificado a recursos

Go SELF
Nivel 44, Lección 2
Disponible

1. Para que hace falta fs.Sub

Cuando el proyecto es pequeno, cadenas como "assets/help.txt" parecen inofensivas. Pero en cuanto hay mas de dos recursos, empiezan las clasicas “aventuras divertidas”: en un sitio escribieron "assets/help.txt", en otro "asset/help.txt", en un tercero "./assets/help.txt", y de pronto el programa solo funciona en la fase lunar “creciente, pero en dias pares”. Nuestro objetivo es conseguir que la estructura de recursos sea una sola, y que el resto del codigo no ande adivinando prefijos.

Imaginemos que tenemos una aplicacion educativa: un pequeno CLI de seguimiento de tareas, tasker. Antes podia leer/escribir tareas en un archivo, y ahora queremos anadir recursos incrustados: el texto de ayuda y una plantilla de “archivo de tareas de ejemplo”. La estructura del proyecto puede ser esta:

tasker/
  main.go
  assets/
    help.txt
    templates/
      sample_tasks.txt

Si escribimos "assets/..." por todas partes, eso se convierte en una “cadena magica” repartida por el proyecto. En Go, para este tipo de problemas hay una herramienta muy practica: fs.Sub.

2. fs.Sub: re-enraizamos el sistema de archivos

fs.Sub es una funcion que toma cualquier fs.FS y “corta” de el un subarbol, convirtiendo el directorio elegido en la nueva raiz. Suena abstracto, pero la idea es muy cotidiana: como si le hubieras dicho al codigo “considera que la carpeta assets/ es tu /”.

La firma se ve asi:

sub, err := fs.Sub(fsys, "assets")

Y a partir de ahi, dentro de sub, el archivo "help.txt" corresponde al original "assets/help.txt".

Esto resuelve dos problemas a la vez. Primero, dejamos de arrastrar el prefijo "assets/" por todo el codigo. Segundo, limitamos el acceso: si una funcion recibe sub, entonces fisicamente no puede leer por accidente algo “fuera de los recursos”. Es una seguridad muy agradable “por construccion”, no “por esperanza”.

3. Una entrada unica a los recursos: el paquete assets

Queremos que la directiva //go:embed y la estructura de rutas vivan en un solo sitio. El resto del codigo debe trabajar con algo simple: “dame el fs.FS de recursos” o “lee la ayuda”.

En proyectos reales esto se hace continuamente: por ejemplo, en los fuentes de pkgsite (el sitio de documentacion de Go) se menciona que anadir bibliotecas de terceros esta ligado a editar la linea //go:embed en fs.go.

Creemos el paquete assets (podemos ponerlo en internal/assets, pero para simplificar lo llamaremos simplemente assets).

Incrustamos el arbol de recursos

La idea es sencilla: necesitamos un embed.FS, porque hay varios archivos.

package assets

import "embed"

//go:embed assets/* assets/templates/*
var embedded embed.FS

Aqui es importante que //go:embed funciona solo con variables de nivel de paquete, no dentro de funciones: eso ya lo vimos. Y otro punto importante: los patrones hay que elegirlos con cuidado, o incrustaras “todo el archivo fotografico de la casa de 2007”, y luego te sorprendera el tamano del binario (el tamano del binario es, por cierto, todo un tema “religioso” en el mundo Go).

Devolvemos un FS “re-enraizado” mediante fs.Sub

Ahora hagamos el truco principal de la leccion: devolvamos al exterior un fs.FS, ya “recortado” hasta assets/.

package assets

import "io/fs"

func FS() (fs.FS, error) {
	return fs.Sub(embedded, "assets")
}

Fijate en el estilo: no devolvemos embed.FS, devolvemos fs.FS. Eso reduce el acoplamiento: el resto del codigo no necesita saber nada de embed; solo necesita un “sistema de archivos de solo lectura”.

4. Leemos recursos sin “rutas magicas”

Ahora queremos evitar que en main aparezcan cadenas como "templates/sample_tasks.txt" y mucho menos construcciones del tipo “bueno, aqui anadimos el prefijo y alli lo quitamos”. En buenos proyectos, las rutas de recursos suelen centralizarse.

Funcion HelpText()

La idea: la ayuda se necesita a menudo, y queremos que la llamada sea lo mas tonta posible (en el buen sentido).

package assets

import (
	"fmt"
	"io/fs"
)

func HelpText() (string, error) {
	fsys, err := FS()
	if err != nil {
		return "", err
	}

	b, err := fs.ReadFile(fsys, "help.txt")
	if err != nil {
		return "", fmt.Errorf("read help.txt: %w", err)
	}
	return string(b), nil
}

Aqui hay dos detalles practicos. Primero, fs.ReadFile devuelve []byte, y nosotros lo convertimos a string. Segundo, envolvemos el error para que arriba quede claro que se estaba leyendo exactamente.

Plantilla desde el subdirectorio templates/

Ahora anadamos una funcion que toma la plantilla del archivo de tareas desde templates/.

package assets

import (
	"fmt"
	"io/fs"
)

func SampleTasks() ([]byte, error) {
	fsys, err := FS()
	if err != nil {
		return nil, err
	}

	b, err := fs.ReadFile(fsys, "templates/sample_tasks.txt")
	if err != nil {
		return nil, fmt.Errorf("read sample_tasks.txt: %w", err)
	}
	return b, nil
}

Fijate: la ruta "templates/sample_tasks.txt" ya es una ruta relativa al FS “re-enraizado” (despues de Sub("assets")). Es decir, trabajamos como si assets/ fuera la raiz.

5. Por que filepath.Join puede “traicionar”

Ahora viene un tema con el que incluso los desarrolladores muy despiertos se tropiezan, porque las costumbres pesan mucho. Si antes has trabajado mucho con disco, la mano se te va sola a filepath.Join. Y eso es logico: construye la ruta “correctamente” segun el sistema operativo actual.

Pero las rutas dentro de FS en fs.FS no son rutas de SO. Para embed.FS y, en general, para la mayoria de implementaciones de fs.FS, la ruta es una cadena con el separador /. Incluso en Windows. Incluso si lo pides muy amablemente.

Para no confundirnos, tenemos que tener clara esta tabla:

Que construimos Con que lo unimos Separador Ejemplo
Ruta en el disco del SO
filepath.Join
depende del SO
C:\data\tasks.json
o
/home/me/tasks.json
Ruta dentro de
fs.FS
path.Join
siempre
/
templates/sample_tasks.txt

La idea aqui es esta: filepath es “para el sistema de archivos del SO”, y path es “para rutas tipo URL/FS”. Y embed.FS vive mas cerca de la segunda.

Demostracion del bug “parece correcto, pero no funciona”

Imaginemos que queremos leer una plantilla por nombre de archivo:

package assets

import (
	"io/fs"
	"path/filepath"
)

func ReadTemplateBad(name string) ([]byte, error) {
	fsys, _ := FS()

	// filepath.Join puede devolver "templates\sample_tasks.txt" en Windows
	p := filepath.Join("templates", name)
	return fs.ReadFile(fsys, p)
}

En Linux/macOS puedes tardar mucho en notar el problema (porque alli el separador tambien es /). Pero en Windows, de repente, obtendras “file does not exist”, aunque el archivo este incrustado con toda seguridad. Y entonces empiezas a sospechar de una conspiracion del compilador, y no de ti mismo (clasico).

La version correcta:

package assets

import (
	"io/fs"
	"path"
)

func ReadTemplateOK(name string) ([]byte, error) {
	fsys, _ := FS()

	// path.Join siempre usa "/"
	p := path.Join("templates", name)
	return fs.ReadFile(fsys, p)
}

Este pequeno detalle es una de las principales razones por las que en la leccion damos tanta atencion a fs.Sub y al “acceso unificado”: queremos que los errores de rutas queden localizados en un solo paquete, y no aparezcan por todo el proyecto.

6. Como integrarlo con el CLI tasker

Ahora vamos a construir el “puente” hacia la aplicacion, para que esto no quede en teoria abstracta. En tasker tenemos un comando help, que debe imprimir el help.txt incrustado. Y tenemos un comando init, que crea un archivo de tareas a partir de una plantilla incrustada (en disco, ya con os.WriteFile normal, porque el embedded FS es read-only).

Impresion de help en main

La idea: main debe ser aburrido. Cuanto mas aburrido sea main, menos probabilidades hay de que tengas que depurarlo a las 3 de la manana.

package main

import (
	"fmt"
	"os"

	"tasker/assets"
)

func main() {
	help, err := assets.HelpText()
	if err != nil {
		fmt.Fprintln(os.Stderr, "error:", err)
		os.Exit(1)
	}

	fmt.Print(help)
}

Fijate: main no sabe en absoluto donde esta help.txt, cual es su ruta, ni si usamos fs.Sub. Solo pide el texto.

Comando init: escribimos un archivo en disco a partir de una plantilla embedded

La idea: los recursos incrustados se usan a menudo como “valores por defecto” — configuraciones, plantillas, datos de demostracion. Es comodo: el usuario no tiene que recorrer el repositorio para encontrar un ejemplo.

package main

import (
	"fmt"
	"os"

	"tasker/assets"
)

func initTasksFile(dst string) error {
	b, err := assets.SampleTasks()
	if err != nil {
		return err
	}

	return os.WriteFile(dst, b, 0644)
}

Aqui lo bueno es que separamos con claridad dos mundos. La lectura de la plantilla va a traves de fs.FS (el mundo incrustado), y la escritura va a traves de os.WriteFile (el mundo del disco). Nada de mezclarlo todo.

Esquema: capa de recursos despues de fs.Sub

A veces es util visualizar que ocurre exactamente con la “raiz”. Aqui tienes un esquema simple:

flowchart TD
    A["embedded embed.FS
con la raiz del proyecto"] --> B["fs.Sub(embedded, 'assets')
sub-FS"] B --> C["fs.ReadFile(fsys, 'help.txt')"] B --> D["fs.ReadFile(fsys, 'templates/sample_tasks.txt')"]

El sentido del esquema es uno solo: despues de Sub, todas las rutas son mas cortas, y tu codigo depende menos de la estructura de directorios del proyecto fuente.

7. Errores tipicos al usar fs.Sub y acceder a recursos

Error n. 1: seguir escribiendo rutas con prefijo despues de fs.Sub.
Esto se ve asi: hiciste fs.Sub(embedded, "assets"), y luego sigues leyendo "assets/help.txt". El resultado sera “no encontrado”, y es logico: la nueva raiz ya es assets, no hace falta repetirla. Normalmente se arregla con disciplina: despues de Sub("assets") usamos rutas como "help.txt" y "templates/...".

Error n. 2: ignorar el error de fs.Sub.
fs.Sub devuelve (fs.FS, error) no por maldad. Si te equivocas en el nombre del directorio o el recurso no esta incrustado como crees, Sub te dira honestamente “no puedo”. Si ignoras err, puedes obtener errores raros mas tarde, en otro sitio, y perder tiempo preguntandote “por que no se puede leer help”. El enfoque correcto es comprobar err de inmediato y fallar con un mensaje comprensible en el limite de la aplicacion.

Error n. 3: usar filepath.Join para rutas dentro de fs.FS.
Es una de las trampas mas traicioneras, porque en sistemas tipo Unix el problema puede no aparecer en absoluto. Pero tu programa puede acabar en Windows (o simplemente en CI), y de repente romperse. Para las rutas dentro de fs.FS usa path.Join, y deja filepath.Join para trabajar con rutas del SO.

Error n. 4: repartir cadenas de rutas por el proyecto en lugar de centralizarlas en un paquete.
Cuando "templates/sample_tasks.txt" aparece en tres paquetes distintos, cualquier cambio de directorios se convierte en una mini migracion. Es mucho mejor que las rutas vivan en el paquete assets, y que hacia fuera solo asomen funciones como HelpText() y SampleTasks(). Entonces puedes cambiar la estructura de recursos casi sin tocar la logica de la aplicacion.

Error n. 5: considerar los recursos embedded como “secretos”.
Un archivo incrustado vive dentro del binario. Si ahi pones claves, tokens o contrasenas, considera que los has publicado. Aunque “nadie lo va a adivinar”, eso no es una estrategia de seguridad, sino una esperanza. Incrusta solo lo que puedas distribuir con seguridad.

1
Tarea
Go SELF, nivel 44, lección 2
Bloqueada
Ayuda portátil
Ayuda portátil
1
Tarea
Go SELF, nivel 44, lección 2
Bloqueada
Plantilla por nombre
Plantilla por nombre
1
Tarea
Go SELF, nivel 44, lección 2
Bloqueada
Vitrina de plantillas
Vitrina de plantillas
Comentarios
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION