Servlets y JSP: tutorial Jakarta EE 10 y Java 21

Durante años, el paquete javax.servlet fue la base de cualquier aplicación web Java. En 2019 Eclipse Foundation asumió la gestión de Jakarta EE y, con el traslado, todo el espacio de nombres cambió a jakarta.servlet. No es un cambio cosmético: si usas Tomcat 10.1 con una aplicación compilada contra javax.servlet, obtendrás errores de ClassNotFoundException en tiempo de ejecución. El código no arranca, sin más.

Este tutorial cubre Servlets y JSP con Jakarta EE 10 y Java 21, el stack que necesitas hoy para construir aplicaciones web Java sin frameworks de terceros, o para entender bien qué hace Spring MVC por debajo.

1. Jakarta EE 10 y el cambio de namespace

El cambio de javax.servlet a jakarta.servlet afecta a todas las clases del API: HttpServlet, HttpServletRequest, HttpServletResponse, Filter, HttpSession... Cualquier import antiguo con javax falla en Tomcat 10.1+. La migración es mecánica: un busca-y-reemplaza en los imports basta, pero hay que hacerla.

Tomcat 10.1 implementa el Servlet API 6.0, que va con Jakarta EE 10. Si tu proyecto usa Tomcat 9.x, sigue en el namespace javax. Si usas Tomcat 10.x, ya es jakarta. Nada más que saber.

¿Cuándo usar Servlets en lugar de controladores en Spring Boot, la evolución natural de los Servlets? Cuando el proyecto es pequeño, cuando quieres controlar cada byte de la respuesta sin capas de abstracción, o cuando aprendes cómo funciona HTTP en Java antes de subir al nivel de Spring. Los Servlets son lo que Spring MVC usa internamente: todo DispatcherServlet es un Servlet al fin y al cabo.

La dependencia Maven que necesitas:

<dependency>
    <groupId>jakarta.servlet</groupId>
    <artifactId>jakarta.servlet-api</artifactId>
    <version>6.0.0</version>
    <scope>provided</scope>
</dependency>

scope=provided porque Tomcat ya incluye la API en tiempo de ejecución. Si la empaquetas en el WAR, obtendrás conflictos de clases.

2. Ciclo de vida de un Servlet

Un Servlet vive en el contenedor (Tomcat) y pasa por tres fases: inicialización, servicio y destrucción.

  • init(): el contenedor lo llama una sola vez al cargar el Servlet. Aquí puedes abrir conexiones a bases de datos o cargar configuración.
  • service(): cada petición HTTP dispara este método. HttpServlet lo delega a doGet(), doPost(), etc. según el verbo HTTP.
  • doGet() / doPost(): los métodos que sobreescribes en la práctica. Raramente tocas service() directamente.
  • destroy(): el contenedor lo llama al retirar el Servlet. Cierra aquí lo que abriste en init().

La anotación @WebServlet elimina la necesidad de configurar rutas en web.xml:

import jakarta.servlet.annotation.WebServlet;
import jakarta.servlet.http.*;
import java.io.*;

@WebServlet("/saludo")
public class SaludoServlet extends HttpServlet {

    @Override
    protected void doGet(HttpServletRequest req, HttpServletResponse resp)
            throws IOException {
        String nombre = req.getParameter("nombre");
        if (nombre == null || nombre.isBlank()) {
            nombre = "visitante";
        }

        resp.setContentType("text/html;charset=UTF-8");
        PrintWriter out = resp.getWriter();
        out.println("<!DOCTYPE html>");
        out.println("<html><body>");
        out.println("<h1>Hola, " + nombre + "!</h1>");
        out.println("</body></html>");
    }
}

Con @WebServlet("/saludo"), Tomcat mapea /saludo a esta clase. Puedes pasar varios patrones: @WebServlet({"/saludo", "/hola"}).

3. HttpServletRequest y HttpServletResponse

HttpServletRequest encapsula todo lo que viene del navegador. Los métodos más usados:

  • getParameter("nombre"): devuelve el valor de un parámetro de query string o de un formulario POST. Si el parámetro no existe, devuelve null.
  • getParameterMap(): devuelve todos los parámetros como Map<String, String[]>. Útil cuando el número de parámetros es variable.
  • getHeader("Authorization"): lee una cabecera HTTP concreta.
  • getMethod(): devuelve GET, POST, PUT, etc.
  • getRequestURI(): la ruta de la petición.

Para las respuestas, HttpServletResponse te da:

// Respuesta HTML
resp.setContentType("text/html;charset=UTF-8");
PrintWriter out = resp.getWriter();
out.println("<p>Texto HTML</p>");

// Respuesta JSON
resp.setContentType("application/json;charset=UTF-8");
resp.setHeader("Cache-Control", "no-cache");
PrintWriter out = resp.getWriter();
out.println("{"estado": "ok"}");

// Redirección
resp.sendRedirect("/otra-pagina");

// Código de estado personalizado
resp.setStatus(HttpServletResponse.SC_NOT_FOUND); // 404

Cuando produces contenido binario (imágenes, PDFs, ficheros), usa resp.getOutputStream() en lugar de getWriter(). Los dos no pueden usarse a la vez en la misma respuesta.

4. Sesiones HTTP

HTTP es sin estado por definición. Las sesiones son el mecanismo estándar para mantener datos entre peticiones del mismo usuario. Tomcat gestiona un ID de sesión que viaja en una cookie (JSESSIONID) o, si las cookies están desactivadas, en la URL.

// Obtener la sesión existente o crear una nueva
HttpSession sesion = request.getSession();

// Guardar datos
sesion.setAttribute("usuario", "elena");
sesion.setAttribute("rol", "admin");

// Recuperar datos en otra petición
String usuario = (String) sesion.getAttribute("usuario");

// Cerrar sesión
sesion.invalidate();

Para controlar el tiempo de expiración en web.xml (en minutos):

<session-config>
    <session-timeout>30</session-timeout>
</session-config>

También puedes ajustarlo por código: sesion.setMaxInactiveInterval(1800) (en segundos). Pasado ese tiempo sin actividad, Tomcat invalida la sesión automáticamente.

Ojo con guardar objetos grandes en sesión: cada objeto ocupa memoria en el servidor por cada usuario conectado. Guarda solo lo imprescindible, normalmente el ID de usuario y su rol.

5. Filtros

Un filtro intercepta peticiones antes de que lleguen al Servlet, y puede modificar tanto la petición como la respuesta. Son perfectos para autenticación, logging, manejo de CORS o añadir cabeceras comunes.

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

@WebFilter("/*")
public class AuthFilter implements Filter {

    @Override
    public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain)
            throws IOException, ServletException {

        HttpServletRequest request = (HttpServletRequest) req;
        HttpServletResponse response = (HttpServletResponse) res;

        String uri = request.getRequestURI();

        // Rutas públicas que no necesitan autenticación
        if (uri.contains("/login") || uri.contains("/publico")) {
            chain.doFilter(req, res);
            return;
        }

        HttpSession sesion = request.getSession(false);
        if (sesion == null || sesion.getAttribute("usuario") == null) {
            response.sendRedirect(request.getContextPath() + "/login");
            return;
        }

        chain.doFilter(req, res);
    }
}

chain.doFilter(req, res) pasa el control al siguiente filtro de la cadena, o al Servlet si no hay más filtros. Si no llamas a este método, la petición se detiene ahí. Eso es precisamente lo que hace el filtro de autenticación cuando la sesión no existe: cortar y redirigir.

Puedes encadenar varios filtros con @WebFilter. El orden de ejecución depende del orden de declaración en web.xml, no del orden de las anotaciones, así que cuando el orden importa, configúralos en web.xml.

6. JSP y JSTL

Una página JSP es HTML con capacidad para insertar lógica Java. Cuando Tomcat la procesa por primera vez, la compila a un Servlet. Las siguientes peticiones van directo a ese Servlet compilado, así que el rendimiento no es peor que un Servlet manual.

La forma antigua de escribir JSP usaba scriptlets:

<!-- MAL: scriptlet con lógica Java mezclada con HTML -->
<% List<String> items = (List<String>) request.getAttribute("items"); %>
<% for (String item : items) { %>
    <li><%= item %></li>
<% } %>

Hoy se usa Expression Language (EL) y JSTL, que separan la lógica de la presentación:

<%@ taglib prefix="c" uri="jakarta.tags.core" %>

<!-- Mostrar una variable -->
<p>Usuario: ${sessionScope.usuario}</p>

<!-- Condicional -->
<c:if test="${not empty items}">
    <ul>
        <c:forEach var="item" items="${items}">
            <li><c:out value="${item}"/></li>
        </c:forEach>
    </ul>
</c:if>

<!-- Choose / when / otherwise -->
<c:choose>
    <c:when test="${rol == 'admin'}"><p>Panel de administración</p></c:when>
    <c:otherwise><p>Acceso restringido</p></c:otherwise>
</c:choose>

Para usar JSTL añade la dependencia Maven:

<dependency>
    <groupId>jakarta.servlet.jsp.jstl</groupId>
    <artifactId>jakarta.servlet.jsp.jstl-api</artifactId>
    <version>3.0.0</version>
</dependency>
<dependency>
    <groupId>org.glassfish.web</groupId>
    <artifactId>jakarta.servlet.jsp.jstl</artifactId>
    <version>3.0.1</version>
</dependency>

c:out escapa HTML automáticamente, lo que previene XSS. Es buena costumbre usarlo siempre que muestres datos que vienen del usuario.

7. Patrón MVC con Servlets y JSP

El patrón MVC en Java clásico funciona así: el Servlet actúa de controlador, recibe la petición, llama a la lógica de negocio (el modelo) y pasa los datos a una JSP (la vista).

Ejemplo con una lista de tareas:

// Modelo: un POJO simple
public class Tarea {
    private int id;
    private String titulo;
    private boolean completada;

    // Constructor, getters y setters
    public Tarea(int id, String titulo, boolean completada) {
        this.id = id;
        this.titulo = titulo;
        this.completada = completada;
    }
    public int getId() { return id; }
    public String getTitulo() { return titulo; }
    public boolean isCompletada() { return completada; }
}
// Controlador: el Servlet
@WebServlet("/tareas")
public class TareasServlet extends HttpServlet {

    @Override
    protected void doGet(HttpServletRequest req, HttpServletResponse resp)
            throws ServletException, IOException {

        // Aquí iría la llamada a tu capa de datos (DAO, repositorio, etc.)
        List<Tarea> lista = List.of(
            new Tarea(1, "Revisar código", false),
            new Tarea(2, "Desplegar en staging", true),
            new Tarea(3, "Escribir tests", false)
        );

        // Pasar datos a la vista
        req.setAttribute("tareas", lista);

        // Delegar a la JSP
        RequestDispatcher rd = req.getRequestDispatcher("/WEB-INF/vistas/tareas.jsp");
        rd.forward(req, resp);
    }
}
<!-- Vista: /WEB-INF/vistas/tareas.jsp -->
<%@ taglib prefix="c" uri="jakarta.tags.core" %>
<!DOCTYPE html>
<html>
<head><title>Lista de tareas</title></head>
<body>
<h1>Tareas</h1>
<ul>
    <c:forEach var="t" items="${tareas}">
        <li>
            <c:out value="${t.titulo}"/>
            <c:if test="${t.completada}"> ?</c:if>
        </li>
    </c:forEach>
</ul>
</body>
</html>

Fíjate en que las JSP van dentro de WEB-INF/vistas/. Cualquier fichero en WEB-INF no es accesible directamente desde el navegador, así que el usuario no puede entrar a /WEB-INF/vistas/tareas.jsp sin pasar por el Servlet. Esto es una práctica habitual.

RequestDispatcher.forward() delega la petición a la JSP sin cambiar la URL en el navegador. sendRedirect(), en cambio, manda al navegador una respuesta 302 y provoca una nueva petición. Usa forward() para mostrar vistas y sendRedirect() tras un POST para evitar que el formulario se reenvíe al recargar la página (patrón Post/Redirect/Get).

8. Subida de ficheros con @MultipartConfig

Para recibir ficheros desde un formulario HTML necesitas la anotación @MultipartConfig en el Servlet y enctype="multipart/form-data" en el formulario.

// Formulario HTML
<form action="/upload" method="post" enctype="multipart/form-data">
    <input type="file" name="archivo">
    <button type="submit">Subir</button>
</form>
import jakarta.servlet.annotation.MultipartConfig;
import jakarta.servlet.annotation.WebServlet;
import jakarta.servlet.http.*;
import java.io.*;
import java.nio.file.*;

@WebServlet("/upload")
@MultipartConfig(
    maxFileSize    = 10 * 1024 * 1024,  // 10 MB por fichero
    maxRequestSize = 20 * 1024 * 1024   // 20 MB total de la petición
)
public class UploadServlet extends HttpServlet {

    private static final String UPLOAD_DIR = "/var/archivos/uploads";

    @Override
    protected void doPost(HttpServletRequest req, HttpServletResponse resp)
            throws Exception {

        Part part = req.getPart("archivo");
        String nombreFichero = Paths.get(part.getSubmittedFileName()).getFileName().toString();

        // Validar extensión antes de guardar
        if (!nombreFichero.matches(".*\.(jpg|png|pdf)$")) {
            resp.sendError(HttpServletResponse.SC_BAD_REQUEST, "Tipo de fichero no permitido");
            return;
        }

        part.write(UPLOAD_DIR + "/" + nombreFichero);

        resp.setContentType("text/plain;charset=UTF-8");
        resp.getWriter().println("Fichero guardado: " + nombreFichero);
    }
}

part.getSubmittedFileName() devuelve el nombre original del fichero tal como lo envía el navegador. Pásalo siempre por Paths.get(...).getFileName() para evitar ataques de path traversal del tipo ../../etc/passwd. Y valida la extensión, porque el tipo MIME que envía el navegador no es de fiar.

9. Despliegue en Tomcat 10.1

Un proyecto web Java se empaqueta en un WAR (Web Application Archive). La estructura mínima:

miapp.war
??? index.jsp
??? WEB-INF/
?   ??? web.xml          (opcional con anotaciones)
?   ??? classes/         (clases .class compiladas)
?   ??? lib/             (JARs de dependencias, excepto las provided)

Con Maven, el plugin maven-war-plugin genera el WAR automáticamente:

<packaging>war</packaging>

<build>
    <plugins>
        <plugin>
            <groupId>org.apache.maven.plugins</groupId>
            <artifactId>maven-war-plugin</artifactId>
            <version>3.4.0</version>
        </plugin>
    </plugins>
</build>

Ejecuta mvn package y obtendrás target/miapp.war. Para desplegarlo en Tomcat tienes dos opciones:

  • Copia directa: copia el WAR en $TOMCAT_HOME/webapps/. Tomcat lo detecta y lo despliega automáticamente. La aplicación queda accesible en /miapp.
  • Manager de Tomcat: accede a http://localhost:8080/manager/html con un usuario con rol manager-gui y sube el WAR desde la interfaz web. Útil en servidores donde no tienes acceso directo al sistema de ficheros.

Para que Tomcat 10.1 use Java 25 LTS o Java 21, asegúrate de que la variable JAVA_HOME apunta a la JDK correcta antes de arrancar Tomcat. La forma más rápida de comprobarlo: $TOMCAT_HOME/bin/version.sh muestra la versión de Java que está usando el servidor.

Los Servlets y JSP no son tecnología antigua que hay que evitar: son la base de todo Java web y conocerlos a fondo hace que cualquier framework de más alto nivel sea mucho más fácil de entender y depurar. La especificación completa está en jakarta.ee/specifications/servlet/6.0/.

Imagen: Pexels

COMPARTE ESTE ARTÍCULO

COMPARTIR EN FACEBOOK
COMPARTIR EN TWITTER
COMPARTIR EN LINKEDIN
COMPARTIR EN WHATSAPP
ARTÍCULO ANTERIOR