Capítulo 17 · Caso de estudio: servidor HTTP
Llegamos al primero de dos casos de estudio que cierran el libro. La idea es ver, en un solo lugar, cómo las piezas de los capítulos anteriores encajan en algo que se parece a software real.
Este capítulo cubre un servidor HTTP: la familia de
problemas donde lo que pesa es la concurrencia, la
modularidad y la separación entre lógica de dominio e IO. El
capítulo 18 cubrirá el otro extremo del espectro de la
industria: un libro mayor contable, donde lo que pesa son
los tipos precisos (monedas con unidades), las invariantes de
negocio (contratos requires/ensures), y la inmutabilidad
estricta. Son dos casos del mismo lenguaje, con énfasis distintos.
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
parte “real” no es la lógica (que es simple), sino 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.
Tamaño del programa: unas 250 líneas, repartidas en cinco archivos.
17.1 La forma del programa
Antes del código, veamos 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
Son cinco archivos, y cada uno carga con una preocupación distinta:
dominio.kaies el centro. Tipos puros, sin efectos, sin IO. Lo que el dominio “es”: 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, responde al que pregunta. Por dentro la lógica de manipulación es pura (funciónprocesar), envuelta en un bucleActor.receive()que la conecta al mundo.persistencia.kaies otro actor. Recibe eventos (creación, borrado), los escribe a un archivo de log. Aislar el disco en un actor nos permite que el almacén siga 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. Cada módulo es
ortogonal: la lógica pura del dominio se puede testear 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
= Ok(String)
| Creado(Nota)
| NoEncontrado
| ErrorCliente(String)
| ErrorServidor(String)
Tres declaraciones. Una nota tiene id y cuerpo. Hay cuatro comandos que se pueden ejecutar contra el dominio (listar, obtener uno, crear, borrar). Hay cinco respuestas posibles, que mapean conceptualmente a códigos HTTP 200, 201, 404, 400 y 500.
Lo que no hay en este archivo: nada de HTTP, nada de fibras, nada de archivos. Si un día decides exponer la API por gRPC en vez de HTTP, este archivo no cambia. Si decides cambiar el almacenamiento de memoria a SQLite, este archivo no cambia. Es el invariante del programa.
El #[derive(Show)] sobre Nota es lo que nos permite
interpolar #{nota} en un string (cap. 9). Sin él, tendríamos
que escribir 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 actor
import dominio
pub type AlmacenMsg = Pregunta(dominio.Comando, Pid[AlmacenResp])
pub type AlmacenResp = Respuesta(dominio.Respuesta)
AlmacenMsg es lo que el almacén recibe; AlmacenResp es lo
que devuelve. El cliente, antes de mandar, abre su propio
mailbox con with_mailbox, mete el Pid en el mensaje, y
después espera la respuesta. Es 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.Ok(serializar_lista(cuerpos)), notas, next_id)
}
Obtener(id) ->
match buscar(notas, id) {
Some(n) -> (dominio.Ok(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.Ok("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 de esto es nuevo del cap. 17: son las construcciones del
cap. 5 (sum types y match) y el cap. 6 (funciones y pipelines)
puestas a trabajar.
Como procesar es pura, es directamente testeable:
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.Ok(c) -> c == "primera"
_ -> false
}
assert obtener_ok
}
No hay fibras ni IO ni sockets: solo lógica. Si el día de mañana queremos paralelizar la creación de notas, agregar índices, cambiar el algoritmo de búsqueda, todos los cambios pasan por esta función pura y se prueban aquí.
Encima de procesar viene el bucle del actor, que la
conecta a Actor.receive():
fn bucle(notas: [dominio.Nota], proximo_id: Int)
: Unit / Actor[AlmacenMsg] + Actor[AlmacenResp] {
match Actor.receive() {
Pregunta(comando, cliente) -> {
let (resp, notas_nuevas, id_nuevo) =
procesar(comando, notas, proximo_id)
Actor.send(cliente, Respuesta(resp))
bucle(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. Y la firma declara los dos
efectos que el actor produce: Actor[AlmacenMsg] para recibir,
Actor[AlmacenResp] para responder.
El helper arrancar arma todo:
pub fn arrancar() : Pid[AlmacenMsg] / Spawn + Cancel + Actor[AlmacenMsg] + Actor[AlmacenResp] {
spawn_actor(() => bucle([], 1))
}
Y 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() {
Respuesta(r) -> r
}
}
preguntar es lo que llaman los handlers HTTP del main:
“hazle esta pregunta al almacén y dame la respuesta”. Por
adentro es un send seguido de un receive. Lo expone como una
función simple, 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 actor
import fs.file
pub type Evento = Linea(String)
fn bucle(path: String) : Unit / Actor[Evento] + File {
match Actor.receive() {
Linea(s) -> {
file.append(path, s ++ "\n")
bucle(path)
}
}
}
pub fn arrancar(path: String)
: Pid[Evento] / Spawn + Cancel + Actor[Evento] + File {
file.write(path, "") # trunca al inicio
spawn_actor(() => bucle(path))
}
Aislar la escritura a archivo en su propio actor tiene dos beneficios:
- El almacén no espera al disco. Cuando el almacén procesa
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 al mismo tiempo.
En un sistema real, este actor tendría un mailbox Bounded(N, DropOldest) para protegerse de inundación. Aquí usamos el
mailbox predeterminado (Unbounded) por simplicidad del 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.Respuesta, dominio.Comando] {
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 contiene una
respuesta inmediata (404, 400). Esa convención mantiene la
firma compacta: o el request se traduce a un comando válido,
o tenemos la respuesta directamente.
Hay también 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 sin efectos, testeables con
strings de entrada y comparación de salida.
17.6 El main: armar todas las piezas
main.kai es el pegamento que arma las piezas:
import actor
import spawn
import fs.file
import dominio
import almacen
import persistencia
import net.tcp
import web
const PUERTO : Int = 8080
const PATH_LOG : String = "notas.log"
fn main() : Unit / Console + NetTcp + File + Spawn + Cancel + Actor[almacen.AlmacenMsg] + Actor[almacen.AlmacenResp] + Actor[persistencia.Evento] {
let almacen_pid = almacen.arrancar()
let log_pid = persistencia.arrancar(PATH_LOG)
match NetTcp.listen("0.0.0.0", PUERTO) {
Err(msg) -> println("error al levantar el servidor: " ++ msg)
Ok(listener) -> {
println("servidor escuchando en puerto #{PUERTO}")
aceptar_loop(listener, almacen_pid, log_pid)
}
}
}
Cuatro líneas de “negocio”:
- Arrancar el almacén (actor que mantiene las notas).
- Arrancar el persistor (actor que escribe el log).
- Abrir un socket TCP en el puerto.
- Entrar al bucle de aceptación.
La fila de efectos del main lista todo lo que el programa
usa: Console para imprimir, NetTcp para sockets, File para
escribir, Spawn + Cancel para fibras, Actor[X] para cada uno
de los tres canales de mensajes. La firma no oculta nada: si
el main hiciera más cosas, su fila crecería en consecuencia.
El bucle de aceptación abre un nursery y por cada conexión
nueva lanza una fibra. 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(() => manejar_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.
Y por cada conexión, el handler:
fn manejar_conexion(conn, almacen_pid, log_pid) {
let raw = leer_request(conn)
let resp = match web.parsear_request(raw) {
Err(msg) -> dominio.ErrorCliente(msg)
Ok(req) -> match web.enrutar(req) {
Err(r) -> r
Ok(comando) -> {
registrar(log_pid, comando)
almacen.preguntar(almacen_pid, comando)
}
}
}
NetTcp.send(conn, string_to_bytes(web.serializar_respuesta(resp)))
NetTcp.close(conn)
}
Lee bytes, parsea HTTP, enruta a un comando, registra en el log, consulta al almacén, serializa la respuesta, escribe al socket, cierra. Cada paso es una función pura o un mensaje a un actor. No aparece memoria compartida en ninguna parte, ni hace falta un solo lock.
17.7 Lo que está ocurriendo, en términos del libro
Vale enumerar qué piezas del libro se usan, 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 nada nuevo aquí en términos de sintaxis. Lo nuevo es la combinación: piezas pequeñas, ortogonales, encajando en un programa con responsabilidades reales.
17.8 Cómo extenderlo
Hay varias direcciones donde el lector puede llevar este programa para profundizar lo que aprendió:
- 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 efectoTimeaparece en la fila delbucle. - 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. Ninguna requiere cambiar 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
Hay un patrón claro en lo que acabamos de armar:
- 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 paralelizan el trabajo concurrente (cooperativamente). Una fibra por conexión. 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 tres.
El cap. 18 va a aplicar exactamente el mismo patrón a un dominio muy distinto (contabilidad financiera) y vas a ver cómo la estructura se mantiene aunque el problema cambie. El cierre del libro viene allá.