Servlets, flujos de trabajo y servicios OSGi: tres piezas del stack Java empresarial

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étodoCuándo se ejecutaUso típico
initUna vez, antes de atender la primera peticiónAbrir recursos, leer configuración
serviceEn cada peticiónDelegar en doGet, doPost u otro verbo
destroyAl descargar la aplicación o el contenedorCerrar 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

ConceptoSignificado
Definición de procesoEl modelo del proceso, versionado, normalmente en BPMN 2.0
Instancia de procesoUna ejecución concreta de esa definición
Ejecución (execution)Una rama activa del grafo, que avanza por los nodos
VariableUn dato del proceso almacenado y versionado
Tarea humanaUn nodo que espera la acción de una persona
JobUn 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:

  • service declara el contrato bajo el que se publica el componente.
  • immediate = true hace que el servicio se registre en cuanto el componente se activa, sin esperar a que alguien lo solicite.
  • @Activate, @Modified y @Deactivate gestionan el arranque, los cambios de configuración y la parada.
  • La anotación @Reference con volatile describe la dependencia: el framework la inyecta, y si el servicio desaparece, la referencia pasa a null en 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 HttpSession desaparecen 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:

PiezaAporta
ServletEl contrato con HTTP: petición, respuesta y ciclo de vida corto
Flujo de trabajoProcesos persistentes, con pausas humanas, temporizadores y trazas
Servicio OSGiModularidad, 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