Servlets, flujos de trabajo y servicios OSGi: tres piezas del stack Java empresarial
En un sistema Java empresarial rara vez se trabaja con una sola tecnología. Una petición HTTP entra, se procesa con un Servlet, ese Servlet necesita la lógica de negocio que vive en otro módulo y, además, la operación puede tardar días: alguien tiene que aprobar el resultado antes de que el proceso continúe.
Ese escenario combina con frecuencia tres piezas que evolucionaron por separado y que hoy se encuentran en la misma arquitectura:
- Servlets como punto de entrada del protocolo HTTP.
- Flujos de trabajo como forma de modelar procesos que no caben en el ciclo de vida de una petición.
- Servicios OSGi como mecanismo para que los módulos se puedan registrar, descubrir y conectar en tiempo de ejecución.
Lo interesante no es usar las tres por separado, sino ver qué aporta cada una y dónde encaja el límite entre ellas.
Las tres piezas en una misma operación
Navegador
↓ petición HTTP
Servlet (Jakarta Servlet)
↓ busca un servicio en el registro
Servicio OSGi con la lógica de negocio
↓ instancia un proceso
Motor de workflows
↓ persiste el estado
Base de datos
La petición HTTP termina en unos pocos milisegundos. El proceso iniciado por el motor de workflows, en cambio, puede permanecer activo durante semanas. Esa diferencia de ciclo de vida es la razón por la que las tres piezas siguen conviviendo: cada una resuelve un problema distinto y ninguna sustituye a las otras dos.
Servlets: el punto de entrada HTTP
El contrato
Un Servlet es una clase que se invoca cuando llega una petición que coincide con su ruta. En el mundo Jakarta EE la anotación @WebServlet evita tener que escribir un descriptor de despliegue:
package com.ejemplo.web;
import jakarta.servlet.annotation.WebServlet;
import jakarta.servlet.http.HttpServlet;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.io.PrintWriter;
@WebServlet(urlPatterns = "/saludo", name = "saludoServlet")
public class SaludoServlet extends HttpServlet {
@Override
protected void doGet(HttpServletRequest request, HttpServletResponse response)
throws IOException {
String nombre = request.getParameter("nombre");
response.setContentType("text/plain;charset=UTF-8");
response.setStatus(HttpServletResponse.SC_OK);
try (PrintWriter out = response.getWriter()) {
out.println("Hola, " + (nombre != null ? nombre : "mundo"));
}
}
}
Todo el trabajo ocurre dentro de la petición: se leen los parámetros, se escribe el cuerpo de la respuesta y el objeto HttpServlet vuelve a quedar libre.
Un detalle de la API actual: desde la versión 5.0 el paquete es jakarta.servlet y no javax.servlet. En proyectos nuevos es la forma correcta de importarlo.
El ciclo de vida
El contenedor crea una sola instancia del Servlet y la reutiliza para todas las peticiones que llegan a esa ruta. El ciclo tiene tres momentos:
| Método | Cuándo se ejecuta | Uso típico |
|---|---|---|
init | Una vez, antes de atender la primera petición | Abrir recursos, leer configuración |
service | En cada petición | Delegar en doGet, doPost u otro verbo |
destroy | Al descargar la aplicación o el contenedor | Cerrar conexiones y liberar recursos |
Como hay una única instancia compartida por peticiones concurrentes, los datos de una petición no deben guardarse en atributos de la instancia:
// Incorrecto: dos peticiones simultaneas comparten el atributo
public class ServletIncorrecto extends HttpServlet {
private String usuarioActual;
}
Los datos que deben viajar entre peticiones van en el HttpServletRequest (alcance de la petición), en la HttpSession (por usuario) o en el ServletContext (para toda la aplicación).
Filtros
Un filtro se ejecuta alrededor de la petición, antes y después del Servlet. Es el lugar adecuado para concerns transversales como la autenticación, el registro de trazas o la codificación de caracteres:
package com.ejemplo.web;
import jakarta.servlet.Filter;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.ServletRequest;
import jakarta.servlet.ServletResponse;
import jakarta.servlet.annotation.WebFilter;
import jakarta.servlet.http.HttpServletRequest;
import java.io.IOException;
@WebFilter(urlPatterns = "/api/*")
public class AutenticacionFilter implements Filter {
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
throws IOException, ServletException {
HttpServletRequest http = (HttpServletRequest) request;
String token = http.getHeader("Authorization");
if (token == null || token.isBlank()) {
response.getWriter().write("Falta la cabecera Authorization");
return;
}
chain.doFilter(request, response);
}
}
El return sin llamar a chain.doFilter corta la cadena: la petición no llega al Servlet. Esa es la forma de bloquear una petición desde el filtro.
Programación asíncrona
Un Servlet también puede devolver el control antes de terminar la respuesta, útil cuando el trabajo depende de un recurso lento:
@WebServlet(urlPatterns = "/tareas/lentas", asyncSupported = true)
public class TareaLentaServlet extends HttpServlet {
@Override
protected void doGet(HttpServletRequest request, HttpServletResponse response) {
AsyncContext async = request.startAsync();
response.setContentType("text/plain;charset=UTF-8");
CompletableFuture
.supplyAsync(() -> calcularResultado())
.thenAccept(resultado -> {
try {
response.getWriter().write(resultado);
} catch (IOException e) {
throw new UncheckedIOException(e);
} finally {
async.complete();
}
});
}
}
El atributo asyncSupported = true es obligatorio: sin él el contenedor rechaza la llamada a startAsync.
Flujos de trabajo: procesos que sobreviven a la petición
Por qué no basta con un método
Un método Java se ejecuta de principio a fin dentro de una transacción y termina con un valor de retorno o una excepción. Eso no sirve para procesos que tienen pausas, esperas o decisiones tomadas por personas.
Un flujo de trabajo (workflow) representa el proceso como un grafo de estados persistente, donde cada estado es un nodo del grafo y las transiciones se evalúan conforme el proceso avanza. El motor guarda el estado en una base de datos, de modo que el proceso:
- puede esperar días a una aprobación humana,
- sobrevive a un reinicio del servidor,
- genera trazas de auditoría de quién hizo qué,
- puede tener temporizadores que lo reanudan sin intervención.
Conceptos del motor
| Concepto | Significado |
|---|---|
| Definición de proceso | El modelo del proceso, versionado, normalmente en BPMN 2.0 |
| Instancia de proceso | Una ejecución concreta de esa definición |
| Ejecución (execution) | Una rama activa del grafo, que avanza por los nodos |
| Variable | Un dato del proceso almacenado y versionado |
| Tarea humana | Un nodo que espera la acción de una persona |
| Job | Un trabajo diferido, por ejemplo un temporizador o un reintento |
Un proceso de ejemplo
El siguiente diagrama representa, de forma conceptual, el flujo de aprobación de un gasto:
Solicitud registrada
↓
¿El monto supera 1000?
├── No ──→ Aprobación del jefe directo
└── Sí ──→ Aprobación del jefe directo ──→ Aprobación de dirección
↓
Notificación
↓
Fin
En un motor real ese diagrama se declara en BPMN y el motor compila una implementación que guarda el estado en tablas propias. La petición HTTP solo necesita iniciar el proceso; el recorrido posterior ocurre en el motor.
La API del motor
Los motores más usados en Java son Flowable, Camunda 7 y jBPM, todos con una API de servicios muy parecida:
package com.ejemplo.workflow;
import org.flowable.engine.RuntimeService;
import org.flowable.engine.TaskService;
import org.flowable.task.api.Task;
import org.flowable.task.api.TaskQuery;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
public class GastoService {
private final RuntimeService runtimeService;
private final TaskService taskService;
public GastoService(RuntimeService runtimeService, TaskService taskService) {
this.runtimeService = runtimeService;
this.taskService = taskService;
}
public String registrarSolicitud(String solicitante, double monto) {
Map<String, Object> variables = new HashMap<>();
variables.put("solicitante", solicitante);
variables.put("monto", monto);
return runtimeService
.startProcessInstanceByKey("aprobacionGasto", variables)
.getId();
}
public List<String> tareasPendientesDe(String usuario) {
TaskQuery query = taskService.createTaskQuery()
.taskAssignee(usuario)
.orderByTaskCreateTime().desc();
return query.list().stream()
.map(Task::getName)
.toList();
}
public void aprobar(String tareaId, boolean aprobado) {
Map<String, Object> variables = new HashMap<>();
variables.put("aprobado", aprobado);
taskService.complete(tareaId, variables);
}
}
startProcessInstanceByKey devuelve el identificador de la instancia creada. Ese identificador es la referencia que une el proceso con la aplicación, y es lo que el Servlet devuelve al cliente.
Lógica dentro del proceso
Los nodos de tipo automático suelen delegar en clases Java, que reciben el contexto del proceso:
package com.ejemplo.workflow;
import org.flowable.engine.delegate.DelegateExecution;
import org.flowable.engine.delegate.JavaDelegate;
public class NotificarAprobacion implements JavaDelegate {
private final NotificacionService notificacionService;
public NotificarAprobacion(NotificacionService notificacionService) {
this.notificacionService = notificacionService;
}
@Override
public void execute(DelegateExecution execution) {
String destinatario = (String) execution.getVariable("destinatario");
String mensaje = (String) execution.getVariable("mensaje");
notificacionService.enviar(destinatario, mensaje);
execution.setVariable("notificadoEn", java.time.Instant.now().toString());
}
}
execution.setVariable deja el resultado en el proceso, de modo que cualquier nodo posterior puede leerlo. Las variables son el mecanismo de transferencia de datos entre nodos.
Temporizadores y reanudación
Un temporizador no es un Thread.sleep. El motor registra un job con una fecha de vencimiento y un JobExecutor lo ejecuta cuando llega el momento:
Proceso en espera
↓ el motor guarda el job con la fecha
Base de datos
↓ el JobExecutor detecta el job vencido
Proceso continúa en otro hilo
Lo mismo ocurre cuando alguien aprueba una tarea: el motor reanuda la instancia, normalmente en un hilo del pool del proceso, no en el hilo de la petición que aprobó. Esa es la diferencia estructural más importante frente a escribir la lógica en un Servlet: la ejecución de un proceso no está atada a ningún hilo concreto.
Servicios OSGi: modularidad y descubrimiento
Qué es OSGi
OSGi (Open Services Gateway) es un framework de modularidad para la JVM. Define cómo se empaqueta un módulo (un bundle), cómo se instala, cómo se inicia y cómo publica y consume servicios de otros módulos en tiempo de ejecución.
Las implementaciones más habituales son Equinox, Apache Felix y Karaf, esta última con una consola de línea de comandos muy cómoda para depurar.
Un bundle pasa por los siguientes estados:
INSTALLED → RESOLVED → STARTING → ACTIVE → STOPPING → UNINSTALLED
Solo en ACTIVE el código del bundle se ejecuta, y sus servicios están disponibles para el resto del framework.
El registro de servicios
Un bundle publica un servicio registrando un objeto en el registro común del framework:
package com.ejemplo.osgi;
import org.osgi.framework.BundleContext;
import org.osgi.framework.ServiceReference;
import org.osgi.framework.ServiceRegistration;
public class RegistroServicio {
public void publicar(BundleContext context, Calculadora calculadora) {
ServiceRegistration<Calculadora> registro =
context.registerService(Calculadora.class, calculadora, null);
System.out.println("Servicio registrado: " + registro.getReference());
}
public Calculadora obtener(BundleContext context) {
ServiceReference<Calculadora> referencia =
context.getServiceReference(Calculadora.class);
return context.getService(referencia);
}
}
La característica más importante del modelo es que los servicios son dinámicos: un bundle puede publicar un servicio, dejar de ofrecerlo y volver a ofrecerlo sin que el consumidor tenga que recompilarse.
Declarative Services
Escribir el registro a mano funciona, pero obliga a acordarse de desregistrar en stop. Declarative Services (DS) resuelve el ciclo de vida por nosotros usando anotaciones:
package com.ejemplo.osgi;
import org.osgi.service.component.annotations.Activate;
import org.osgi.service.component.annotations.Component;
import org.osgi.service.component.annotations.Deactivate;
import org.osgi.service.component.annotations.Modified;
import org.osgi.service.component.annotations.Reference;
import org.osgi.service.component.annotations.ReferenceCardinality;
import org.osgi.service.component.annotations.ReferencePolicyOption;
@Component(
service = Calculadora.class,
immediate = true,
property = {
"version=1.0",
"idioma=es"
}
)
public class CalculadoraImpl implements Calculadora {
@Reference(
cardinality = ReferenceCardinality.MANDATORY,
policyOption = ReferencePolicyOption.GREEDY
)
private volatile FuenteDeDatos fuente;
@Activate
void activar() {
fuente.conectar();
}
@Modified
void configuracionCambiada() {
fuente.recargar();
}
@Deactivate
void desactivar() {
fuente.desconectar();
}
}
Puntos clave de esta clase:
servicedeclara el contrato bajo el que se publica el componente.immediate = truehace que el servicio se registre en cuanto el componente se activa, sin esperar a que alguien lo solicite.@Activate,@Modifiedy@Deactivategestionan el arranque, los cambios de configuración y la parada.- La anotación
@Referenceconvolatiledescribe la dependencia: el framework la inyecta, y si el servicio desaparece, la referencia pasa anullen lugar de dejar un objeto inválido.
Las anotaciones se procesan en tiempo de compilación: el plugin de Maven para OSGi (bnd) genera la metadata OSGI-INF que el contenedor necesita para saber qué componentes existen.
El patrón Whiteboard
El problema clásico de OSGi es la dirección de la dependencia. Si el Servlet depende de la calculadora, la calculadora queda atrapada en el bundle del Servlet y desaparece con él.
El patrón whiteboard invierte esa dirección: se declara una extensión, normalmente una interfaz, y todos los proveedores registran en ella su implementación. El consumidor no depende de ningún proveedor concreto, pide al registro todas las implementaciones disponibles.
@Component(
service = Calculadora.class,
property = {"id=basica"}
)
public class CalculadoraBasica implements Calculadora {
@Override
public int sumar(int a, int b) {
return a + b;
}
}
Varios bundles pueden registrar implementaciones distintas de la misma interfaz, y un tercer bundle puede obtenerlas todas:
List<ServiceReference<Calculadora>> referencias = context.getServiceReferences(
Calculadora.class,
null
);
for (ServiceReference<Calculadora> referencia : referencias) {
Calculadora calculadora = context.getService(referencia);
System.out.println(calculadora.sumar(2, 3));
}
Cómo encajan las tres piezas
Publicar un Servlet como servicio OSGi
Un Servlet no tiene por qué desplegarse en un contenedor externo: la especificación HTTP Service de OSGi permite registrarlo desde el propio framework, y el HTTP Service Whiteboard lo hace declarativo:
@Component(
service = jakarta.servlet.Servlet.class,
property = {
"osgi.http.whiteboard.servlet.pattern=/gastos/*"
}
)
public class GastosServlet extends HttpServlet {
@Reference
private volatile GastoService gastoService;
@Override
protected void doGet(HttpServletRequest request, HttpServletResponse response)
throws IOException {
String usuario = request.getUserPrincipal().getName();
List<Gasto> pendientes = gastoService.pendientesDe(usuario);
response.setContentType("application/json;charset=UTF-8");
response.getWriter().write("{\"pendientes\":" + pendientes.size() + "}");
}
}
El Servlet deja de ser una clase más del bundle y pasa a ser un servicio registrado en el framework, con su ciclo de vida atado al bundle que lo declara.
Desde el Servlet hacia el motor de workflows
El Servlet no debe contener la lógica del proceso. Su trabajo se limita a recoger los datos de la petición, invocar el servicio de workflows y devolver una respuesta:
@WebServlet(urlPatterns = "/gastos", asyncSupported = true)
public class RegistroGastoServlet extends HttpServlet {
private GastoService gastoService;
public void setGastoService(GastoService gastoService) {
this.gastoService = gastoService;
}
@Override
protected void doPost(HttpServletRequest request, HttpServletResponse response)
throws IOException {
String solicitante = request.getParameter("solicitante");
double monto = Double.parseDouble(request.getParameter("monto"));
String procesoId = gastoService.registrarSolicitud(solicitante, monto);
response.setStatus(HttpServletResponse.SC_ACCEPTED);
response.setContentType("application/json;charset=UTF-8");
response.getWriter().write("{\"procesoId\":\"" + procesoId + "\"}");
}
}
El código 202 Accepted es la respuesta correcta: la petición se aceptó, pero el resultado final todavía no existe. El cliente guarda el procesoId y puede consultar el estado más adelante.
El ciclo completo
t0 El cliente envía la solicitud de gasto
t0 El Servlet crea la instancia del proceso y responde 202
t1 El proceso espera una tarea humana
t2 Alguien completa la tarea, quizá desde otra aplicación
t2 El motor reanuda el proceso y termina
Entre t0 y t2 no hay ningún hilo, ninguna petición abierta y ningún objeto en memoria manteniendo el estado. Todo está en la base de datos del motor de workflows. Por eso reiniciar el servidor no rompe los procesos pendientes.
Aspectos que suelen provocar errores
- Guardar estado de la petición en el Servlet. Al ser una instancia única compartida, cualquier atributo de instancia es compartido por todas las peticiones concurrentes.
- Usar las variables del proceso como caché. Las variables se guardan en la base de datos; meter en ellas datos de alto volumen o dumps completos penaliza el motor.
- Cachear un servicio OSGi sin comprobación. Un bundle puede detenerse en cualquier momento, y el consumidor debe reaccionar a la desaparición, no asumir que la referencia seguirá ahí.
- Confundir un flujo de trabajo con una transacción. Un proceso no es una transacción: no se deshace con un
rollback. Los errores de negocio se modelan como transiciones de estado, no como excepciones de base de datos. - Guardar en memoria el estado de un proceso largo. Un atributo de sesión o un
HttpSessiondesaparecen en un reinicio o en un balanceo. Si el proceso debe sobrevivir, su estado va en el motor de workflows, no en el contenedor web.
Conclusión
Las tres piezas responden a problemas distintos y se complementan:
| Pieza | Aporta |
|---|---|
| Servlet | El contrato con HTTP: petición, respuesta y ciclo de vida corto |
| Flujo de trabajo | Procesos persistentes, con pausas humanas, temporizadores y trazas |
| Servicio OSGi | Modularidad, descubrimiento y ciclo de vida dinámico entre módulos |
La combinación habitual es que el Servlet valide la entrada y devuelva una respuesta inmediata, el servicio OSGi aporte la lógica de negocio sin acoplar los módulos entre sí y el motor de workflows se encargue de todo lo que tarda más que una petición.
La idea que resume el diseño es separar claramente los tres tiempos: la petición termina pronto, el servicio vive mientras su bundle esté activo y el proceso permanece en la base de datos hasta que alguien o algo lo termine.
© Arturo Juárez Monroy