Java EE / Jakarta EE

Jakarta Servlet 6: servlets, filtros, sesiones y la capa web

Principiante10 min de lecturaActualizado

Esta página se tradujo del inglés con ayuda de IA y se cotejó con el original. Leer la versión en inglés

Los servlets son la API HTTP de más bajo nivel de Jakarta EE y la base sobre la que se construyen Faces, Jakarta REST y Spring MVC. Esta guía explica cómo el contenedor asigna las peticiones a los servlets, el ciclo de vida y el modelo de hilos, cómo leer peticiones y escribir respuestas, y cómo encajan filtros, listeners, sesiones y procesamiento asíncrono, con ejemplos que compilan contra Servlet 6.0.

Lo que aprenderás

  • Explicar el ciclo de vida de un servlet y por qué debe ser thread-safe
  • Mapear URL a servlets y filtros con anotaciones o con web.xml
  • Leer parámetros, cabeceras, cuerpos y subidas multipart, y escribir respuestas con la codificación correcta
  • Usar HttpSession y las cookies correctamente y conocer sus implicaciones de seguridad
  • Aplicar filtros, listeners y procesamiento asíncrono a los aspectos transversales

Toda petición HTTP que llega a una aplicación Jakarta EE pasa por el contenedor de servlets, tanto si el código que acaba atendiéndola es una página Faces, un recurso de Jakarta REST, un controlador de Spring o un simple servlet. La especificación Servlet define cómo el contenedor recibe una petición, elige el componente que la va a procesar, expone la petición y la respuesta como objetos Java, sigue las sesiones y te permite interceptar el flujo. En un proyecto moderno rara vez escribirás un método doGet a mano, pero cuando te caiga encima un bug de orden de filtros, un problema de codificación de caracteres o un hallazgo de fijación de sesión, la respuesta está en esta capa.

El contenedor y el ciclo de una petición

Un contenedor de servlets (Tomcat, Jetty, Undertow dentro de WildFly, el motor web de Open Liberty, etc.) escucha en un puerto, parsea HTTP y, para cada petición, construye un HttpServletRequest y un HttpServletResponse. Después recorre las aplicaciones web desplegadas para encontrar aquella cuyo context path coincide (/shop para shop.war), elige el servlet cuyo URL pattern encaja mejor con el resto de la ruta, ejecuta los filtros aplicables y, por último, llama al método service del servlet en un hilo de su pool. Cuando ese método termina, la respuesta queda confirmada (committed) y el hilo vuelve al pool.

Los URL patterns siguen reglas de coincidencia fijas, que se aplican en este orden: coincidencia exacta (/orders/export), prefijo de ruta más largo (/orders/*), extensión (*.xhtml) y, por último, el servlet por defecto (/). El servlet por defecto sirve los archivos estáticos del WAR; el patrón /* no es lo mismo que /: lo captura todo, recursos estáticos incluidos, y es un error de configuración clásico.

Escribir un servlet

Un servlet es una clase que extiende jakarta.servlet.http.HttpServlet y sobrescribe los métodos correspondientes a los métodos HTTP que atiende. Desde Servlet 3.0 se registra con una anotación; web.xml es opcional.

package shop.web;

import java.io.IOException;
import jakarta.servlet.ServletException;
import jakarta.servlet.annotation.WebServlet;
import jakarta.servlet.http.HttpServlet;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;

@WebServlet(urlPatterns = "/orders/*", loadOnStartup = 1)
public class OrderServlet extends HttpServlet {

    @Override
    protected void doGet(HttpServletRequest req, HttpServletResponse resp)
            throws ServletException, IOException {
        String id = req.getPathInfo();            // "/42" for GET /shop/orders/42
        if (id == null || id.length() < 2) {
            resp.sendError(HttpServletResponse.SC_BAD_REQUEST, "order id required");
            return;
        }
        resp.setContentType("application/json");
        resp.setCharacterEncoding("UTF-8");        // must precede getWriter()
        resp.getWriter().printf("{\"id\":%s,\"status\":\"NEW\"}", id.substring(1));
    }

    @Override
    protected void doPost(HttpServletRequest req, HttpServletResponse resp)
            throws ServletException, IOException {
        String sku = req.getParameter("sku");     // form field or query parameter
        int qty = Integer.parseInt(req.getParameter("qty"));
        // ... create the order ...
        resp.setStatus(HttpServletResponse.SC_CREATED);
        resp.setHeader("Location", req.getContextPath() + "/orders/43");
    }
}

La clase base enruta GET a doGet, POST a doPost, y así sucesivamente; la implementación por defecto de cada uno devuelve 405 Method Not Allowed, de modo que los métodos no soportados se gestionan correctamente sin código adicional. HEAD se deriva automáticamente de doGet. OPTIONS y TRACE también tienen implementación por defecto; muchos equipos desactivan TRACE a nivel de servidor por motivos de seguridad.

Ciclo de vida e hilos

El contenedor instancia el servlet una sola vez (en la primera petición, o al arrancar si se indica loadOnStartup), llama a init(ServletConfig) una vez, después a service para cada petición, posiblemente desde decenas de hilos al mismo tiempo, y por último a destroy() cuando se retira la aplicación del servidor. De ahí se derivan tres consecuencias:

  1. Un servlet no debe guardar estado de la petición en sus campos. Un campo de instancia private Order current; es una condición de carrera que solo aparece bajo carga.
  2. Los recursos costosos y thread-safe (un DataSource, una plantilla ya parseada, un bean CDI inyectado) van en campos inicializados en init o por inyección.
  3. Desde Servlet 3.0, los servlets de una aplicación con CDI habilitado son objetivos de inyección, así que @Inject OrderService service; funciona; pero el servlet en sí no es un bean CDI y no tiene scope CDI.

La antigua interfaz SingleThreadModel, que pedía al contenedor serializar las llamadas, se marcó como obsoleta en Servlet 2.4 y por fin se eliminó en la 6.0; nunca resolvió el problema, porque las sesiones y los atributos de contexto seguían siendo compartidos.

Petición y respuesta en la práctica

HttpServletRequest te da el método, la URI por partes (getContextPath(), getServletPath(), getPathInfo(), getQueryString()), las cabeceras, las cookies, los parámetros y el cuerpo. Algunos puntos provocan bugs con regularidad:

  • Los parámetros combinan la query string y un cuerpo codificado como formulario. getParameter devuelve el primer valor; usa getParameterValues para nombres repetidos. Llamar a getParameter en un POST lee y consume el cuerpo, así que después ya no puedes leerlo como stream.
  • La codificación de un cuerpo de formulario es ISO-8859-1 por defecto si la petición no indica charset, algo que los navegadores suelen omitir. Define el valor por defecto una sola vez para toda la aplicación en lugar de llamar a setCharacterEncoding("UTF-8") en cada servlet:
<!-- web.xml, Servlet 4.0+ -->
<request-character-encoding>UTF-8</request-character-encoding>
<response-character-encoding>UTF-8</response-character-encoding>
  • Las subidas multipart requieren @MultipartConfig en el servlet (o su equivalente en web.xml); entonces req.getPart("file") devuelve un Part con un stream, un tamaño y el nombre del archivo enviado. Fija maxFileSize y maxRequestSize en límites razonables: por defecto no hay límite.
  • Los atributos de petición (setAttribute/getAttribute) son el espacio de trabajo de cada petición, y sirven para pasar datos de un filtro o servlet a una JSP o vista Faces a la que se hace forward.

Del lado de la respuesta, el orden importa: el estado, las cabeceras y el tipo de contenido deben fijarse antes de que la respuesta se confirme, lo que ocurre en cuanto se llena el buffer o haces flush. A partir de ahí, sendRedirect y setHeader se ignoran sin avisar o lanzan IllegalStateException. Las redirecciones (sendRedirect) devuelven al cliente un 302 con una URL nueva; los forwards (req.getRequestDispatcher("/WEB-INF/views/order.jsp").forward(req, resp)) transfieren el control a otro recurso dentro del servidor sin que el cliente se entere. Coloca las vistas bajo WEB-INF para que no se puedan pedir directamente.

Servlet 6.0 añade getRequestId(), getProtocolRequestId() (el id del stream HTTP/2) y getServletConnection(), muy útiles para correlacionar líneas de log con la capa de red.

Filtros y listeners

Un filtro envuelve el procesamiento de las peticiones que coinciden con él. Se ejecuta antes del servlet, puede decidir si la cadena continúa y ve la respuesta a la vuelta. Los filtros son la herramienta adecuada para comprobaciones de autenticación, logging de peticiones, compresión, cabeceras CORS y de seguridad, y para envolver la petición o la respuesta y cambiar su comportamiento.

import java.io.IOException;
import jakarta.servlet.*;
import jakarta.servlet.annotation.WebFilter;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;

@WebFilter(urlPatterns = "/*")
public class SecurityHeadersFilter implements Filter {

    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
            throws IOException, ServletException {
        var resp = (HttpServletResponse) response;
        resp.setHeader("X-Content-Type-Options", "nosniff");
        resp.setHeader("Referrer-Policy", "strict-origin-when-cross-origin");
        long start = System.nanoTime();
        try {
            chain.doFilter(request, response);   // hand over to the next filter or the servlet
        } finally {
            var req = (HttpServletRequest) request;
            System.out.printf("%s %s -> %d in %d ms%n", req.getMethod(), req.getRequestURI(),
                    resp.getStatus(), (System.nanoTime() - start) / 1_000_000);
        }
    }
}

Hay dos detalles con los que casi todo el mundo tropieza. Primero, los filtros declarados con anotaciones no tienen un orden definido; si el orden importa (autenticación antes que logging, por ejemplo), decláralos en web.xml, donde se ejecutan en orden de declaración, o usa un único filtro que delegue. Segundo, por defecto un filtro solo se invoca en los dispatches REQUEST; añade dispatcherTypes = {DispatcherType.REQUEST, DispatcherType.FORWARD} si también debe ver las peticiones reenviadas con forward, y DispatcherType.ERROR para el renderizado de páginas de error.

Los listeners reaccionan a eventos del contenedor. ServletContextListener (@WebListener) ejecuta código al arrancar y al parar la aplicación, el sitio tradicional para inicializaciones puntuales antes de que existiera el evento CDI @Observes @Initialized(ApplicationScoped.class). HttpSessionListener cuenta sesiones o limpia al expirar, y ServletRequestListener puede preparar un contexto por petición, como un MDC de logging.

Sesiones y cookies

HTTP no tiene estado; HttpSession te da estado en el servidor, indexado por un identificador que el contenedor envía al navegador, casi siempre como una cookie llamada JSESSIONID. req.getSession() crea una sesión si no existe; req.getSession(false) devuelve null en su lugar, que es lo que quieres en código que solo lee. Los atributos guardados en la sesión deben ser Serializable si el servidor persiste o replica las sesiones en un clúster.

Las reglas de seguridad de las sesiones están bien asentadas, y la API de servlets las soporta todas:

  • Regenera el id después del login con req.changeSessionId() (Servlet 3.1+) para frustrar la fijación de sesión.
  • Marca la cookie como HttpOnly y Secure para que los scripts no puedan leerla y nunca viaje en claro. Configúralo en web.xml bajo <session-config><cookie-config> o por código mediante ServletContext.getSessionCookieConfig().
  • Define SameSite. Servlet 6.0 añadió por fin Cookie.setAttribute("SameSite", "Lax") y el SessionCookieConfig.setAttribute equivalente, con lo que ya no hacen falta ajustes específicos de cada servidor.
  • Mantén un timeout corto e invalida la sesión al hacer logout con session.invalidate().
  • Mantén las sesiones pequeñas. El estado de sesión es memoria en el servidor y, en un clúster, tráfico de red con cada cambio. Guardar grafos de entidades o el contenido de archivos en la sesión es la causa más habitual de presión de memoria en este tipo de aplicaciones web.

La reescritura de URL (;jsessionid=… añadido a los enlaces) era la alternativa para clientes sin cookies; Servlet 6.0 eliminó la API isRequestedSessionIdFromUrl, y conviene desactivar el mecanismo con <tracking-mode>COOKIE</tracking-mode>, porque los ids en las URL se filtran a través de los logs y de las cabeceras Referer.

Procesamiento asíncrono

Por defecto, un hilo del contenedor queda ocupado durante toda la petición. Cuando el trabajo es lento porque espera a otra cosa —una llamada remota, una consulta larga a la base de datos, un mensaje que todavía no ha llegado—, ese hilo se desperdicia. Servlet 3.0 introdujo AsyncContext para que la petición pueda quedar aparcada y completarse más tarde desde otro hilo:

import java.io.IOException;
import jakarta.annotation.Resource;
import jakarta.enterprise.concurrent.ManagedExecutorService;
import jakarta.servlet.AsyncContext;
import jakarta.servlet.annotation.WebServlet;
import jakarta.servlet.http.*;

@WebServlet(urlPatterns = "/report", asyncSupported = true)
public class ReportServlet extends HttpServlet {

    @Resource                                     // Jakarta Concurrency: a container-managed executor
    ManagedExecutorService executor;

    @Override
    protected void doGet(HttpServletRequest req, HttpServletResponse resp) {
        AsyncContext ctx = req.startAsync();
        ctx.setTimeout(30_000);
        executor.submit(() -> {
            String report = buildReport();        // slow work off the request thread
            try {
                ctx.getResponse().setContentType("text/plain;charset=UTF-8");
                ctx.getResponse().getWriter().write(report);
            } catch (IOException ignored) {
            } finally {
                ctx.complete();                   // releases the request; forgetting this leaks it
            }
        });
    }
}

Todos los filtros de la cadena deben declarar también asyncSupported = true; si no, startAsync lanza una excepción. Servlet 3.1 añadió E/S no bloqueante (ReadListener/WriteListener) para hacer streaming de cuerpos grandes sin bloquear un hilo; @Suspended AsyncResponse y los Server-Sent Events de Jakarta REST se construyen sobre estos mecanismos, así que normalmente los usarás a través de esa API.

Gestión de errores y restricciones de seguridad

Las excepciones no capturadas y las llamadas a sendError se mapean a páginas de error declaradas en web.xml, por código de estado o por tipo de excepción:

<error-page>
  <error-code>404</error-code>
  <location>/WEB-INF/errors/not-found.jsp</location>
</error-page>
<error-page>
  <exception-type>java.lang.Throwable</exception-type>
  <location>/WEB-INF/errors/failure.jsp</location>
</error-page>

Sin ellas, el contenedor muestra su propia página, que en algunos servidores incluye un stack trace: una fuga de información que debes cerrar antes de pasar a producción.

La seguridad declarativa también vive en web.xml: los elementos <security-constraint> asocian URL patterns y métodos HTTP a roles, y <login-config> selecciona un método de autenticación (FORM, BASIC o un mecanismo de autenticación de Jakarta Security). Una restricción con <auth-constraint/> y sin roles deniega el acceso a todo el mundo, y una con <user-data-constraint>CONFIDENTIAL</user-data-constraint> obliga a usar HTTPS. Las comprobaciones por código usan req.isUserInRole("admin"), req.getUserPrincipal() y, desde Servlet 3.0, req.login, req.logout y req.authenticate. Ojo: las restricciones solo cubren los métodos que enumeras —o todos si no enumeras ninguno— y omitir un método ha permitido históricamente saltárselas, así que es preferible no enumerar ninguno (protegerlo todo) y usar <http-method-omission> de forma deliberada.

Páginas, Expression Language y qué fue de las vistas

Jakarta Server Pages (JSP) y la Jakarta Standard Tag Library siguen formando parte de la plataforma y siguen funcionando, pero son una tecnología de vistas heredada: se compilan a servlets, mezclan marcado y lógica con demasiada facilidad y Faces 4.0 ya no las admite como capa de vista. El Expression Language (EL 5.0) que popularizó JSP, en cambio, sigue muy vivo y lo usan Faces y los beans @Named de CDI. Para interfaces nuevas, normalmente elegirás entre Jakarta Faces, para UI de componentes renderizadas en el servidor, y un front-end JavaScript que hable con endpoints de Jakarta REST; ambos, igual que Spring MVC, son en el fondo servlets.

Errores frecuentes

  • Campos de instancia con datos de la petición. El síntoma es que, de vez en cuando, un usuario ve los datos de otro. Solución: solo variables locales y atributos de petición.
  • Escribir en la respuesta antes de decidir el estado. Una vez enviados los bytes, ya no puedes redirigir ni cambiar cabeceras. Calcula primero, escribe al final y mantén un buffer de respuesta suficiente para las páginas pequeñas.
  • Leer el cuerpo dos veces. Tras getParameter o getInputStream, el cuerpo ya no está. Si un filtro lo necesita, envuelve la petición en un HttpServletRequestWrapper que guarde los bytes en caché.
  • Mapear /* en un servlet de la aplicación. Se traga el CSS, las imágenes y los mapeos de los servlets de Faces o REST. Usa un prefijo (/api/*) o rutas exactas.
  • Confiar en el orden de las anotaciones para los filtros. Declara en web.xml los filtros sensibles al orden.
  • Sesiones hinchadas y cookies sin HttpOnly/Secure/SameSite. Aparecen en todos los informes de pruebas de penetración; configura la cookie una vez en web.xml.
  • Olvidar AsyncContext.complete(). La petición se queda colgada hasta que salta el timeout asíncrono y el contenedor registra un aviso; el cliente recibe un error tras una espera muy larga.

Puntos clave

  • El contenedor de servlets parsea HTTP, asocia la URL a un servlet, ejecuta la cadena de filtros y llama a service en un hilo del pool; Faces, Jakarta REST y Spring MVC se apoyan en todo esto.
  • Una única instancia de servlet atiende todas las peticiones a la vez, así que no guardes estado en los campos.
  • Fija el estado y las cabeceras antes de escribir el cuerpo; usa forward para el dispatch interno y redirect cuando el cliente deba ver una URL nueva.
  • Configura una sola vez en web.xml la codificación de peticiones y respuestas, los atributos de la cookie de sesión, las páginas de error y las restricciones de seguridad; usa anotaciones para los propios servlets, filtros y listeners.
  • Los filtros se ocupan del comportamiento transversal; decláralos en web.xml cuando su orden importe.
  • El procesamiento asíncrono libera hilos del contenedor durante el trabajo lento, pero todos los filtros de la cadena deben soportarlo y siempre debes llamar a complete().
  • Servlet 6.0 eliminó API heredadas, añadió soporte de SameSite mediante atributos de cookie e incorporó identificadores de petición y de conexión.

Fuentes y lecturas recomendadas

Preguntas frecuentes

¿Se comparte una única instancia de servlet entre todas las peticiones?
Sí. El contenedor crea una sola instancia por cada declaración de servlet y llama a su método service desde muchos hilos a la vez. Todo lo que guardes en un campo de instancia es estado compartido: mantén los datos de cada petición en variables locales o en atributos de la petición, y haz que cualquier campo compartido sea inmutable o esté bien sincronizado.
¿Sigo necesitando web.xml?
No para declarar servlets, filtros y listeners: desde Servlet 3.0 las anotaciones se encargan de eso. web.xml sigue siendo el sitio para lo que las anotaciones no pueden expresar o lo que cambia según el entorno: restricciones de seguridad, páginas de error, timeout de sesión, archivos de bienvenida, codificaciones por defecto y el orden de los filtros declarados en distintas librerías.
¿Qué cambió en Servlet 6.0 respecto a 5.0?
Servlet 6.0 eliminó API que llevaban años obsoletas —SingleThreadModel, HttpSessionContext, HttpUtils y los métodos del id de sesión en la URL—, añadió Cookie.setAttribute para atributos como SameSite, incorporó identificadores de petición e información de la conexión, y endureció el tratamiento de las URI para que las rutas ambiguas, como las barras codificadas, se rechacen por defecto.
¿Escribo servlets puros o uso un framework?
Para el código de aplicación, usa Jakarta REST para las API y Faces o un framework de front-end para las interfaces; ambos se apoyan en la API de servlets y se ocupan por ti del enrutamiento, la conversión y la negociación de contenido. Escribe servlets y filtros directamente para temas de infraestructura como autenticación, logging, endpoints de salud e integración con sistemas heredados.