Введение
Недавно на работе я столкнулся с задачей, которая на первый взгляд казалась несложной: необходимо было создать API для отправки кастомных уведомлений пользователям портала. Требования были следующими:
Обычно, когда Liferay-разработчику нужно отправить уведомление, он смотрит в сторону OSGi сервисов, таких как UserNotificationEventLocalService. Однако этот путь имеет недостатки:
-
Сложность: Требуется писать много бойлерплейт-кода для создания payload'а.
-
Отсутствие истории: Стандартный механизм уведомлений — это «доставка». Если пользователь прочитал и удалил уведомление, оно исчезает.
-
Требования бизнеса: В моем случае требовался аудит. Мы должны были хранить историю: кому, когда и что было отправлено.
В экосистеме Liferay (особенно версий 7.4+) существует множество способов решить одну и ту же задачу. Начав исследовать нативные Liferay OSGi сервисы, я понял, что прямого и простого способа «просто отправить уведомление и сохранить его» нет. Обычно это требует написания собственных сущностей, service-layer'а и довольно громоздкого кода для формирования пейлоада уведомления.
Писать с нуля кастомный модуль с таблицами БД только ради лога уведомлений показалось излишеством. Я обратил внимание на Liferay Objects — инструмент, который позволяет создавать сущности и автоматически генерировать для них REST API.
В этой статье я покажу, как программно настроить эту систему через REST API и как выглядит сервис отправки.
Архитектурное решение
Идея заключается в инверсии контроля. Вместо того чтобы мы «стучались» в сервис уведомлений, мы будем создавать запись в базе данных, а Liferay сам отреагирует на это событие и разошлет уведомления.
Идея проста:
-
Мы создаем Liferay Object (назовем его SimpleNotification).
-
Добавляем ему поля: кому отправить (ID, Email) и текст сообщения.
-
Настраиваем Notification Templates (шаблоны уведомлений).
-
Создаем Object Actions — триггеры, которые срабатывают onAfterAdd (после создания записи объекта) и отправляют уведомление по шаблону.
Таким образом, отправка уведомления сводится к одному REST-запросу: создать запись в объекте. Liferay сам сохранит данные (история) и сам разошлет уведомления (триггеры). Ниже я подробно опишу, как автоматизировать создание этой инфраструктуры на Kotlin (Spring Boot), чтобы не «накликивать» это каждый раз вручную в Control Panel.
Этап 1: Автоматическая конфигурация (Infrastructure as Code)
Мы будем использовать подход "Configuration as Code". При старте приложения специальный компонент проверяет, применены ли настройки, и если нет — создает их через Headless API самого Liferay.
1. Определение объекта (Object Definition).
if (isConfigApplied()) {
logger.info("Configuration '$configName' already applied. Skipping...")
return
}
val objectDef = client.post()
.uri("/object-admin/v1.0/object-definitions")
.body(mapOf(
"label" to mapOf("ru_RU" to "SimpleNotification"),
"name" to "SimpleNotification",
"pluralLabel" to mapOf("ru_RU" to "SimpleNotifications"),
"scope" to "company",
"enableObjectEntryHistory" to false,
"system" to false
))
.buildAdminAuth(adminName, adminPassword)
.retrieve()
.body(Map::class.java)
val objectDefinitionId = objectDef?.get("id") as Number
Важно: Мы используем scope: company, чтобы уведомления можно было отправлять пользователям вне зависимости от конкретного сайта.
2. Структура данных (Поля)
Определяем, из чего состоит наше уведомление. Нам нужно три поля: ID получателя (чтобы Liferay знал, кому слать), Email (для дублирования) и само Сообщение.
fun createField(fieldName: String, type: String, label: String) {
client.post()
.uri("/object-admin/v1.0/object-definitions/$objectDefinitionId/object-fields")
.body(mapOf(
"businessType" to type,
// Маппинг типов: LongInteger в базе становится BIGINT, Text -> VARCHAR/CLOB
"DBType" to if(type=="LongInteger") "Long" else "String",
"label" to mapOf("ru_RU" to label),
"name" to fieldName,
"required" to true
))
.buildAdminAuth(adminName, adminPassword)
.retrieve()
.body(Map::class.java)
}
createField("recipientUserId", "LongInteger", "recipientUserId")
createField("recipientUserMail", "Text", "recipientUserMail")
createField("message", "Text", "message")
3. Публикация объекта
Это критический шаг. Просто создать объект недостаточно — он будет в статусе "Draft". Чтобы Liferay сгенерировал для него API эндпоинты (/o/c/simplenotifications) и создал таблицы в БД, его нужно опубликовать.
client.post()
.uri("/object-admin/v1.0/object-definitions/$objectDefinitionId/publish")
.buildAdminAuth(adminName, adminPassword)
.retrieve()
.body(Map::class.java)
Этап 2: Шаблонизация и Триггеры
Теперь, когда у нас есть "контейнер" для данных, нужно научить Liferay отправлять уведомления при поступлении этих данных.
1. Шаблоны уведомлений (Notification Templates)
Мы создаем два шаблона. Обратите внимание на синтаксис [%ObjectName_FieldName%]. Это плейсхолдеры, которые Liferay заполнит значениями из полей нашего объекта.
А. Уведомление в «Колокольчик»:
val userNotification = client.post()
.uri("/notification/v1.0/notification-templates")
.body(mapOf(
"name" to "SimpleNotificationTemplate",
"type" to "userNotification",
"recipientType" to "term",
"subject" to mapOf("ru_RU" to "[%SIMPLENOTIFICATION_MESSAGE%]"),
"recipients" to listOf(mapOf("term" to "[%SIMPLENOTIFICATION_RECIPIENTUSERID%]"))
))
.buildAdminAuth(adminName, adminPassword)
.retrieve()
.body(Map::class.java)
val userTemplateId = userNotification?.get("id") as Number
Б. Уведомление на Email: Для Email структура сложнее, так как используется XML-конфигурация получателей.
val emailNotification = client.post()
.uri("/notification/v1.0/notification-templates")
.body(mapOf(
"name" to "SimpleNotificationTemplateMail",
"type" to "email",
"recipientType" to "email",
"subject" to mapOf("ru_RU" to "Уведомление"),
"recipients" to listOf(mapOf(
"from" to "no_reply@liferay.com",
"to" to "<root available-locales=\"ru_RU\" default-locale=\"ru_RU\"><Value language-id=\"ru_RU\">[%SIMPLENOTIFICATION_RECIPIENTUSERMAIL%]</Value></root>",
"singleRecipient" to "true"
)),
"body" to mapOf("ru_RU" to "<p>[%SIMPLENOTIFICATION_MESSAGE%]</p>")
))
.buildAdminAuth(adminName, adminPassword)
.retrieve()
.body(Map::class.java)
val emailTemplateId = emailNotification?.get("id") as Number
2. Действия (Object Actions)
Финальная связка. Мы создаем Action, который говорит: «Когда в SimpleNotification добавляется запись (onAfterAdd), исполни (notification) указанный шаблон».
fun createAction(name: String, label: String, templateId: Number) {
client.post()
.uri("/object-admin/v1.0/object-definitions/$objectDefinitionId/object-actions")
.body(mapOf(
"active" to true,
"label" to mapOf("ru_RU" to label),
"name" to name,
"objectActionExecutorKey" to "notification",
"objectActionTriggerKey" to "onAfterAdd",
"parameters" to mapOf("notificationTemplateId" to templateId)
))
.buildAdminAuth(adminName, adminPassword)
.retrieve()
.body(Map::class.java)
}
createAction("triggerSimpleNotification", "TriggerSimpleNotification", userTemplateId)
createAction("triggerSimpleNotificationMail", "TriggerSimpleNotificationMail", emailTemplateId)
markConfigApplied()
Этап 3: Клиентский сервис (Runtime)
Теперь инфраструктура готова. Нам нужен сервис, который будет использоваться бизнес-логикой для отправки уведомлений.
Благодаря Liferay Objects, мы получили REST API по адресу /o/c/simplenotifications. Наш сервис просто делает POST-запрос на этот адрес.
Особенности реализации:
-
Асинхронность: Мы используем Coroutines (Dispatchers.IO), чтобы отправка уведомления не блокировала основной поток выполнения бизнес-логики.
-
Шаблонизация текста: Чтобы не передавать "сырой" текст каждый раз, реализован метод toNotificationRequest с поддержкой подстановок через StringSubstitutor.
private val client: RestClient = restClientBuilder
.baseUrl("$baseUrl/o/c/simplenotifications")
.build()
data class NotificationRequest(
val recipientUserId: Long,
val recipientUserMail: String,
val message: String,
val substitutions: Map<String, Any?>,
// Важно при создании объекта выдать разрешение на его просмотр, иначе пользователь не получит уведомление
val permissions: List<Permission> = listOf(Permission(listOf("VIEW"), "User"))
)
data class Permission(
val actionIds: List<String>,
val roleName: String
)
fun sendNotification(request: NotificationRequest) {
CoroutineScope(Dispatchers.IO).launch {
try {
client.post()
.header("Content-Type", "application/json")
.body(request)
.buildAdminAuth(adminName, adminPassword)
.retrieve()
} catch (e: RestClientResponseException) {
logger.error("Failed to send notification to userId=${request.recipientUserId}: ${e.responseBodyAsString}", e)
} catch (e: Exception) {
logger.error("Unexpected error while sending notification to userId=${request.recipientUserId}", e)
}
}
}
И вспомогательный метод для удобного вызова из кода:
// Пример использования:
// user.toNotificationRequest("Привет, %(username)!", mapOf("username" to "Ivan"))
fun LiferayUserDto.toNotificationRequest(message: String, substitutions: Map<String, Any?>) = NotificationRequest(
recipientUserId = this.id,
recipientUserMail = this.email,
message = StringSubstitutor(substitutions, "%(", ")").replace(message),
substitutions = substitutions
)
Итог
Используя этот подход, мы получили:
-
Гибкость: Настройка уведомлений происходит программно (Configuration as Code), но при этом использует движок Liferay.
-
Аудит: Таблица SimpleNotification (доступная в Control Panel -> Objects) автоматически хранит историю всех отправленных сообщений. Мы можем зайти в админку и посмотреть, кому и что уходило.
-
Простота API: Нам не пришлось писать OSGi модули для UserNotificationEvent. Мы просто делаем POST запрос.
Это решение отлично подходит для интеграции внешних систем с Liferay или для микросервисной архитектуры, где Liferay выступает в роли Headless-платформы.