Технология JSP: как страница превращается в сервлет, EL и JSTL

Технология JSP: как страница превращается в сервлет, EL и JSTL Полезное

JSP (Jakarta Server Pages, до 2019 года — JavaServer Pages) — это технология серверных шаблонов для Java: файл .jsp содержит обычный HTML и специальные элементы, а контейнер сервлетов (например, Apache Tomcat) превращает его в Java-класс сервлета, компилирует и выполняет на каждый запрос. Браузер получает уже готовый HTML — Java-код до него не доходит.

Ниже — минимальная работающая страница, путь от .jsp до сервлета, EL и JSTL, правильная связка «сервлет + JSP», переход с javax.* на jakarta.* и честный ответ, нужен ли JSP в 2026 году. Все примеры проверены 23.09.2026 в Apache Tomcat 11.0.26 на OpenJDK 25.0.4 (образ tomcat:11).

Мини-словарь: что с чем путают

Термин Что это Где живет
Сервлет Java-класс, который принимает HTTP-запрос и формирует ответ (HttpServlet) код приложения
Контейнер сервлетов Сервер, который управляет сервлетами: Tomcat, Jetty и др. отдельный процесс или встроен в приложение
JSP Шаблон страницы, из которого контейнер генерирует сервлет файл .jsp
Jasper JSP-движок Tomcat: транслирует .jsp в .java и компилирует внутри Tomcat
EL Язык выражений ${...} для чтения данных в шаблоне внутри JSP
JSTL Библиотека стандартных тегов: условия, циклы, вывод с экранированием отдельные jar-файлы

Главное различие: сервлет — это код, JSP — шаблон, из которого код генерируется автоматически. В хорошо устроенном приложении сервлет готовит данные, а JSP только отображает их.

Минимальная JSP-страница

Файл hello.jsp кладется в корень веб-приложения (в Tomcat — webapps/<имя приложения>/). Никакой настройки web.xml для него не нужно: запросы к *.jsp Tomcat по умолчанию отдает своему JSP-движку.

<%@ page contentType="text/html; charset=UTF-8" pageEncoding="UTF-8" %>
<%@ page import="java.time.LocalDate" %>
<!DOCTYPE html>
<html>
<body>
  <h1>Привет из JSP</h1>
  <p>Сегодня: <%= LocalDate.now() %></p>
  <p>Сумма: ${2 + 3}, сравнение: ${10 > 3}</p>
  <p>Метод запроса: ${pageContext.request.method}</p>
</body>
</html>

Запрос curl http://localhost:8080/demo/hello.jsp вернул такой HTML (дата — день запуска):

<h1>Привет из JSP</h1>
<p>Сегодня: 2026-09-23</p>
<p>Сумма: 5, сравнение: true</p>
<p>Метод запроса: GET</p>

Что здесь есть. <%@ page ... %> — директива: настройки для трансляции (кодировка, импорты). <%= ... %> — выражение Java, результат попадает в HTML. ${...} — выражение EL, оно вычисляется при каждом запросе. Все остальное — статический текст, который уходит в ответ как есть.

Жизненный цикл: от .jsp к сервлету

У JSP тот же жизненный цикл, что у сервлета, плюс два шага в начале. Порядок такой:

  1. Трансляция. Движок (в Tomcat — Jasper) превращает hello.jsp в исходник hello_jsp.java.
  2. Компиляция. Исходник компилируется в hello_jsp.class.
  3. Загрузка и создание экземпляра. Контейнер загружает класс и создает объект.
  4. Инициализация. Один раз вызывается jspInit().
  5. Обработка запросов. На каждый запрос вызывается _jspService(request, response).
  6. Уничтожение. При остановке или перезагрузке приложения вызывается jspDestroy().

Шаги 1-2 выполняются при первом обращении к странице (или заранее, если приложение прекомпилировано), поэтому первый запрос заметно медленнее следующих. В Tomcat сгенерированные файлы лежат в work/Catalina/localhost/<приложение>/org/apache/jsp/. Вот фрагмент реального counter_jsp.java:

public final class counter_jsp extends org.apache.jasper.runtime.HttpJspBase
    implements org.apache.jasper.runtime.JspSourceDependent,
  ...
  public void _jspInit() {
  ...
  public void _jspDestroy() {
  ...
  public void _jspService(final jakarta.servlet.http.HttpServletRequest request, final jakarta.servlet.http.HttpServletResponse response)

Если изменить .jsp, Tomcat в режиме разработки (включен по умолчанию) заметит новую дату файла и повторит трансляцию и компиляцию — перезапускать сервер не нужно. В production этот режим обычно отключают или прекомпилируют страницы при сборке.

Элементы JSP: что есть в шаблоне

Элемент Синтаксис Когда выполняется Статус сейчас
Директива page <%@ page import="..." %> при трансляции используется
Директива include <%@ include file="part.jspf" %> при трансляции, текст вклеивается в страницу используется
Директива taglib <%@ taglib prefix="c" uri="..." %> при трансляции используется
Действие include <jsp:include page="dyn.jsp"/> при каждом запросе, отдельный сервлет используется
EL ${user.name} при каждом запросе основной способ вывода
Выражение <%= expr %> при каждом запросе legacy
Скриптлет <% code; %> при каждом запросе legacy
Объявление <%! int x; %> поле класса сервлета legacy, опасно

Разница двух include видна в сгенерированном коде: для <%@ include %> Jasper вписал содержимое part.jspf прямо в inc_jsp.java (out.write("<p>footer</p>\n")), а для <jsp:include> создал отдельный dyn_jsp.java и вызов JspRuntimeLibrary.include(...). Статический include быстрее, динамический позволяет менять подключаемую страницу независимо.

Ошибка с объявлением: одно поле на всех

Частая ошибка из старых учебников — счетчик или «данные пользователя» в объявлении <%! %>. Вот неверный код counter.jsp:

<%@ page contentType="text/plain; charset=UTF-8" %>
<%! private int hits = 0; %>
<% int local = 0; hits++; local++; %>
hits=<%= hits %> local=<%= local %>

Результат трех запросов подряд (фактический вывод):

hits=1 local=1
hits=2 local=1
hits=3 local=1

Причина в том, что контейнер обычно держит один экземпляр сервлета и обслуживает им все запросы параллельно. Объявление <%! %> становится полем этого экземпляра (в сгенерированном коде — private int hits = 0;), поэтому оно общее для всех пользователей и не потокобезопасно. Переменная из скриптлета <% %> — локальная внутри _jspService и живет один запрос.

Исправление: не хранить состояние в полях страницы. Данные одного запроса передавать атрибутами запроса, данные пользователя — атрибутами сессии, общие счетчики — в потокобезопасных структурах в коде приложения (например, AtomicInteger), а не в JSP.

Правильная связка: сервлет готовит данные, JSP показывает

Логика в скриптлетах быстро превращается в нечитаемую смесь HTML и Java. Устоявшийся подход (Model 2, по сути MVC): запрос принимает сервлет, кладет данные в атрибуты и передает управление JSP, а JSP выводит их через EL и JSTL.

package demo;

import jakarta.servlet.ServletException;
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.util.List;

@WebServlet("/products")
public class ProductsServlet extends HttpServlet {
    public record Product(String name, int price) {}

    @Override
    protected void doGet(HttpServletRequest req, HttpServletResponse resp)
            throws ServletException, IOException {
        List<Product> products = List.of(
                new Product("Клавиатура", 3500),
                new Product("Мышь <USB>", 1200));
        req.setAttribute("products", products);
        req.setAttribute("q", req.getParameter("q"));
        req.getRequestDispatcher("/WEB-INF/views/products.jsp").forward(req, resp);
    }
}

Шаблон лежит в WEB-INF/views/products.jsp:

<%@ page contentType="text/html; charset=UTF-8" pageEncoding="UTF-8" %>
<%@ taglib prefix="c" uri="jakarta.tags.core" %>
<!DOCTYPE html>
<html>
<body>
  <p>Поиск: <c:out value="${q}"/></p>
  <ul>
    <c:forEach var="p" items="${products}">
      <li><c:out value="${p.name}"/> - ${p.price} руб.</li>
    </c:forEach>
  </ul>
  <c:if test="${empty products}"><p>Товаров нет</p></c:if>
</body>
</html>

Запрос к /demo/products?q=<script>alert(1)</script> (параметр закодирован через curl -G --data-urlencode) вернул:

<p>Поиск: &lt;script&gt;alert(1)&lt;/script&gt;</p>
<ul>
  <li>Клавиатура - 3500 руб.</li>
  <li>Мышь &lt;USB&gt; - 1200 руб.</li>
</ul>

Три вещи, которые здесь важны:

  • JSP в WEB-INF недоступна напрямую. Запрос к /demo/WEB-INF/views/products.jsp дал 404 — страницу можно открыть только через сервлет, который подготовил данные.
  • EL читает свойства объектов: ${p.name} вызывает getName() у JavaBean или, как здесь, метод name() у record. Поддержка record появилась в Jakarta EL 6.0, то есть в Tomcat 11. В Tomcat 10.1 та же страница падает с PropertyNotFoundException: Property [name] not found on type [demo.ProductsServlet$Product] — там модель данных делают обычным классом с геттерами.
  • JSTL не входит в Tomcat. Нужны два jar-файла — API и реализация Jakarta Standard Tag Library — в WEB-INF/lib или зависимостью сборки. Пример проверен с jakarta.servlet.jsp.jstl:jakarta.servlet.jsp.jstl-api:3.0.2 и org.glassfish.web:jakarta.servlet.jsp.jstl:3.0.1. В JSTL 3.0 адрес библиотеки — jakarta.tags.core; в старых проектах встречается http://java.sun.com/jsp/jstl/core — эта реализация принимает и его, но в новом коде пишут новый адрес.

Безопасность: EL сам ничего не экранирует

Выражение ${...} в тексте страницы выводит значение как есть. Страница raw.jsp с той же директивой page (UTF-8) и строкой <p>Вы искали: ${param.q}</p> на тот же запрос вернула:

<p>Вы искали: <script>alert(1)</script></p>

Это XSS: браузер выполнит скрипт из параметра. Правило — все, что пришло извне или из базы, выводить через <c:out value="${...}"/> или ${fn:escapeXml(...)} (для fn нужна директива <%@ taglib prefix="fn" uri="jakarta.tags.functions" %>). Оба кодируют <, >, & и кавычки: строка "a'<b>& превратилась в &#034;a&#039;&lt;b&gt;&amp;. У c:out экранирование включено по умолчанию, атрибут escapeXml="false" его отключает — в пользовательских данных его не ставят. Это защита вывода в HTML-тексте и в значениях атрибутов, заключенных в кавычки; для вставки в JavaScript, CSS или URL нужны свои правила кодирования. Валидация входных данных и параметризация SQL — отдельные меры, c:out их не заменяет.

javax или jakarta: главная ловушка старых примеров

В Jakarta EE 9 (2020) пакеты javax.servlet.* переименовали в jakarta.servlet.*. Tomcat 10 и 11 работают только с новым пространством имен, Tomcat 9 — со старым. Код из учебника 2018 года под Tomcat 11 не соберется:

package demo;

import javax.servlet.http.HttpServlet;

public class OldServlet extends HttpServlet {
}
demo/OldServlet.java:3: error: package javax.servlet.http does not exist
import javax.servlet.http.HttpServlet;
                         ^
demo/OldServlet.java:5: error: cannot find symbol
public class OldServlet extends HttpServlet {
                                ^
  symbol: class HttpServlet
2 errors

Исправление — заменить импорты на jakarta.servlet... и обновить зависимости (API сервлетов, JSTL) на Jakarta-версии. Для готовых старых приложений у Apache есть Tomcat Migration Tool for Jakarta EE; Tomcat 11 содержит его библиотеку в lib.

Если не получилось: симптомы и причины

  • 404 на .jsp. Файл не в той папке (нужен корень приложения, не WEB-INF) или ошибка в имени приложения в URL.
  • 500 с JasperException. Ошибка трансляции или компиляции страницы: в тексте ошибки Tomcat указывает строку .jsp. Частая причина — JSTL-тег без jar-файлов в WEB-INF/lib.
  • ClassNotFoundException: javax.servlet.... Старый код или библиотека под javax на Tomcat 10+.
  • На странице видно ${...} буквально. EL отключен: isELIgnored="true" или очень старая версия в web.xml.
  • Кракозябры вместо кириллицы. Не задан pageEncoding="UTF-8" или файл сохранен не в UTF-8.

JSP в 2026 году: когда брать, когда нет

JSP — часть актуальной платформы Jakarta EE (Jakarta Pages поддерживается в Tomcat 11), но для новых проектов его выбирают редко. Ситуация зависит от задачи:

Задача Что обычно берут Почему
Поддержка существующего приложения на JSP JSP + EL + JSTL, без новых скриптлетов переписывание дороже, чем аккуратная доработка
Новое серверное приложение на Spring Boot Thymeleaf или другой шаблонизатор JSP во встроенном контейнере Spring Boot имеет ограничения
Интерфейс с богатой логикой на клиенте SPA (React, Vue, Angular) + REST API на Java HTML собирается в браузере
Приложение на Jakarta Faces Facelets (XHTML) в Faces основной язык представления — не JSP

Поэтому JSP стоит знать как основу: он показывает, как работают сервлеты, запросы, сессии и шаблоны на сервере, и регулярно встречается в корпоративных системах, которые нужно поддерживать и мигрировать.

Выводы

  • JSP — серверный шаблон: контейнер транслирует .jsp в сервлет, компилирует его и вызывает _jspService на каждый запрос.
  • Трансляция и компиляция происходят при первом запросе или при сборке; в режиме разработки Tomcat пересобирает измененную страницу сам.
  • Современный стиль — сервлет готовит данные, JSP выводит их через EL и JSTL; скриптлеты и объявления <%! %> — legacy, объявления опасны общим состоянием.
  • EL не экранирует вывод: данные извне выводят через c:out или fn:escapeXml.
  • Tomcat 10+ работает только с jakarta.*; для новых проектов чаще выбирают Thymeleaf или SPA, но JSP нужно знать для поддержки и миграции.

Где применяется / связь с практикой

Освойте тему на практике

JSP почти не встречается в изоляции: он живет внутри Java-приложения с сервлетами, базой данных, сборкой и развертыванием на сервере приложений. Чтобы поддерживать такие системы и переводить их на современный стек, нужно уверенно владеть самой Java, коллекциями, многопоточностью и веб-частью платформы. Системно это разбирают на курсе Java Developer. Professional.

Освойте тему на практике

Отдельные темы по Java и веб-разработке удобно посмотреть на открытых уроках Otus — это бесплатные вебинары с преподавателями-практиками.

FAQ

Чем JSP отличается от HTML-файла?
HTML отдается сервером как есть, а JSP сначала выполняется на сервере: EL и теги подставляют данные, и браузер получает уже итоговый HTML.

Можно ли запустить JSP без Tomcat?
Нужен любой контейнер с поддержкой Jakarta Pages — например, Jetty, GlassFish или WildFly. Открыть .jsp в браузере как файл не получится: выполнять его некому.

Нужен ли web.xml для JSP?
Для простых страниц и сервлетов с аннотацией @WebServlet — нет. web.xml нужен для страниц ошибок, фильтров, групп настроек JSP и других параметров развертывания.

OTUS Журнал
Скидка 5% 14-20 сентября на курсы (popup)