Capítulo 17 · Caso de estudio: servidor HTTP
Este es el primero de dos casos de estudio que cierran el libro. En él ves cómo las piezas de los capítulos anteriores encajan en algo que se parece a software real. Desconfío de los libros de lenguajes que nunca salen del ejemplo de juguete, así que quise cerrar este con dos programas que podrían estar en producción.
Este capítulo cubre un servidor HTTP, la familia de
problemas donde pesan la concurrencia, la modularidad y la
separación entre lógica de dominio e IO. El capítulo 18 cubre
el otro extremo de la industria, un libro mayor contable,
donde pesan los tipos precisos (monedas con unidades), las
invariantes de negocio (contratos requires/ensures) y la
inmutabilidad estricta.
El programa es un servidor HTTP de notas. Tiene una
interfaz HTTP simple (GET /notas, POST /notas,
GET /notas/<id>, DELETE /notas/<id>), mantiene las notas
en memoria y escribe cada cambio a un archivo de log. La
lógica es simple; lo interesante es cómo se arma: efectos en
las firmas, actores para encapsular estado, fibras para servir
conexiones concurrentes, módulos para separar dominio, parser
HTTP, almacenamiento, persistencia.
Son unas 440 líneas, repartidas en cinco archivos.
17.1 La forma del programa
Antes de leer el código, ubica las piezas y sus responsabilidades:
notas/
├── kai.toml # manifest del proyecto
├── main.kai # punto de entrada y bucle de accept
├── dominio.kai # tipos: Nota, Comando, Respuesta
├── almacen.kai # actor que guarda las notas
├── persistencia.kai # actor que escribe el log a disco
└── web.kai # parser y serializador HTTP mínimos
Cada archivo carga con una preocupación distinta:
dominio.kaies el centro. Tipos puros, sin efectos, sin IO: qué es una nota, qué comandos se pueden ejecutar, qué respuestas se pueden producir.almacen.kaies un actor. Recibe comandos, mantiene la lista de notas como estado interno y responde al que pregunta. Por dentro la lógica es pura (funciónprocesar), envuelta en un bucleActor.receive()que la conecta al mundo.persistencia.kaies otro actor. Recibe eventos (creación, borrado) y los escribe a un archivo de log. Con el disco aislado en un actor, el almacén sigue respondiendo aunque la escritura sea lenta.web.kaison funciones puras: parsear bytes HTTP en una estructuraReqHttp, traducir requests en comandos de dominio, serializar respuestas a bytes. Sin actores, sin IO.main.kaiarma todo: arranca los actores, levanta el socket TCP, abre un nursery, y por cada conexión nueva lanza una fibra que la maneja.
Esta separación es la forma natural en kaikai. Pruebas la
lógica del dominio sin arrancar fibras, el parser HTTP sin
abrir sockets, el almacén sin tocar el disco. El main solo
conecta las piezas.

Figura 17.1 · Arquitectura del servidor de notas. Cinco
módulos, dos actores (almacén y persistencia), una fibra
por conexión entrante. Los módulos puros (dominio.kai,
web.kai, cluster verde) no tienen efectos; los módulos
con estado (almacen.kai, persistencia.kai, cluster
rojo) esconden su mutación detrás de un mailbox; main.kai
es pegamento.
17.2 El dominio: tipos puros
Empezamos por el centro. dominio.kai:
#[derive(Show)]
pub type Nota = { id: Int, cuerpo: String }
pub type Comando
= Listar
| Obtener(Int)
| Crear(String)
| Borrar(Int)
pub type Respuesta
= Guardado(String)
| Creado(Nota)
| NoEncontrado
| ErrorCliente(String)
| ErrorServidor(String)
Tres declaraciones. Una nota tiene id y cuerpo. Hay cuatro comandos contra el dominio (listar, obtener uno, crear, borrar) y cinco respuestas posibles, que mapean conceptualmente a los códigos HTTP 200, 201, 404, 400 y 500.
En este archivo no hay HTTP, ni fibras, ni archivos. Si un día expones la API por gRPC en vez de HTTP, o cambias el almacenamiento de memoria a SQLite, este archivo no cambia. Es el invariante del programa.
El #[derive(Show)] sobre Nota te deja interpolar #{nota}
en un string (cap. 9). Sin él, escribirías un
impl Show for Nota a mano.
17.3 El almacén: actor con estado
almacen.kai define un actor que mantiene la lista de notas y
responde comandos. Su tipo de mensaje es el comando más el
Pid para responder:
import dominio
pub type AlmacenMsg = Pregunta(dominio.Comando, Pid[AlmacenResp])
pub type AlmacenResp = Respondido(dominio.Respuesta)
AlmacenMsg es lo que el almacén recibe; AlmacenResp es lo
que devuelve. El cliente abre su propio mailbox con
with_mailbox, mete el Pid en el mensaje y espera la
respuesta: el patrón request/reply del §14.5.
El corazón del módulo es la lógica pura de procesamiento:
pub fn procesar(c: dominio.Comando, notas: [dominio.Nota], next_id: Int)
: (dominio.Respuesta, [dominio.Nota], Int) {
match c {
Listar -> {
let cuerpos = list.map(notas, .cuerpo)
(dominio.Guardado(serializar_lista(cuerpos)), notas, next_id)
}
Obtener(id) ->
match buscar(notas, id) {
Some(n) -> (dominio.Guardado(n.cuerpo), notas, next_id)
None -> (dominio.NoEncontrado, notas, next_id)
}
Crear(cuerpo) -> {
let nueva = dominio.Nota { id: next_id, cuerpo: cuerpo }
(dominio.Creado(nueva), [nueva, ...notas], next_id + 1)
}
Borrar(id) ->
match buscar(notas, id) {
Some(_) -> {
let restantes = list.filter(notas, (n) => n.id != id)
(dominio.Guardado("borrada"), restantes, next_id)
}
None -> (dominio.NoEncontrado, notas, next_id)
}
}
}
Una sola función, sin efectos en su firma. Recibe el comando,
las notas actuales y el próximo id; devuelve la respuesta, la
nueva lista de notas y el nuevo próximo id. Pattern match
exhaustivo sobre las cuatro variantes de Comando, listas
construidas con [h, ...tail], list.map y list.filter:
nada nuevo, son las construcciones de los caps. 5 (sum types y
match) y 6 (funciones y pipelines) puestas a trabajar.
Como procesar es pura, la pruebas directamente:
test "crear y obtener" {
let (r1, n1, id1) = procesar(dominio.Crear("primera"), [], 1)
let creado_ok = match r1 {
dominio.Creado(_) -> true
_ -> false
}
assert creado_ok
assert id1 == 2
let (r2, _, _) = procesar(dominio.Obtener(1), n1, id1)
let obtener_ok = match r2 {
dominio.Guardado(c) -> c == "primera"
_ -> false
}
assert obtener_ok
}
No hay fibras ni IO ni sockets: solo lógica. Si mañana quieres paralelizar la creación de notas, agregar índices o cambiar el algoritmo de búsqueda, el cambio pasa por esta función pura y se prueba aquí.
Encima de procesar viene el bucle del actor, que la
conecta a Actor.receive():
pub fn almacen_loop(notas: [dominio.Nota], next_id: Int) : Unit / Actor[AlmacenMsg] + Actor[AlmacenResp] {
match Actor.receive() {
Pregunta(comando, cliente) -> {
let (resp, notas_nuevas, id_nuevo) = procesar(comando, notas, next_id)
Actor.send(cliente, Respondido(resp))
almacen_loop(notas_nuevas, id_nuevo)
}
}
}
Tres líneas de trabajo:
- Recibe una pregunta.
- Procesa (lógica pura).
- Responde y recursa con el estado nuevo.
La recursión por cola se compila a un loop (cap. 6), así que el
actor puede correr indefinidamente. La firma declara los dos
efectos del actor: Actor[AlmacenMsg] para recibir,
Actor[AlmacenResp] para responder.
El módulo no trae un helper que arranque el actor; de eso se
encarga el main con spawn_actor (§17.6). Lo que sí exporta
es un wrapper sincrónico para clientes:
pub fn preguntar(almacen: Pid[AlmacenMsg], c: dominio.Comando)
: dominio.Respuesta / Actor[AlmacenMsg] + Actor[AlmacenResp] + Cancel {
Actor.send(almacen, Pregunta(c, Actor.self()))
match Actor.receive() {
Respondido(r) -> r
}
}
Los handlers HTTP del main llaman a preguntar: “hazle esta
pregunta al almacén y dame la respuesta”. Por adentro es un
send seguido de un receive, expuesto como una función simple y
no como un protocolo abierto.
17.4 Persistencia: actor de escritura
persistencia.kai es más simple: un actor que recibe líneas de
log y las agrega a un archivo:
import fs.file
pub type Evento = Linea(String)
pub fn persistencia_loop(path: String) : Unit / Actor[Evento] + File {
match Actor.receive() {
Linea(s) -> {
file.append(path, s ++ "\n")
persistencia_loop(path)
}
}
}
pub fn reset_log(path: String) : Unit / File {
file.write(path, "")
}
Aislar la escritura en su propio actor tiene dos beneficios:
- El almacén no espera al disco. Al procesar un
Crear, manda un mensaje al actor de persistencia y vuelve a su trabajo. La escritura ocurre en otra fibra. - El orden de las escrituras está garantizado. Todos los eventos pasan por el mismo mailbox, que se procesa en orden FIFO. No hay races aunque varios handlers escriban al log a la vez.
En un sistema real, este actor tendría un mailbox Bounded(N, DropOldest) para protegerse de inundación. Aquí uso el
mailbox predeterminado (Unbounded) para simplificar el demo.
La decisión es explícita y vive en una sola línea, fácil de
cambiar.
17.5 Parser HTTP
web.kai es código puro de string manipulation. La pieza
central es enrutar, que traduce un request HTTP en un
comando del dominio:
pub fn enrutar(req: ReqHttp) : Result[dominio.Comando, dominio.Respuesta] {
if req.metodo == "GET" {
if req.path == "/notas" {
Ok(dominio.Listar)
} else {
enrutar_id(req.path, (id) => dominio.Obtener(id))
}
} else if req.metodo == "POST" {
if req.path == "/notas" {
Ok(dominio.Crear(req.cuerpo))
} else {
Err(dominio.NoEncontrado)
}
} else if req.metodo == "DELETE" {
enrutar_id(req.path, (id) => dominio.Borrar(id))
} else {
Err(dominio.NoEncontrado)
}
}
El tipo de retorno usa Result de forma poco ortodoxa: Ok
contiene un comando para ejecutar; Err, una respuesta
inmediata (404, 400). La firma queda compacta: o el request se
traduce a un comando válido, o ya tienes la respuesta.
Completan el módulo un parser de la primera línea HTTP
(GET /path HTTP/1.1) y un serializador que produce los bytes
de respuesta. Son funciones puras, que pruebas con strings de
entrada y comparación de salida.
17.6 El main: armar todas las piezas
main.kai es el pegamento:
import actor
import spawn
import loop
import net.tcp
import dominio
import almacen
import persistencia
import web
const PUERTO: Int = 8080
const PATH_LOG: String = "notas.log"
fn main() : Int / Stdout + NetTcp + File + Spawn + Cancel {
with_mailbox {
with_mailbox {
persistencia.reset_log(PATH_LOG)
nursery { n ->
let almacen_body: () -> Unit / Actor[almacen.AlmacenMsg] + Actor[almacen.AlmacenResp] = () => almacen.almacen_loop([], 1)
let log_body: () -> Unit / Actor[persistencia.Evento] + File = () => persistencia.persistencia_loop(PATH_LOG)
let almacen_pid = spawn_actor(almacen_body)
let log_pid = spawn_actor(log_body)
match NetTcp.listen("0.0.0.0", PUERTO) {
Err(msg) -> println("error al arrancar el servidor: " ++ msg)
Ok(listener) -> {
println("servidor escuchando en puerto #{PUERTO}")
forever(() => match NetTcp.accept(listener) {
Err(_) -> ()
Ok(conn) -> {
let _ = n.spawn(() => with_mailbox(() => atender_conexion(conn, almacen_pid, log_pid)))
()
}
})
}
}
}
}
}
0
}
Leído de afuera hacia adentro:
- Los dos
with_mailboxanidados le dan amainlos buzones que necesita para hablarle al almacén y al persistor. De paso son la razón de que la firma no liste ningúnActor[X]: un efecto manejado deja de estar en la fila. reset_logdeja el archivo de auditoría en blanco.- El
nurseryabre el scope donde van a vivir las fibras hijas. spawn_actorlevanta los dos actores, cada uno con su cuerpo declarado con tipo explícito.NetTcp.listenabre el socket yforeverentra al bucle de aceptación.
La fila de efectos del main lista lo que el programa deja
vivo al salir: Stdout para imprimir, NetTcp para sockets,
File para escribir, Spawn + Cancel para fibras. Si el
main hiciera más cosas, su fila crecería.
El bucle de aceptación lanza una fibra por cada conexión
nueva. Ojo: n no es un valor de tipo Nursery que pueda
viajar a otra función: el compilador
reescribe cada n.spawn(...) en Spawn.spawn(...)
etiquetado con el brand de este nursery, así que el spawn
tiene que aparecer léxicamente dentro del bloque. Por eso el
bucle accept va inline:
nursery { n ->
forever(() => match NetTcp.accept(listener) {
Err(_) -> ()
Ok(conn) -> {
let _ = n.spawn(() => with_mailbox(() => atender_conexion(conn, almacen_pid, log_pid)))
()
}
})
}
Cada conexión vive en su propia fibra. El nursery garantiza que cuando el bucle termine (porque alguien cancela el listener, o el programa recibe SIGINT), las fibras hijas también terminan. No hay handlers de conexión zombies.
Apagado ordenado: atrapar la señal
Ese “o el programa recibe SIGINT” separa un servidor de juguete de uno que puedes dejar corriendo. Por omisión, un Ctrl-C mata el proceso donde esté: a medio escribir un archivo, con conexiones a medio responder.
El efecto Signal convierte esa interrupción en algo que tu
programa decide cuándo atender:
# ejemplos/cap17/01_senal.kai
import effects.os
fn main() : Unit / Stdout + Signal = {
Signal.on(SigInt)
println("esperando Ctrl-C...")
let _s = Signal.await()
println("señal recibida, salgo ordenado")
}
Córrelo y manda un Ctrl-C: queda esperando hasta que la señal llegue, así que antes no hay salida que mostrar.
Signal.on(SigInt) se suscribe; Signal.await() suspende la
fibra hasta que llegue. Es un punto de yield como cualquier
otro: el resto del programa sigue corriendo mientras esta
fibra espera.
En un servidor, una fibra dedicada espera la señal y, al
recibirla, cancela el listener. La cancelación
desciende por el nursery y cada fibra de conexión corre su
handler de Cancel en su próximo punto de yield. El log no
depende de ese cierre: el actor de persistencia anexa cada
evento al archivo apenas lo recibe. Es el mismo mecanismo del
cap. 13, con la señal del sistema operativo como disparador.
Y por cada conexión, el handler:
fn atender_conexion(conn: Conn, almacen_pid: Pid[almacen.AlmacenMsg], log_pid: Pid[persistencia.Evento]) : Unit / NetTcp + Actor[almacen.AlmacenMsg] + Actor[almacen.AlmacenResp] + Actor[persistencia.Evento] + Cancel {
let raw = leer_request(conn)
let wire = manejar_request(raw, almacen_pid, log_pid)
let _ = NetTcp.send(conn, web.string_to_bytes(wire))
NetTcp.close(conn)
}
Son cuatro líneas porque el trabajo vive en manejar_request:
parsea HTTP, enruta a un comando, registra en el log, consulta
al almacén y serializa la respuesta. Aquí quedan las dos puntas
que tocan el socket, leer y escribir, más el cierre. Cada paso
es una función pura o un mensaje a un actor. No hay memoria
compartida en ninguna parte, ni un solo lock.
17.7 Lo que está ocurriendo, en términos del libro
Estas son las piezas del libro que usaste, una a una:
- Cap. 2 (pensar en kaikai): las funciones son
expresiones;
procesardevuelve una tupla en una sola expresión. - Cap. 4 (tipos compuestos): tuplas de retorno
(
(Respuesta, [Nota], Int)), records (Nota), listas con pattern de cabeza y cola. - Cap. 5 (sum types y match):
Comando,Respuesta,Eventoson sum types; los match cubren todas las variantes; la exhaustividad la verifica el compilador. - Cap. 6 (funciones y pipelines):
list.map,list.filtersobre la lista de notas; closures pasadas a esas funciones. - Cap. 7 (pruebas): tests sobre
procesarque verifican la lógica sin arrancar fibras. - Cap. 8 (módulos): cinco archivos cada uno con su
pub, imports entre ellos. - Cap. 9 (protocolos):
#[derive(Show)]para interpolar notas. - Cap. 12 (efectos): cada función declara su fila;
handleno aparece directamente porque loshandles viven dentro dewith_mailboxyspawn_actordel stdlib. - Cap. 13 (fibras):
nurserypara estructurar el bucle de aceptación; cada conexión es una fibra. - Cap. 14 (actores): el almacén y la persistencia son
actores;
with_mailboxen cada cliente;spawn_actorpara arrancarlos; mensajes tipados. - Cap. 16 (tooling):
kai run main.kailo arranca;kai testcorre los tests del móduloalmacen.
No hay sintaxis nueva. Lo nuevo es la combinación: piezas pequeñas y ortogonales encajando en un programa con responsabilidades reales.
17.8 Cómo extenderlo
Puedes llevar este programa en varias direcciones para profundizar lo que aprendiste:
- Persistencia con recovery. Hoy el log es write-only. Si el servidor se reinicia, las notas se pierden. Una extensión natural: al arrancar, leer el log y reconstruir el estado.
- Búsqueda por contenido. El comando
Obtenerbusca por id. Agregar unBuscar(String)que filtre por substring del cuerpo. La lógica pura va enprocesar; elmatchdelenrutarHTTP gana un brazo. - TTL por nota. Cada nota tiene una expiración. El
almacén, en cada
Obtener, verifica si la nota expiró y la borra si sí. El campocreated_atse agrega aNota; el efectoClockaparece en la fila dealmacen_loop. - Múltiples instancias. Hoy hay un solo almacén. Para un
servicio más grande, particionar las notas en varios actores
por hash del id. El
mainarranca N almacenes y enruta cada request al actor que corresponda. - Métricas. Un cuarto actor que recibe eventos
(
request_recibido,nota_creada,error_emitido) y acumula contadores. Elmainlo arranca, los handlers le mandan eventos, un endpointGET /metricaslee. - Test de integración. Un programa cliente que abre una conexión TCP al servidor, manda un request, lee la respuesta, verifica que sea lo esperado. Pone el servidor en un nursery, corre el cliente, cierra.
Cada una es una sesión de tarde, y ninguna cambia la estructura básica: un dominio puro, actores con estado, fibras para concurrencia, módulos para separación.
17.9 Lo que muestra este caso
Lo que armaste sigue un patrón claro:
- El dominio es puro.
Comando,Respuesta,procesar: tipos y funciones sin efectos. Se testean con entradas y salidas, sin arrancar nada. - Los actores envuelven el estado mutable. El almacén mantiene la lista de notas; el persistor mantiene el archivo de log. La mutación queda encerrada dentro de cada actor, invisible para el resto del programa.
- Las fibras atienden el trabajo concurrente. Una fibra por conexión, y el scheduler las reparte entre los hilos del sistema. El nursery garantiza que ninguna sobreviva al servidor.
- Los módulos separan responsabilidades. Cinco archivos para cinco temas, y cualquiera se puede reemplazar sin tocar los otros cuatro.
En el cap. 18 aplicas el mismo patrón a un dominio muy distinto, la contabilidad financiera, y la estructura se mantiene aunque el problema cambie.