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.
Состав пакета
| Пакет | Назначение |
|---|---|
|
|
|
Универсальная форма |
|
Регистрация 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:
-
TelemetryFilterначинает измерение пользовательских и административных запросов; -
SetCharacterEncodingFilterустанавливает UTF-8; -
AuthFilterвосстанавливает пользователя из HTTP-сессии, токена или настроенного провайдера аутентификации; -
SetRequestParamsFilterдобавляет в HTML/JSP-запросы кеши и другиеctx*-атрибуты; -
SentryFilterоборачивает вызовы*.do.
После выбора mapping Struts заполняет request-scoped DynActionForm и вызывает BaseAction.dispatchMethod(…).
Дальнейшая последовательность одинакова для actions ядра и плагинов:
-
в форму записываются
HttpServletRequest,HttpServletResponseи текущий пользователь; -
создаётся транзакционный
ServerContextпользователя, а для неавторизованного запроса используетсяUSER_SYSTEM; -
вычисляется ключ права
<полное имя Action>:<action>; -
action вызывается через reflection;
-
после нормального возврата выполняется общий commit;
-
освобождается переданный в параметре
lockFreeзахват, обновляется активность пользовательской сессии; -
в
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 | Содержание |
|---|---|
|
|
|
Параметры пагинации. Если переданы |
|
Один |
|
Текущий пользователь и параметры выданного ему права. |
|
Объект стандартного JSON-ответа. |
|
Исходные servlet request и response. Они устанавливаются |
Для параметров предусмотрены 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 | Поведение |
|---|---|---|---|
|
|
400 |
Ожидаемая ошибка введённых данных. |
|
|
400 |
Безопасное сообщение пользователю. |
Остальные |
|
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
Минимальный порядок:
-
создать Kotlin-класс с суффиксом
Actionв пакете..struts.action..и унаследоватьBaseAction; -
объявить метод с одной из четырёх поддерживаемых сигнатур;
-
проверить входные данные и выбрасывать
BGMessageExceptionилиBGMessageInvalidExceptionдля ожидаемого отказа; -
вызвать service/use case, не размещая SQL в action;
-
заполнить
form.responseи вернуть JSON forward либо вернуть объявленный JSP forward; -
добавить mapping в
struts-config-<module>.xml; -
добавить permission node для каждого доступного значения
action; -
добавить unit-тесты валидации, правозависимого поведения и граничных сценариев; для изменения общей инфраструктуры отдельно проверить транзакцию и формат ошибки;
-
если добавлен 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 == nullaction выполняется с контекстом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. -
В
WebSocketConfigtoken читается из query string и используется reflection к внутреннему классу Tomcat. -
Общий WebSocket scope не имеет явного shutdown в
contextDestroyed; он живёт до остановки JVM.
Общая цепочка запуска ERP и frontend-контракт описаны в разделе "Общая информация".