Módulo 04: Servidores Web Nativos & Filosofía Hipermedia
Un servidor completo con la librería estándar, middlewares y plantillas HTML.
Fase 1: Arquitectura, configuración y web hipermedia · Lección 7 de 20
🎯 Objetivos
- Registrar rutas con métodos y comodines en
net/http. - Escribir middlewares como
http.Handler. - Renderizar HTML en el servidor con
html/template. - Arrancar y detener el servidor de forma correcta (timeouts y apagado ordenado).
1. El servidor mínimo
package main
import (
"fmt"
"log"
"net/http"
)
func main() {
mux := http.NewServeMux()
mux.HandleFunc("GET /{$}", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintln(w, "Hola desde Go")
})
log.Fatal(http.ListenAndServe(":8080", mux))
}
Todo gira en torno a dos tipos:
http.Request: la petición (método, URL, cabeceras, cuerpo, formulario).http.ResponseWriter: por donde escribes la respuesta (cabeceras → estado → cuerpo, en ese orden).
Y una interfaz fundamental:
type Handler interface {
ServeHTTP(http.ResponseWriter, *http.Request)
}
http.HandlerFunc adapta cualquier función con esa firma a Handler. Cada servidor, enrutador y middleware en Go es un Handler que envuelve o delega a otro.
2. Enrutamiento moderno (Go 1.22+)
El ServeMux estándar admite métodos, comodines y patrones; ya no necesitas un enrutador externo para la mayoría de aplicaciones:
mux := http.NewServeMux()
mux.HandleFunc("GET /tareas", listar) // solo GET
mux.HandleFunc("POST /tareas", crear)
mux.HandleFunc("GET /tareas/{id}", ver) // comodín de un segmento
mux.HandleFunc("DELETE /tareas/{id}", eliminar)
mux.HandleFunc("GET /archivos/{ruta...}", archivos) // resto de la ruta
mux.HandleFunc("GET /{$}", inicio) // SOLO la raíz exacta "/"
mux.Handle("GET /static/", http.StripPrefix("/static/", fileServer))
Lectura de datos de la petición:
func ver(w http.ResponseWriter, r *http.Request) {
idStr := r.PathValue("id") // /tareas/42 → "42"
id, err := strconv.ParseInt(idStr, 10, 64)
if err != nil {
http.Error(w, "id inválido", http.StatusBadRequest)
return
}
q := r.URL.Query().Get("pagina") // ?pagina=2
r.ParseForm() // formularios (POST)
titulo := r.FormValue("titulo") // campo de formulario o query
_ = q; _ = titulo; _ = id
}
Reglas de precedencia: gana el patrón más específico; GET también atiende HEAD; un método no permitido en una ruta existente devuelve automáticamente 405 Method Not Allowed.
Escribir respuestas:
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.WriteHeader(http.StatusCreated) // si no lo llamas, será 200
fmt.Fprint(w, "creado")
http.Redirect(w, r, "/tareas", http.StatusSeeOther) // 303: patrón Post/Redirect/Get
http.NotFound(w, r)
3. Middlewares
Un middleware es una función que recibe un Handler y devuelve otro, añadiendo comportamiento antes y/o después. Sirven para registro, autenticación, recuperación de errores, compresión, métricas…
type Middleware func(http.Handler) http.Handler
Un registro de peticiones:
func Logging(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
inicio := time.Now()
rec := &respuestaRegistrada{ResponseWriter: w, estado: http.StatusOK}
next.ServeHTTP(rec, r)
log.Printf("%s %s -> %d (%s)", r.Method, r.URL.Path, rec.estado, time.Since(inicio))
})
}
// Envoltorio para capturar el código de estado que escribe el handler.
type respuestaRegistrada struct {
http.ResponseWriter
estado int
}
func (r *respuestaRegistrada) WriteHeader(code int) {
r.estado = code
r.ResponseWriter.WriteHeader(code)
}
Recuperación de panics para que un error en un handler no tumbe la conexión:
func Recuperar(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
if rec := recover(); rec != nil {
log.Printf("panic: %v\n%s", rec, debug.Stack())
http.Error(w, "error interno", http.StatusInternalServerError)
}
}()
next.ServeHTTP(w, r)
})
}
Una utilidad para encadenarlos (el primero de la lista queda más externo):
func Encadenar(h http.Handler, mws ...Middleware) http.Handler {
for i := len(mws) - 1; i >= 0; i-- {
h = mws[i](h)
}
return h
}
// uso:
handler := Encadenar(mux, Recuperar, Logging)
Orden recomendado (de afuera hacia adentro): recuperación → ID de petición → registro → autenticación → handler.
4. Plantillas con html/template
html/template genera HTML y escapa automáticamente según el contexto (HTML, atributo, URL, JS), lo que neutraliza la mayoría de ataques XSS. Úsalo siempre en lugar de text/template para HTML.
Sintaxis esencial
{{.Titulo}} <!-- imprime un campo -->
{{if .Hecha}}✔{{else}}○{{end}} <!-- condicional -->
{{range .Tareas}} <!-- bucle; dentro, "." es cada elemento -->
<li>{{.Titulo}}</li>
{{else}}
<li>No hay tareas</li>
{{end}}
{{with .Usuario}}Hola {{.Nombre}}{{end}}
{{template "formulario" .}} <!-- invoca otra plantilla -->
{{- /* los guiones recortan espacios */ -}}
Estructura con layout base
internal/web/templates/base.html:
{{define "base"}}
<!doctype html>
<html lang="es">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{template "titulo" .}} — Tareas</title>
<link rel="stylesheet" href="/static/estilos.css">
</head>
<body>
<header><a href="/tareas">Tareas</a></header>
<main>{{template "contenido" .}}</main>
</body>
</html>
{{end}}
internal/web/templates/tareas.html:
{{define "titulo"}}Mis tareas{{end}}
{{define "contenido"}}
<h1>Mis tareas</h1>
<form method="post" action="/tareas">
<input name="titulo" placeholder="Nueva tarea" required>
<button>Añadir</button>
</form>
<ul id="lista">
{{range .Tareas}}
<li>{{if .Hecha}}<s>{{.Titulo}}</s>{{else}}{{.Titulo}}{{end}}</li>
{{else}}
<li>No hay tareas todavía.</li>
{{end}}
</ul>
{{end}}
Cargar las plantillas (incrustadas en el binario)
Se analiza cada página junto con el layout en su propio conjunto, para que los bloques titulo y contenido de una página no choquen con los de otra:
package web
import (
"embed"
"fmt"
"html/template"
"io/fs"
"net/http"
)
//go:embed templates/*.html
var plantillasFS embed.FS
type Vistas map[string]*template.Template
func CargarVistas() (Vistas, error) {
paginas, err := fs.Glob(plantillasFS, "templates/*.html")
if err != nil {
return nil, err
}
vistas := make(Vistas)
for _, p := range paginas {
if p == "templates/base.html" {
continue
}
t, err := template.ParseFS(plantillasFS, "templates/base.html", p)
if err != nil {
return nil, fmt.Errorf("analizando %s: %w", p, err)
}
vistas[p] = t
}
return vistas, nil
}
func (v Vistas) Render(w http.ResponseWriter, nombre string, datos any) {
t, ok := v["templates/"+nombre]
if !ok {
http.Error(w, "plantilla inexistente", http.StatusInternalServerError)
return
}
// Renderiza primero a un buffer: si falla, aún puedes enviar un 500 limpio.
var buf bytes.Buffer
if err := t.ExecuteTemplate(&buf, "base", datos); err != nil {
http.Error(w, "error al renderizar", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
buf.WriteTo(w)
}
(Importa también bytes.) //go:embed incluye los archivos dentro del binario: no hay que desplegar carpetas aparte.
5. El servidor completo con handlers
internal/web/servidor.go:
type Servidor struct {
svc *tarea.Servicio
vistas Vistas
}
func NuevoServidor(svc *tarea.Servicio, v Vistas) *Servidor {
return &Servidor{svc: svc, vistas: v}
}
func (s *Servidor) Rutas() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("GET /{$}", func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/tareas", http.StatusSeeOther)
})
mux.HandleFunc("GET /tareas", s.listar)
mux.HandleFunc("POST /tareas", s.crear)
mux.Handle("GET /static/", http.StripPrefix("/static/", http.FileServer(http.FS(estaticosFS()))))
return Encadenar(mux, Recuperar, Logging)
}
func (s *Servidor) listar(w http.ResponseWriter, r *http.Request) {
tareas, err := s.svc.Listar(r.Context())
if err != nil {
http.Error(w, "error interno", http.StatusInternalServerError)
return
}
s.vistas.Render(w, "tareas.html", map[string]any{"Tareas": tareas})
}
func (s *Servidor) crear(w http.ResponseWriter, r *http.Request) {
if _, err := s.svc.Crear(r.Context(), r.FormValue("titulo")); err != nil {
if errors.Is(err, tarea.ErrTituloVacio) {
http.Error(w, err.Error(), http.StatusUnprocessableEntity)
return
}
http.Error(w, "error interno", http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/tareas", http.StatusSeeOther) // Post/Redirect/Get
}
(estaticosFS() devolvería un fs.FS de otro //go:embed static/* análogo al de las plantillas.)
Observa el mapeo de errores en el borde: el dominio devuelve ErrTituloVacio; el adaptador HTTP decide que eso es un 422.
Arranque con timeouts y apagado ordenado
http.ListenAndServe sin configurar es peligroso: no tiene límites de tiempo y un cliente lento puede acaparar conexiones. Configura el servidor explícitamente:
srv := &http.Server{
Addr: fmt.Sprintf(":%d", cfg.Puerto),
Handler: web.NuevoServidor(svc, vistas).Rutas(),
ReadHeaderTimeout: 5 * time.Second,
ReadTimeout: cfg.TiempoLectura,
WriteTimeout: cfg.TiempoEscritura,
IdleTimeout: 60 * time.Second,
}
// Escucha señales de apagado (Ctrl+C, docker stop)
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
go func() {
log.Printf("escuchando en %s", srv.Addr)
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
log.Printf("error del servidor: %v", err)
stop()
}
}()
<-ctx.Done() // espera la señal
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
return srv.Shutdown(shutdownCtx) // termina las peticiones en curso y cierra
🧪 Práctica
Construye una app con rutas GET /tareas, POST /tareas y GET /tareas/{id}, un middleware de registro y una plantilla base con layout. Conecta el tarea.Servicio del Módulo 03.
Ver solución
Añade a Servidor el handler de detalle y la plantilla detalle.html:
mux.HandleFunc("GET /tareas/{id}", s.detalle)
func (s *Servidor) detalle(w http.ResponseWriter, r *http.Request) {
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil {
http.NotFound(w, r)
return
}
t, err := s.svc.Obtener(r.Context(), id) // añade Obtener al servicio
switch {
case errors.Is(err, tarea.ErrNoEncontrada):
http.NotFound(w, r)
case err != nil:
http.Error(w, "error interno", http.StatusInternalServerError)
default:
s.vistas.Render(w, "detalle.html", t)
}
}
{{define "titulo"}}{{.Titulo}}{{end}}
{{define "contenido"}}<h1>{{.Titulo}}</h1><p>Creada: {{.CreadaEn.Format "02/01/2006 15:04"}}</p>
<a href="/tareas">← Volver</a>{{end}}
Prueba con curl -i -X POST -d "titulo=Estudiar" localhost:8080/tareas y verás un 303; con curl -i localhost:8080/tareas/999 verás un 404.
⚠️ Errores comunes
- Llamar a
WriteHeaderdespués de escribir el cuerpo (no tiene efecto). - Olvidar
returntrashttp.Error: el código continúa y escribe dos respuestas. - Servidor sin timeouts (vulnerable a clientes lentos).
- Usar
text/templatepara HTML (sin escape automático). - Parsear las plantillas en cada petición: hazlo una vez al arrancar.
📌 Resumen
net/http+ServeMuxde Go 1.22 resuelven métodos, comodines y rutas.- Middleware =
func(http.Handler) http.Handler. html/templaterenderiza en el servidor con escape automático;go:embedempaqueta las plantillas.- Configura timeouts y apaga el servidor con
Shutdown.
✅ Autoevaluación
- ¿Qué devuelve el servidor si pides
POST /tareas/1y solo existeGET /tareas/{id}? - ¿Por qué se usa el estado
303tras unPOST? - ¿Qué ventaja da
html/templatesobretext/template? - ¿Por qué hay que renderizar a un buffer antes de escribir?
- ¿Qué hace
srv.Shutdown?
- Puedo explicar con mis palabras cada objetivo de esta lección.
- Mi servidor lista y crea tareas desde el navegador.
- Sé qué parte me costó más y la repasaré.