Struts: HTTP actions и WebSocket

Пакет ru.bgcrm.struts содержит основной HTTP-слой ERP на Apache Struts 1.3 и общую инфраструктуру WebSocket. Это не отдельный Gradle-модуль: код входит в core, а маршруты, JSP и права поставляются отдельными ресурсами.

Новые HTTP API по-прежнему могут использовать этот слой, если должны работать с существующим интерфейсом ERP. Сам action координирует запрос, проверку входных данных и формирование ответа. SQL размещается в DAO или repository, а прикладная логика находится в service, command, query или use case.

Состав пакета

Пакет Назначение

ru.bgcrm.struts.action

BaseAction, общие helpers и actions ядра. Подпакет admin обслуживает административные экраны; остальные подпакеты содержат загрузку адресов, диагностику, экспорт, ярлыки и формирование табелей.

ru.bgcrm.struts.form

Универсальная форма DynActionForm, стандартный JSON-ответ Response и функции чтения JSON body.

ru.bgcrm.struts.websocket

Регистрация WebSocket endpoints из плагинов, аутентификация handshake, общий жизненный цикл соединения, ping/pong и JMX-счётчик сессий.

Папка содержит и прикладные actions крупных подсистем: процессов, контрагентов, сообщений, адресов, пользователей и настроек. Их наличие в одном пакете является историческим устройством монолита, а не рекомендацией помещать новую бизнес-логику в web-слой.

Регистрация HTTP-маршрута

Все запросы *.do направляются на ru.bgcrm.servlet.ActionServlet через WEB-INF/web.xml. При запуске servlet просматривает рабочий каталог webapps/WEB-INF и передаёт Struts все файлы, имя которых начинается с struts-config. Порядок файлов зависит от результата File.listFiles() и кодом не фиксируется, поэтому одинаковый path в разных конфигурациях создавать нельзя.

Основная конфигурация ядра находится в core/struts/struts-config.xml. Подключаемый Gradle-модуль может положить собственный struts-config-<module>.xml в каталог struts; сборка скопирует его в общий WEB-INF дистрибутива.

Общая форма объявлена один раз:

<form-bean name="form" type="ru.bgcrm.struts.form.DynActionForm"/>

Типичный mapping:

<action path="/user/example"
        parameter="action"
        type="ru.bgcrm.example.struts.action.ExampleAction"
        name="form"
        scope="request">
    <forward name="default" path="/WEB-INF/jspf/user/example/default.jsp"/>
</action>

Такой mapping принимает URL /user/example.do. Параметр action выбирает метод класса, например /user/example.do?action=find вызывает find(…​). HTTP-метод в диспетчеризации не участвует: GET и POST с одинаковым URL и action попадают в один метод.

ActionServlet загружает только файлы, уже попавшие в webapps/WEB-INF. После добавления или изменения ресурсов локально выполните ./gradlew :app:processResourcesRun либо пересоберите дистрибутив.

Обработка запроса

До Struts запрос проходит servlet filters в порядке из WEB-INF/web.xml:

  1. TelemetryFilter начинает измерение пользовательских и административных запросов;

  2. SetCharacterEncodingFilter устанавливает UTF-8;

  3. AuthFilter восстанавливает пользователя из HTTP-сессии, токена или настроенного провайдера аутентификации;

  4. SetRequestParamsFilter добавляет в HTML/JSP-запросы кеши и другие ctx*-атрибуты;

  5. SentryFilter оборачивает вызовы *.do.

После выбора mapping Struts заполняет request-scoped DynActionForm и вызывает BaseAction.dispatchMethod(…​). Дальнейшая последовательность одинакова для actions ядра и плагинов:

  1. в форму записываются HttpServletRequest, HttpServletResponse и текущий пользователь;

  2. создаётся транзакционный ServerContext пользователя, а для неавторизованного запроса используется USER_SYSTEM;

  3. вычисляется ключ права <полное имя Action>:<action>;

  4. action вызывается через reflection;

  5. после нормального возврата выполняется общий commit;

  6. освобождается переданный в параметре lockFree захват, обновляется активность пользовательской сессии;

  7. в finally закрывается ServerContext и при включённом журналировании создаётся запись web-request.

ServerContext доступен из action, service и repository текущего потока. Он кеширует открытые соединения и сервисы до конца запроса. Подробности приведены в документации соединений и транзакций.

Сигнатуры action-методов

BaseAction ищет метод с выбранным именем непосредственно в конкретном классе через getDeclaredMethod. Поддерживаются только четыре точные сигнатуры:

ActionForward method(ActionMapping mapping, DynActionForm form, ConnectionSet conSet)
ActionForward method(ActionMapping mapping, DynActionForm form, Connection connection)
ActionForward method(ActionMapping mapping, DynActionForm form,
                     HttpServletRequest request, HttpServletResponse response,
                     ConnectionSet conSet)
ActionForward method(ActionMapping mapping, DynActionForm form,
                     HttpServletRequest request, HttpServletResponse response,
                     Connection connection)

Метод с другой сигнатурой завершится NoSuchMethodException. Унаследованный прикладной метод также не будет найден, если он не объявлен в самом concrete action.

Для доступа к master и slave через старый DAO-код используется ConnectionSet. Новый код получает repository и service из ServerContext; сам action не должен содержать SQL. Все наследники BaseAction должны оканчиваться на Action и находиться в пакете ..struts.action... Это проверяют ArchUnit-тесты.

DynActionForm

DynActionForm одновременно реализует Struts ActionForm, Commons BeanUtils DynaBean и DynaClass. Неизвестное имя поля формы трактуется как String[], поэтому один объект принимает произвольные query-параметры, HTML form fields и multipart upload без отдельного класса формы для каждого mapping.

Основные данные формы:

Поле или API Содержание

param

ArrayHashMap со строковыми и множественными параметрами запроса.

page

Параметры пагинации. Если переданы pageableId и новый pageSize, BaseAction сохраняет размер страницы в персонализации пользователя.

file

Один FormFile, заполненный Struts для multipart-запроса.

user, permission

Текущий пользователь и параметры выданного ему права.

response

Объект стандартного JSON-ответа.

httpRequest, httpResponse

Исходные servlet request и response. Они устанавливаются BaseAction до вызова прикладного метода.

Для параметров предусмотрены getters строк, массивов, int, long, boolean, дат, периодов и наборов ID. Методы с числовым результатом часто возвращают 0 для отсутствующего или неверного значения; если 0 является допустимым ID, проверяйте исходную строку отдельно.

Параметр pageableId определяет ключ сохранённого размера страницы. Если он не задан, ключ строится из requestURI и action, поэтому переименование маршрута или метода создаст новую настройку пользователя.

JSON body

Для типизированного тела используются:

  • Java API form.parseBody(MyRequest.class);

  • Kotlin extension form.parseBody<MyRequest>();

  • устаревший формат JSON в form-параметре: parseBodyFromParam(…​), по умолчанию из параметра data.

Стандартные mapper-ы игнорируют неизвестные свойства. Input stream запроса читается целиком и только один раз; повторный parseBody в том же запросе получит пустое тело. Java-вариант преобразует ошибку разбора в BGMessageException, а parseBodyFromParam записывает ошибку в лог и возвращает null.

Не передавайте одновременно обычный FormData и произвольный JSON body в расчёте, что оба источника будут объединены автоматически. Query/form parameters заполняет Struts, JSON DTO action читает явно.

DynActionForm.SERVER_FORM является общим mutable singleton с пользователем USER_SYSTEM для событий вне HTTP-запроса. Не записывайте в него параметры или response data и не передавайте его в асинхронную работу как состояние операции.

Права

Ключ права строится из runtime-класса и параметра action:

ru.bgcrm.example.struts.action.ExampleAction:find

Если параметр отсутствует, суффикс равен null. Дерево прав ядра загружается из XML-файлов рабочего каталога action, например core/action/kernel.xml в исходниках. JSON/XML-дескриптор плагина может добавить actionPermissions без отдельного файла.

Для каждого доступного метода должна существовать запись права:

<item action="ru.bgcrm.example.struts.action.ExampleAction:find"
      title="Просмотр примеров"
      allowAll="1"/>

allowAll разрешает вызов любому аутентифицированному пользователю. Без allowAll пользователь должен получить право через набор прав. Если ключ отсутствует в выданных правах и в общем дереве, BaseAction возвращает ошибку "Действие …​ не найдено". Если узел существует, но не выдан, возвращается "Действие …​ запрещено".

Вложенный текст XML-узла описывает дополнительные параметры права. Они доступны action через form.permission и позволяют ограничивать группы, очереди или другие значения без создания отдельного ключа. Один узел может содержать несколько синонимичных action keys, разделённых ; или ,.

Атрибут notLogging отключает запись этого действия в web_requests_log_YYYYMM. Он не отключает access log, application log и телеметрию.

Ответ

HTML

Action возвращает ActionForward, объявленный внутри mapping. processUserTypedForward(…​) по умолчанию выбирает HTML и вызывает mapping.findForward(name). Параметр запроса forward может заменить имя forward, а forwardFile возвращает произвольный путь напрямую. Используйте их только в существующих совместимых сценариях.

SetRequestParamsFilter добавляет в request атрибуты ctxSetup, ctxUser, справочники пользователей, процессов, параметров и datasource. Они нужны старым JSP. В action эти JSP-атрибуты использовать не следует: данные должны приходить из form, cache, service или repository.

JSON

JSON выбирается параметром responseType=json или заголовком Accept: application/json. processJsonForward(…​) сериализует form.response с content type application/json; charset=UTF-8:

{
  "status": "ok",
  "message": "",
  "data": {},
  "eventList": []
}

Прикладные данные добавляются через form.setResponseData(key, value). Для результата поиска Response.addSearchResult(…​) помещает list и, если нужна пагинация, page. eventList содержит клиентские события, которые общий frontend-interceptor исполняет после ответа.

Поддерживается JSONP-параметр callback: ответ оборачивается в <callback>(…​);. Это legacy-механизм; для нового кода используйте обычный JSON.

Файл и поток

Action может настроить headers, получить form.httpResponseOutputStream, записать файл и вернуть null. Значения responseType=stream и responseType=arraybuffer используются потребителями, которым бинарный ответ нельзя обрабатывать как JSON или HTML, но сами по себе не устанавливают content type и не записывают данные.

Не закрывайте и не flush-ите поток раньше завершения изменений БД. Текущий BaseAction подтверждает транзакцию только после возврата прикладного метода.

Транзакции и ошибки

При нормальном возврате BaseAction вызывает commit() для всех соединений текущего ServerContext. Callbacks, зарегистрированные через invokeAfterCommit, выполняются после commit. Затем контекст закрывает соединения в finally.

Если action выбросил исключение, общий commit не вызывается. Явного rollback() в BaseAction нет: контекст только закрывает JDBC-соединения и полагается на поведение пула для незавершённой транзакции. Не перехватывайте ошибку после частичной записи и не возвращайте успешный response, если операция должна откатиться целиком.

Ошибки преобразуются следующим образом:

Исключение JSON status HTTP status Поведение

BGMessageInvalidException

invalid

400

Ожидаемая ошибка введённых данных.

BGMessageException

invalid

400

Безопасное сообщение пользователю.

Остальные Throwable

error

500

Ошибка записывается с stack trace в серверный лог.

Для JSON HTTP-код устанавливается, если клиент прислал JSON Accept либо включена настройка core.base_action.use_error_codes=true. Legacy-запрос с одним responseType=json при выключенной настройке может получить HTTP 200 и должен проверять поле status. Для HTML ожидаемая ошибка возвращается как text/plain; при неожиданной ошибке старый путь также выводит stack trace в response.

processJsonForward(…​) записывает и flush-ит response внутри action, а общий commit выполняется после возврата метода. Класс TransactionalResponseWrapper, который должен был менять этот порядок, в BaseAction отключён. Поэтому клиент может получить успешный JSON или начало файла до ошибки commit. Не выполняйте внешние побочные эффекты, опираясь только на получение такого ответа; критичную работу запускайте через ServerContext.invokeAfterCommit или AsyncWorker.submitAfterCommit(…​).

Журналирование и телеметрия

Если у permission node нет notLogging, BaseAction после закрытия основного контекста открывает отдельное соединение и записывает запрос через WebRequestLogDAO. Таблица выбирается по месяцу: web_requests_log_YYYYMM. В запись входят action, пользователь, параметры, длительность и result_status.

Журналирование выполняется отдельной транзакцией после прикладной операции. Его ошибка не возвращает исходную транзакцию, но способна повлиять на завершение HTTP-вызова, поскольку находится в finally.

TelemetryFilter измеряет весь filter chain и после ответа публикует WebRequestEndEvent с HTTP-методом, URI, action key, статусом и длительностью. BaseAction устанавливает success, invalid или failure; запрос, не дошедший до этой точки, учитывается как unknown.

Добавление action

Минимальный порядок:

  1. создать Kotlin-класс с суффиксом Action в пакете ..struts.action.. и унаследовать BaseAction;

  2. объявить метод с одной из четырёх поддерживаемых сигнатур;

  3. проверить входные данные и выбрасывать BGMessageException или BGMessageInvalidException для ожидаемого отказа;

  4. вызвать service/use case, не размещая SQL в action;

  5. заполнить form.response и вернуть JSON forward либо вернуть объявленный JSP forward;

  6. добавить mapping в struts-config-<module>.xml;

  7. добавить permission node для каждого доступного значения action;

  8. добавить unit-тесты валидации, правозависимого поведения и граничных сценариев; для изменения общей инфраструктуры отдельно проверить транзакцию и формат ошибки;

  9. если добавлен JSP или frontend repository, проверить HTML и JSON-варианты запроса.

Имена action-методов являются частью внешнего контракта: они встречаются в URL, frontend repositories, XML прав и журналах. Выбирайте имя по операции (find, get, update, delete), не по элементу интерфейса, и синхронно меняйте все четыре места при переименовании.

Тесты самого пакета находятся в core/src/test/java/ru/bgcrm/struts. Сейчас отдельно проверяются parsing DynActionForm, несколько прикладных actions и неиспользуемый в runtime TransactionalResponseWrapper. Полного unit-теста диспетчеризации, проверки прав, commit/error pipeline и WebSocket lifecycle нет, поэтому изменение BaseAction, ActionServlet или BaseWebSocket требует интеграционной проверки.

WebSocket

WebSocketInitializer зарегистрирован как ServletContextListener в WEB-INF/web.xml. При старте он получает JSR 356 ServerContainer, читает WebSocket extensions всех плагинов и создаёт endpoint для каждой пары path/handler:

{
  "extensions": {
    "webSockets": [
      {
        "path": "/example/events",
        "handler": "ru.bgcrm.example.struts.ExampleWebSocket"
      }
    ]
  }
}

Handler должен наследовать BaseWebSocket. Инициализатор загружает класс по имени, подключает WebSocketConfig и регистрирует endpoint в Tomcat. Ошибка одного plugin endpoint записывается в лог и не мешает попытке зарегистрировать остальные. Endpoint /wizard/instance пока добавляется в коде отдельно от plugin descriptor.

Аутентификация handshake

WebSocketConfig сначала использует существующую HttpSession. Если её нет, берётся query-параметр token; через внутренний объект Tomcat извлекается HttpServletRequest, после чего TokenUserSessionManager создаёт или восстанавливает сессию.

Handshake разрешается только если в сессии есть user ID и пользователь найден в UserCache. В EndpointConfig.userProperties передаются userId и userSessionId. Передача token в query string является legacy-контрактом: URL может попасть в proxy/access logs, поэтому не добавляйте в него другие секреты.

BaseWebSocket

Базовый класс делает onOpen, onError и onClose final и оставляет наследнику hooks:

  • onOpenBody(session, userId, userSessionId);

  • onErrorBody(session);

  • onCloseBody(session).

Открытые сессии хранятся в общей ConcurrentHashMap по session.id. При завершении HTTP-сессии UserSessionExpiredEvent закрывает связанные WebSocket-сессии. Раз в 30 секунд общий coroutine отправляет ping; после полученного pong запоминается время, а соединение без pong более 60 секунд закрывается.

Для обращения к БД из message handler создайте отдельный ServerContext, например через withContext(user): servlet context из handshake автоматически в callback не переносится. Не сохраняйте JDBC connection, DynActionForm или request-scoped service в объекте endpoint.

SocketResponse<T> задаёт общий envelope с status, type и payload, но BaseWebSocket не сериализует его автоматически. Формат сообщений и регистрация text/binary handlers остаются ответственностью конкретного endpoint.

JMX-объект BGCRM:type=WebSocketSession,name=WebSocketAllActiveSessions,description=All sessions active web socket публикует общее число активных сессий. Метод BaseWebSocket.sessionsSummary() дополнительно возвращает путь, user ID, состояние соединения и возраст последнего pong для внутренней диагностики.

Ограничения

  • Маршрутизация определяется параметром action, а не HTTP verb; автоматической REST-семантики и защиты от вызова изменяющей операции через GET нет.

  • BaseAction проверяет права только при наличии пользователя от AuthFilter. Маршрут вне /user/, /admin/, /usermob/* и /login.do не становится защищённым автоматически: при user == null action выполняется с контекстом USER_SYSTEM, а проверка permission node пропускается.

  • Mapping-файлы обнаруживаются без фиксированного порядка и не перезагружаются во время работы.

  • BaseAction использует reflection и допускает только четыре legacy-сигнатуры.

  • Один DynActionForm смешивает transport parameters, пользователя, права, pagination и response; не передавайте его глубже web/use-case boundary без необходимости.

  • JSON может быть отправлен до commit, а error pipeline сохраняет legacy HTTP 200 для части клиентов.

  • Ошибка сериализации JSON внутри processJsonForward(…​) записывается в лог и подавляется; action после этого может подтвердить транзакцию с пустым или неполным response.

  • Ошибка закрывается без явного общего rollback в BaseAction.

  • В WebSocketConfig token читается из query string и используется reflection к внутреннему классу Tomcat.

  • Общий WebSocket scope не имеет явного shutdown в contextDestroyed; он живёт до остановки JVM.

Общая цепочка запуска ERP и frontend-контракт описаны в разделе "Общая информация".