Уж+ёж: реактивные компоненты в сервлетном окружении (2/3)

Моя цель - предложение широкого ассортимента товаров и услуг на постоянно высоком качестве обслуживания по самым выгодным ценам.

В предыдущей заметке было показано несколько подходов к управлению зависимостями в «гибридных» приложениях (использующих части сервлетного и реактивного стеков), а также вариант реализации универсального механизма для поддержки MDC-меток в логах. В этой заметке речь пойдёт о другом, не менее насущном вопросе, часто возникающем у разработчиков перед «примеркой» реактивного стека к сервлетным приложениям.

Получение текущего запроса

Возможность заполучить текущий обрабатываемый запрос практически из любого места в коде (не пробрасывая его сквозь сигнатуры методов) – бесценная фича WebMVC. Как и в случае с MDC-метками, она держится на механизме поточно-локальных переменных: в классе RequestContextHolder есть поле

private static final ThreadLocal<RequestAttributes> requestAttributesHolder

, значение которого можно откуда угодно получить публичным статическим методом currentRequestAttributes() (и не только им). А через него уже можно выйти на текущий запрос HttpServletRequest.

Минутка занудства

Вообще говоря, полагаться на вызов статического метода из недр приложения – сомнительная затея, ведь она, как минимум, затрудняет покрытие кода модульными тестами (статические методы сложнее Mock'ировать). Тем не менее, её часто используют в инфраструктурной логике (например, в аспектах), да и в самом Spring WebMVC она тоже широко используется.

Проблема

Как не трудно догадаться, с приходом реактивщины этот механизм перестаёт работать. И дело тут не столько в слове Servlet в названии класса запроса, сколько в том, что в реактивном веб-приложении нет чёткой связки «один запрос – один поток». Как следствие, никто по умолчанию не наполняет поточно-локальную переменную, и любая попытка вызвать метод currentRequestAttributes() из некогда успешной сервлетной логики кончается обескураживающим:

java.lang.IllegalStateException: No thread-bound request found: Are you referring to request attributes outside of an actual web request, or processing a request outside of the originally receiving thread? If you are actually operating within a web request and still receive this message, your code is probably running outside of DispatcherServlet: In this case, use RequestContextListener or RequestContextFilter to expose the current request.

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

Решение

В отличие от случая с MDC, здесь не удастся обойтись лишь добавлением какого-то нового класса/метода, который сделает «всё хорошо». Придётся залезть в ту логику, которая обращается к RequestContextHolder'у, и адаптировать её к работе одновременно на двух стеках: сервлетном и реактивном. Крупными мазками эта адаптация состоит в следующем:

  1. Избавиться от статической зависимости

  2. Перейти на обобщённый класс запроса

    1. Унифицировать получение данных запроса

  3. Обеспечить доступность «реактивного» запроса из единой точки обращения.

Пункт 2.1 является следствием пункта 2, поэтому сделан вложенным. Ниже приводятся конкретные шаги и объяснения, составляющие суть каждого пункта.

Удаление статической зависимости

Даже безотносительно к решаемой здесь задаче переход от вызова статического метода к вызову instance-метода у Spring-бина – хорошая идея как с точки зрения тестируемости кода (см. минутку занудства выше), так и с точки зрения наведения порядка, ведь это позволит сделать зависимость от такого метода более явной и управляемой.

В случае с классом RequestContextHolder можно начать с простого и обернуть его в специально заведённый для этого бин, например такой:

@Component
public class HttpRequestAccessor {
  public HttpServletRequest fetchCurrentRequest() {             // [1]
    var servletRequestAttributes = (ServletRequestAttributes)
      RequestContextHolder.currentRequestAttributes();          // [2]
    return servletRequestAttributes.getRequest();
  }
}

1️⃣ Метод уже не статический.
2️⃣ RequestAttributes – это абстракция для работы с данными запроса в различных окружениях и на различных областях видимости. Из неё можно получить и сам запрос.

Тогда код, который раньше выглядел примерно как:

((ServletRequestAttributes) RequestContextHolder.currentRequestAttributes()).getRequest()

, теперь нужно будет переписать на:

httpRequestAccessor.fetchCurrentRequest()

, где httpRequestAccessor – экземпляр класса HttpRequestAccessor, внедрённый в текущий класс, как правило, через @Autowired.

И хотя для модульных тестов это уже большое благо, для адаптации к реактивному стеку сделанная правка – условие необходимое, но не достаточное. Нужно ещё как-то преобразовать тип результата (HttpServletRequest), чтобы он не был так сильно привязан к сервлетному стеку и годился для стека реактивного.

Обобщение класса запроса

Первое, что может прийти в голову, – это как-то подобрать/написать такую имплементацию интерфейса HttpServletRequest, чтобы она хорошо работала на реактивном стеке, а обращающийся к ней код этого не замечал. Однако помимо эстетического изъяна в виде слова Servlet в имени интерфейса, у этого подхода ещё есть сложности в том, что некоторые методы интерфейса (коих прорва весьма немало) имеют довольно сильную сервлетную специфику, а значит, их придётся либо заглушать чем-то вроде NotImplementedException, либо городить нетривиальную логику по мимикрированию под реактивный стек. Ни то, ни другое перспективным не выглядит.

К счастью, в пакете org.springframework.http есть интерфейс HttpRequest, который является достаточно общей абстракцией, чтобы под него подпадали интерфейсы как «сервлетного» запроса, так и «реактивного». Правда, каждый со своими особенностями.

Интерфейс «реактивных» запросов org.springframework.http.server.reactive.ServerHttpRequest как раз является наследником интерфейса HttpRequest. Это значит, что в обобщённой логике можно будет проверить экземпляр HttpRequest на instanceof от ServerHttpRequest и таким образом понять, «реактивный» это запрос или нет. Всё так, только с такой проверкой надо быть осторожным – в Spring есть ещё один интерфейс с точно таким же именем ServerHttpRequest, но уже в пакете org.springframework.http.server. Хуже того, он тоже расширяет интерфейс HttpRequest. Вся разница только в том, что (согласно javadoc) этот интерфейс:

Represents a server-side HTTP request.

, в то время как нужный нам:

Represents a reactive server-side HTTP request.

, т.е. один является абстракцией универсальной, а второй – частной, для реактивного стека. Почему при этом они делят одно имя, для автора остаётся загадкой.

Интерфейсу «сервлетных» запросов HttpServletRequest такие проблемы чужды – он вообще не является наследником HttpRequest. А чтобы позволить ему хоть как-то вступить в этот элитный клуб, разработчики Spring добавили класс org.springframework.http.server.ServletServerHttpRequest, который решает эту задачу за счёт того, что:

  • является делегатом к заданному экземпляру HttpServletRequest;

  • имплементирует интерфейс HttpRequest, делегируя вызовы его методов оборачиваемому объекту с попутной адаптацией.

Благодаря этому классу при проверках объектов HttpRequest на instanceof теперь тоже можно будет отличать «сервлетные» запросы от остальных, но для этого они должны быть предварительно обёрнуты в этот класс.

Вся картина этих семейных отношений через призму IDEA выглядит примерно так:

Извлечение данных запроса

Найденная абстракция хороша тем, что под неё подпадают запросы из обоих миров, однако её одной мало, потому что состав методов интерфейса HttpRequest (даже с учётом наследования) весьма скуден:

В нём нет методов для получения многих часто используемых данных запроса, например:

  • URI-параметров (аналог javax.servlet.ServletRequest#getParameter),

  • cookies (аналог javax.servlet.http.HttpServletRequest#getCookies),

  • атрибутов (аналог javax.servlet.ServletRequest#getAttribute).

Нет в нём и аналога метода ServletRequest#getInputStream для чтения тела запроса. Да и вообще работа с телами запросов/ответов в реактивном стеке существенно отличается от сервлетного и заслуживает отдельной заметки. А здесь для примера будет рассмотрено извлечение только приведённых выше данных.

Пользуясь выделенным ранее общим интерфейсом HttpRequest и осознанием того, что какие-то данные запроса придётся извлекать отдельными методами, можно уже сейчас наметить примерный скелет этих методов. Для простоты положим, что они будут находиться в том же классе HttpRequestAccessor, а выглядеть согласно такому шаблону:

public Object getSomethingFrom(HttpRequest request) {
  // если запрос сервлетный
  if (request instanceof ServletServerHttpRequest wrapper) {          // [1]
    HttpServletRequest servletRequest = wrapper.getServletRequest();  // [2]
    // извлекаем что-то из сервлетного запроса
    return something;
  }
  // если запрос реактивный
  if (request instanceof ServerHttpRequest reactiveRequest) {         // [3]
    // извлекаем что-то из реактивного запроса
    return something;
  }
  // если запрос - неведомая дичь
  throw new IllegalArgumentException("Неизвестный тип запроса: " + request.getClass());
}

1️⃣ Используем Pattern Matching (JDK 16+) для краткости.
2️⃣ Избавляемся от обёртки.
3️⃣ ServerHttpRequest – это интерфейс org.springframework.http.server.reactive.ServerHttpRequest (не путать с однофамильцем).

Извлечение URI-параметров

Семейство методов ServletRequest#getParameter* в сервлетном стеке умеет работать как с обычными query-параметрами, передаваемыми в URL после знака ?, так и с параметрами POST-запросов, передаваемыми в теле (например, при сабмите HTML-формы с Content-Type: application/x-www-form-urlencoded). В реактивном стеке для этого введён отдельный метод ServerWebExchange#getFormData. Очевидно, в таких случаях требуется парсинг тела запроса, который остаётся за скобками рассмотрения. А в этом пункте речь пойдёт только об обычных советских URI-параметрах, используемых чаще всего с HTTP-методом GET.

В случае с «реактивным» запросом всё предельно просто – есть метод ServerHttpRequest#getQueryParams, который возвращает MultiValueMap<String, String> – Spring’овый аналог коллекции Map<String, List<String>>. Такое нагромождение коллекций нужно для того, чтобы учесть случай, когда в запросе присутствует параметр, указанный более одного раза, например:

GET http://localhost:8081/inspect?rid=123&rid=567

В этом случае значения 123 и 567 станут элементами списка, доступного по ключу rid. Кажется, что это вполне удобный формат для представления результата, поэтому он и будет принят за основной далее.

А у «сервлетных» запросов аналогичный метод ServletRequest#getParameterMap несколько отличается: он не только умеет возвращать параметры из тела POST-запросов, но и делает это в другом виде – Map<String, String[]>, поэтому придётся адаптировать этот вид к принятому выше MultiValueMap<String, String>.

С учётом этих договорённостей метод получения URI-параметров в классе HttpRequestAccessor может выглядеть примерно так:

public MultiValueMap<String, String> getParameters(HttpRequest request) {
  // если запрос сервлетный
  if (request instanceof ServletServerHttpRequest wrapper) {
    HttpServletRequest servletRequest = wrapper.getServletRequest();
    Map<String, String[]> parameterMap = servletRequest.getParameterMap();
    Map<String, List<String>> rawMap = parameterMap.entrySet().stream()  // [1]
      .map(entry -> Map.entry(entry.getKey(), Arrays.asList(entry.getValue())))
      .collect(toMap(Map.Entry::getKey, Map.Entry::getValue));
    return CollectionUtils.toMultiValueMap(rawMap);                      // [2]
  }
  // если запрос реактивный
  if (request instanceof ServerHttpRequest serverHttpRequest) {
    return serverHttpRequest.getQueryParams();                           // [3]
  }
  // если запрос - неведомая дичь
  throw new IllegalArgumentException("Неизвестный тип запроса: " + request.getClass());
}

1️⃣ Превращаем Map<String, String[]> в Map<String, List<String>>. Наверняка есть более изящные способы это сделать.
2️⃣ Адаптируем к MultiValueMap<String, String>.
3️⃣ Если бы так было везде, эта статья бы не появилась.

Извлечение cookies

С печеньками всё несколько проще, хотя отличия всё-таки есть. Во-первых, аналогичные по смыслу методы

javax.servlet.http.HttpServletRequest#getCookies
org.springframework.http.server.reactive.ServerHttpRequest#getCookies

в сервлетном и реактивном стеках соответственно возвращают коллекции разных классов:

javax.servlet.http.Cookie
org.springframework.http.HttpCookie

Во-вторых, эта разница сводится не только к именам, но и к предназначениям классов (и, как следствие, к составу их полей). Класс javax.servlet.http.Cookie подходит как для cookies в запросах (заголовок Cookie), так и в ответах (заголовок Set-Cookie), поэтому содержит много полей. А класс org.springframework.http.HttpCookie описывает cookies только в запросах, поэтому содержит лишь поля name и value. Для ответов в Spring Web выделен отдельный класс ResponseCookie, но поскольку здесь речь идёт только о запросах, он нам не пригодится.

Классы для cookies в обоих стеках следят за валидностью своих данных по RFC 6265.

Решающим критерием выбора между этими классами, очевидно, является пригодность к применению с любым стеком, и здесь, безусловно, выигрывает класс HttpCookie, потому что он декларирован в независимом от стека модуле Spring Web. Этот класс и возьмём. Тогда получение линейного списка cookies можно оформить примерно в такой метод:

public List<HttpCookie> getCookies(HttpRequest request) {
  // если запрос сервлетный
  if (request instanceof ServletServerHttpRequest servletRequest) {
    Cookie[] cookies = servletRequest.getServletRequest().getCookies();
    if (cookies == null) {
      return List.of();
    }
    return Arrays.stream(cookies)
      .map(servletCookie ->              // [1]
           new HttpCookie(servletCookie.getName(), servletCookie.getValue()))
      .toList();
  }
  // если запрос реактивный
  if (request instanceof ServerHttpRequest reactiveRequest) {
    return reactiveRequest.getCookies()
      .values().stream()
      .flatMap(List::stream)             // [2]
      .toList();
  }
  // если запрос - неведомая дичь
  throw new IllegalArgumentException("Неизвестный тип запроса: " + request.getClass());
}

1️⃣ Превращаем «сервлетные» cookies в абстрактные.
2️⃣ Для простоты дальнейшей демонстрации превращаем MultiValueMap<String, HttpCookie> в линейный список List<HttpCookie> (хотя в production коде это было бы, скорее, вредительством).

Извлечение атрибутов

В отличие от параметров/заголовков/cookies/тела, атрибуты не являются частью протокола HTTP. Это своего рода внутренний механизм серверных фреймворков для передачи мета-данных между этапами обработки запроса внутри сервера. Соответственно, и имплементация этого механизма может отличаться от фреймворка к фреймворку. Так, например, в Spring WebFlux нет понятия «атрибуты запроса», вместо этого там используются атрибуты обмена – сущности, объединяющей запрос и ответ в рамках одного такта взаимодействия между клиентом и сервером. Обмен представлен интерфейсом org.springframework.web.server.ServerWebExchange, у которого есть методы getAttribute*(). А в Servlet API для той же цели служат методы javax.servlet.ServletRequest#getAttribute*(). В обоих случаях тип значения атрибутов объявлен как Object, чтобы через них можно было передавать любые объекты, а не только строки. Это вполне допустимо благодаря тому, что наружу (клиентам) атрибуты, как правило, не отдаются.

Поскольку атрибуты могут использоваться как фреймворками, так и прикладной логикой, во избежание коллизий имена атрибутов должны следовать тем же правилам, что и имена Java-пакетов, в частности (выдержка из javadoc на ServletRequest):

Names beginning with java.* and javax.* are reserved for use by the Servlet specification. Names beginning with sun.*, com.sun.*, oracle.* and com.oracle.*) are reserved for use by Oracle Corporation.

Этого придерживается и Spring – он предваряет имена своих вспомогательных атрибутов (например, bestMatchingHandler и producibleMediaTypes) полным именем класса HandlerMapping. И хотя имена и смысл самих атрибутов в Spring WebMVC и WebFlux почти полностью совпадают, фактические имена атрибутов различаются, потому что в каждом фреймворке есть свой класс HandlerMapping:

org.springframework.web.servlet.HandlerMapping
org.springframework.web.reactive.HandlerMapping

Из этого следует, что просто извлекать атрибуты из разных мест для WebMVC и WebFlux недостаточно, нужно ещё учитывать их имена. Однако здесь важно не увлечься и не начать писать логику преобразования имён одного фреймворка в имена другого. Ведь, если задуматься, атрибуты каждого фреймворка должны запрашиваться только в рамках работы на его родном стеке. Другими словами, попытка запросить атрибут, например, с сервлетным именем при работе на реактивном стеке – это явный признак произошедшей путаницы. И в таком случае нужна не «умная» логика преобразования, а тревога, чтобы как можно раньше «подсветить» место потенциального источника проблем. В случае с сервлетным стеком какой-нибудь первоклассный программист (учащийся в первом классе) написал бы эту подсветку примерно так:

Assert.doesNotContain(attributeName, "org.springframework.web.reactive.HandlerMapping",
  "В сервлетном режиме не должен запрашиваться реактивный атрибут " + attributeName);

, где Assert – это org.springframework.util.Assert.

Придерживаясь взятого ранее курса на возврат полной линейной коллекции запрашиваемых данных запроса, класс HttpRequestAccessor мог бы предоставлять для атрибутов вот такой метод:

public Map<String, String> getAttributes(HttpRequest httpRequest) {
  // если запрос сервлетный
  if (httpRequest instanceof ServletServerHttpRequest wrapper) {
    HttpServletRequest httpServletRequest = wrapper.getServletRequest();
    Map<String, String> attributes = new LinkedHashMap<>();
    Enumeration<String> attributeNames = httpServletRequest.getAttributeNames();
    while (attributeNames.hasMoreElements()) {                           // [1]
      String attributeName = attributeNames.nextElement();
      Object attributeValue = httpServletRequest.getAttribute(attributeName);
      attributes.put(attributeName, String.valueOf(attributeValue));     // [2]
    }
    return attributes;
  }
  // если запрос реактивный
  if (httpRequest instanceof ServerHttpRequest) {
    ServerWebExchange currentExchange = CURRENT_EXCHANGE_HOLDER.get();  // [3]
    return currentExchange.getAttributes()
      .entrySet().stream()
      .map(entry -> Map.entry(entry.getKey(), String.valueOf(entry.getValue())))
      .collect(toMap(Map.Entry::getKey, Map.Entry::getValue));
  }
  // если запрос - неведомая дичь
  throw new IllegalArgumentException("Неизвестный класс запроса: " + httpRequest.getClass());
}

1️⃣ Атрибуты в Servlet API нельзя получить сразу все, поэтому приходится перебирать их по именам.
2️⃣ Для наглядности сводим значение атрибута к строке (в production-коде делать так не надо

Источник: https://habr.com/ru/company/cft/blog/648821/


Интересные статьи

Интересные статьи

Один из ключевых сценариев работы в CRM это общение с клиентом в удобном для него канале. По почте, по телефону, по SMS или в мессенджере. Особенно выделяется WhatsApp — интеграцию с ...
Сегодня, в восьмом уроке курса по Vue, состоится ваше первое знакомство с компонентами. Компоненты — это блоки кода, подходящие для многократного использования, которые могут включать...
Если спросить разработчиков, почему они выбрали веб-компоненты для своего проекта, довольно часто можно услышать такие аргументы Это веб-стандарт, сделанный открытым сообществом, а не какой-...
Несмотря на то, что “в коробке” с Битриксом уже идут модули как для SOAP (модуль “Веб сервисы” в редакции “Бизнес” и старше), так и для REST (модуль “Rest API” во всех редакциях, начиная с...
Здравствуйте. Я уже давно не пишу на php, но то и дело натыкаюсь на интернет-магазины на системе управления сайтами Битрикс. И я вспоминаю о своих исследованиях. Битрикс не любят примерно так,...