Цель этой статьи — рассказать о возможностях платформы CUBA на примере создания небольшого полезного приложения.
CUBA предназначена для быстрой разработки бизнес-приложений на Java, мы уже писали о ней несколько статей на Хабре.
Обычно на платформе строятся либо реальные, но слишком большие и закрытые информационные системы, либо приложения в стиле “Hello World” или искусственные примеры типа “Библиотеки” на нашем сайте. Поэтому некоторое время назад я и решил попробовать убить сразу двух зайцев — написать для себя полезное приложение и выложить его в общий доступ как пример использования нашей платформы, благо предметная область простая и всем понятная.
Что получилось в итоге
Если коротко, то приложение решает две основные задачи:
- На любой момент времени показывает текущий баланс по всем видам денежных средств: наличные, карты, депозиты, долги и т.д.
- Формирует отчет по категориям доходов и расходов, позволяющий узнать, на что тратились или откуда поступали деньги в определенный период.
Чуть более подробно:
- Различные виды денежных средств представляются счетами.
- Возможны операции по приходу на счет, расходу со счета и переводу денежных средств между счетами.
- В операции прихода или расхода можно задать категорию для уточнения, откуда пришли или на что потрачены деньги.
- Баланс по всем счетам на текущую дату отображается постоянно и пересчитывается после совершения каждой операции.
- Отчет по категориям доходов и расходов показывает сводку по двум произвольным периодам одновременно для быстрого визуального сравнения. Любую категорию можно исключить из сравнения. По каждой строке отчета можно “провалиться” в операции, чтобы посмотреть, из чего она состоит.
- Система представляет собой три веб-приложения, развернутых на одном Tomcat:
- Middleware
- Полнофункциональный UI на CUBA
- Responsive UI на Backbone.js + Bootstrap для удобства ввода операций на мобильных устройствах.
Выглядит несколько избыточно для решения такой простой задачи, но, во первых, приложение создавалось больше для учебных целей, чем практических, а во вторых, ресурсов ему много не требуется — мой собственный экземпляр легко крутится на микро-инстансе Amazon EC2.
Немного скриншотов
Основной UI: список операций
Основной UI: отчет по категориям доходов/расходов
Responsive UI: список операций
Responsive UI: текущий баланс
Как запустить
Исходный код проекта здесь: github.com/knstvk/akkount (КК — это мои инициалы, ничего лучше в голову не пришло).
Сама платформа не является свободной, однако пяти одновременных подключений в бесплатной лицензии более чем достаточно для домашнего применения, так что если кто-то захочет использовать — пожалуйста.
Для работы требуется только JDK 7+ и установленная переменная среды JAVA_HOME. Для сборки откройте командную строку в корне проекта и запустите
gradlew setupTomcat deploy
Загрузится Gradle, который скачает
После этого нужно запустить сервер HSQL и создать БД в подкаталоге data проекта:
gradlew startDb
gradlew createDb
Для запуска Томката можно воспользоваться командой Gradle
gradlew start
либо скриптами
startup.*
в подкаталоге build/tomcat/bin
.Основной веб-интерфейс приложения доступен на
localhost:8080/app
, responsive UI — на localhost:8080/app-portal
. Пользователь — admin, пароль — admin.База данных изначально пустая, для ее наполнения тестовыми данными есть генератор. Он доступен через меню Администрирование -> Консоль JMX -> app-core.akkount -> app-core.akkount:type=SampleDataGenerator. Здесь есть метод
generateSampleData()
, который принимает на вход целое число — количество дней назад от текущей даты, за которые нужно создать операции. Введите, например, 200, и нажмите Запустить. Подождите, пока операция отработает, затем выйдите (значок в правом верхнем углу) и снова войдите в систему. Вы увидите примерно то же самое, что и на моих скриншотах.Как заглянуть внутрь
Для изучения и доработки приложения рекомендую скачать и установить CUBA Studio, IntelliJ IDEA и плагин CUBA для нее.
Далее я не буду подробно останавливаться на том, как и что делается в Студии. Там и так все визуально, есть контекстная помощь, есть видеоматериалы и документация по платформе. Поясню единственный нюанс с использованием базы данных HSQL: Студия при открытии проекта, использующего HSQL DB, запускает свой собственный сервер на порту 9001 и хранит базы данных в каталоге
~/.haulmont/studio/hsqldb
. Это означает, что если вы запускали сервер HSQL отдельно от Студии командами Gradle, вам нужно остановить его. Файлы базы данных, если необходимо, можно просто перенести из data/akk
в ~/.haulmont/studio/hsqldb/akk
.Вообще, приложение можно запустить и на более серьезной БД — PostgreSQL, Microsoft SQL Server или Oracle. Для этого в Студии достаточно выбрать нужный тип БД в Project properties, затем выполнить Entities -> Generate DB Scripts, потом в главном меню Run -> Create database.
Основная задача этой статьи — показать те приемы разработки на платформе, которые не видны в интерфейсе Студии и которые сложно найти в документации, если заранее не знаешь, что искать. Поэтому описание проекта будет фрагментарным, с упором на неочевидные и нестандартные вещи.
Модель данных
Классы сущностей располагаются в модуле
global
, который доступен как среднему слою, так и веб-клиентам.В основном это обычные сущности JPA, соответствующим образом проаннотированные и зарегистрированные в
persistence.xml
. Большинство из них имеет также специфическую для CUBA аннотацию @NamePattern
, которая задает “имя экземпляра” — как отображать в UI конкретный экземпляр сущности, что-то вроде toString()
. Если такая аннотация не задана, в качестве имени экземпляра используется как раз toString()
, возвращающий имя класса и идентификатор объекта. Еще одна специфическая аннотация — @Listeners
, задает классы листенеров создания/изменения объектов. Листенеры сущностей ниже будут рассмотрены подробно.Кроме JPA-сущностей в проекте имеется неперсистентная сущность
CategoryAmount
. Экземпляры неперсистентных сущностей не хранятся в БД, а используются только для передачи данных между слоями приложения и отображения стандартными UI компонентами. В данном случае такая сущность используется для формирования отчета по категориям: на среднем слое извлекаются данные, создаются и заполняются экземпляры CategoryAmount
, а в веб-клиенте эти экземпляры кладутся в источники данных (datasources), и отображаются в таблицах. Стандартные компоненты Table
ничего не знают о происхождении сущностей — для них это просто объекты, известные в метаданных приложения. А чтобы включить неперсистентную сущность в метаданные, необходимо добавить ее классу аннотацию @MetaClass
, атрибутам — аннотацию @MetaProperty
, и зарегистрировать класс в файле metadata.xml
. Персистентные сущности, разумеется, тоже описаны в метаданных — для этого загрузчик метаданных на старте приложения разбирает также и файл persistence.xml
.Рядом с сущностями располагаются и классы перечислений (enums), например
OperationType
. Перечисления, использующиеся в модели данных в атрибутах сущностей, не совсем обычные: они реализуют интерфейс EnumClass
и имеют поле id
. Таким образом от Java-значения отделяется значение, хранимое в БД. Это дает возможность обеспечивать совместимость с данными в production DB при произвольном рефакторинге кода приложения.В файлах
messages.properties
и messages_ru.properties
пакета сущностей находятся локализованные названия сущностей и их атрибутов. Эти названия используются в UI, если визуальные компоненты не переопределяют их на своем уровне. Файлы сообщений — это обычные наборы ключ-значение в кодировке UTF-8. Поиск сообщения для некоторой локали аналогичен правилам PropertyResourceBundle
— сначала ключ ищется в файлах с суффиксом, соответствующим локали, если не найден — в файлах без суффикса.Рассмотрим сущности модели.
Currency
— валюта. Имеет уникальный код и произвольное название. Уникальность кода валюты поддерживается уникальным индексом, который Студия включает в скрипты создания БД, если аннотация@Column
содержит свойствоunique = true
. Платформа содержит обработчик исключения, выбрасываемого при нарушении уникальности в БД. Этот обработчик выдает стандартное сообщение пользователю. Обработчик можно подменить у себя в проекте.Account
— счет. Имеет уникальное имя и произвольное описание. Содержит также ссылку на валюту и отдельное поле кода валюты. Это поле — пример денормализации для улучшения производительности. Так как в списках счета как правило отображаются вместе с кодом валюты, имеет смысл избавиться от join в запросах к БД, добавив код валюты в сам счет. Обновлять код валюты в счете при смене валюты счета (хоть на практике это происходит крайне редко) мы заставим листенер сущности — об этом чуть позже. Счет содержит также атрибутactive
— признак того, что он доступен для использования в новых операциях, и атрибутincludeInTotal
— признак того, что остаток по этому счету нужно включать в совокупный баланс.Category
— категория доходов или расходов. Имеет уникальное имя и произвольное описание. АтрибутcatType
— тип категории, определяется перечислениемCategoryType
. Как уже объяснялось выше, в поле класса и в БД хранится значение, определяемое идентификатором перечисления (в данном случае строка “E” или “I”), а геттер и сеттер, а значит, и весь прикладной код, работают со значениямиCategoryType.INCOME
иCategoryType.EXPENSE
.Operation
— операция. Атрибуты операции: тип (перечислениеOperationType
), дата, счета расхода и прихода (acc1
,acc2
) и соответствующие суммы (amount1
,amount2
), категория и комментарии.Balance
— баланс по счету на некоторую дату. Вообще, для домашней бухгалтерии вполне можно было бы обойтись без этой сущности и рассчитывать баланс всегда динамически “с начала времен”: просто сложить весь приход и отнять весь расход по счету. Но я для интереса решил усложнить реализацию на случай большого количества операций — баланс по счету на начало каждого месяца хранится в экземплярахBalance
, при записи каждой операции балансы на начало следующего месяца (и позже, если есть) пересчитываются. Зато для расчета баланса на текущую дату нужно только взять баланс на начало месяца и посчитать оборот по операциям текущего месяца. Такой подход не вызовет проблем с производительностью со временем.UserData
— key-value хранилище некоторых данных, связанных с пользователем. Например, последний использованный счет, параметры отчета по категориям. То есть здесь хранится то, что необходимо “вспоминать” при повторных действиях пользователя. Возможные ключи заданы константами в классеUserDataKeys
.
Entity Listeners
Если вы работали с JPA, то наверняка использовали и листенеры сущностей. Это удобный механизм для выполнения каких-либо действий в момент сохранения изменений сущностей в БД. Самое важное то, что все изменения, внесенные листенерами, производятся в той же транзакции — аналогично триггерам БД. Поэтому на листенерах удобно организовывать логику поддержания консистентности модели данных.
Листенеры сущностей в CUBA несколько отличаются по реализации от JPA. Класс листенера должен реализовывать один или несколько специальных интерфейсов (
BeforeInsertEntityListener
, BeforeUpdateEntityListener
и др.). Регистрируются листенеры на классе сущности в аннотации @Listeners
перечислением имен классов в массиве строк. Использовать литералы классов листенеров напрямую в классе сущности нельзя, так как сущность — глобальный объект, доступный и среднему слою, и клиентам, а листенер — объект только среднего слоя, недоступный клиентам. Листенеры живут только на среднем слое потому, что им нужен доступ к EntityManager
и другим средствам работы с БД.В данном приложении листенеры сущностей выполняют две функции: во-первых, обновляют денормализованные поля, а во-вторых, пересчитывают балансы по счетам на начало месяцев.
Первая задача тривиальна: листенер
AccountEntityListener
в методах onBeforeInsert()
, onBeforeUpdate()
обновляет значение кода валюты. Для этого ему достаточно обратиться к связанному экземпляру Currency
.Вторая задача по сути является одной из основных в бизнес-логике приложения. Занимается этим
OperationEntityListener
в методах onBeforeInsert()
, onBeforeUpdate()
, onBeforeDelete()
. Кроме пересчета баланса, этот листенер также запоминает в объектах UserData
последние использованные счета.Следует отметить, что в Before-листенерах нет никаких ограничений на использование
EntityManager
, загрузку и модификацию экземпляров любых сущностей. Например, в addOperation()
с помощью Query
загружаются и модифицируются экземпляры Balance
. Они будут сохранены в БД одновременно с операцией в одной транзакции. Иногда в листенере требуется получить “предыдущее” состояние имеющегося сейчас в персистентном контексте объекта, то есть то состояние, которое сейчас находится в БД. Например, в данном случае в
onBeforeUpdate()
нам нужно сначала вычесть из баланса предыдущее значение суммы операции, а потом прибавить новое значение. Для этого в методе getOldOperation()
стартует новая транзакция с помощью persistence.createTransaction()
, в ее контексте получается другой экземпляр EntityManager
, и через него загружается из БД предыдущее состояние операции с тем же идентификатором. Затем новая транзакция завершается, никак не влияя на текущую, в которой работает наш листенер.Компоненты среднего слоя
Основную работу по загрузке данных на клиентский уровень и сохранению внесенных пользователем изменений в БД выполняет стандартный DataService, реализованный в платформе. Через него работают источники данных визуальных компонентов. В нашем приложении этого недостаточно, поэтому созданы несколько специфических сервисов.
Во-первых, это
UserDataService
, который позволяет работать с key-value хранилищем UserData
, предоставляя типизированный интерфейс для чтения и записи идентификаторов сущностей. Интерфейс сервиса находится в модуле global
, потому что он должен быть доступен клиентскому уровню. Реализация сервиса находится в модуле core в классе UserDataServiceBean
. Она делегирует вызовы бину UserDataWorker
, в котором и сосредоточен код, выполняющий полезную работу. Сделано так потому, что эта функциональность требуется также и в OperationEntityListener
, то есть “изнутри” среднего слоя. Сервис же образует “границу middleware” и предназначен только для вызова из клиентских блоков. Вызывать его изнутри компонентов среднего слоя не следует, так как это приводит к повторному срабатыванию интерцептора, проверяющего аутентификацию и обрабатывающего специальным образом исключения. Да и просто в целях наведения порядка стоит отделять сервисы, вызываемые снаружи middleware, от остальных бинов, вызываемых изнутри. Хотя бы потому, что при вызове снаружи транзакция всегда отсутствует, а при вызове из кода middleware транзакция уже может быть открыта.Следующий сервис —
BalanceService
. Он позволяет получать значение остатка на счете на произвольную дату. Так как данная функциональность требуется и клиентам в UI, и на среднем слое (генератору тестовых данных), то она также вынесена в отдельный бин BalanceWorker
.И последний сервис —
ReportService
. Он извлекает данные для отчета по категориям, и возвращает их в виде списка экземпляров неперсистентной сущности CategoryAmount
.На среднем слое реализован также бин
SampleDataGenerator
, который предназначен для генерации тестовых данных. Для функциональности такого рода обычно не требуется сложный UI — достаточно обеспечить вызов с передачей простых параметров, иногда нужно отобразить какое-то состояние в виде набора атрибутов. Кроме того, работает с этим только администратор, а не пользователи системы. В таком случае удобно дать бину JMX-интерфейс и вызывать его методы из встроенной в веб-клиент JMX-консоли, либо подключившись любым внешним инструментом JMX. В нашем случае у бина есть интерфейс SampleDataGeneratorMBean
, и он зарегистрирован в spring.xml
модуля core.Обратите внимание, что метод
generateSampleData()
бина аннотирован как @Authenticated
. Это означает, что при вызове данного метода будет выполнен специальный системный логин и в потоке выполнения будет присутствовать пользовательская сессия. Она требуется в данном случае потому, что метод создает и изменяет через EntityManager
сущности, которые при сохранении требуют установки их атрибутов createdBy
, updatedBy
— кто изменял данные экземпляры. С другой стороны, метод removeAllData()
, также вызываемый через JMX-интерфейс, не требует аутентификации потому, что он удаляет данные с помощью SQL-запросов через QueryRunner
и нигде не обращается к пользовательской сессии.Вообще, обязательная проверка наличия пользовательской сессии производится только на входе в средний слой со стороны клиентского уровня — в интерцепторе сервисов. Проверять или не проверять наличие сессии и права пользователя на уровне middleware — решает разработчик приложения, но в некоторых случаях наличие сессии обязательно из-за необходимости проставлять имя пользователя в атрибутах аудита сущностей. Кроме того, права пользователей всегда проверяются в
DataWorker
— бине, которому DataService
делегирует выполнение CRUD-операций с сущностями.Главное окно приложения
Стандартной возможностью веб-клиента CUBA является скрываемая панель в левой части окна приложения, в которой обычно отображаются так называемые “папки приложения” и “папки поиска”. Эти папки используются для быстрого доступа к информации — щелчок по папке открывает определенный экран со списком сущностей и наложенным фильтром.
Мне показалось логичным в левой части главного окна отображать информацию о текущем балансе. Поэтому я встроил панель баланса в верхнюю часть панели папок.
Сделано это следующим образом:
- От платформенного
FoldersPane
унаследован классLeftPanel
, переопределены методыinit()
иrefreshFolders()
, в которых вызывается методcreateBalancePanel()
. В нем создается новый контейнер, заполняется данными, полученными изBalanceService
, и помещается вверху родительского контейнера. - Чтобы
LeftPanel
использовался вместо стандартногоFoldersPane
, от платформенногоAppWindow
унаследован классAkkAppWindow
и переопределен методcreateFoldersPane()
. - Чтобы в свою очередь
AkkAppWindow
использовался вместо стандартногоAppWindow
, переопределен методcreateAppWindow()
классаApp
. Кроме того, здесь определен метод доступа к новой панелиgetLeftPanel()
— он вызывается из экранов для обновления баланса после коммита или удаления операций.
Браузер операций
Описатель экрана расположен в файле
operation-browse.xml
. Здесь все стандартно, за исключением использования классов-форматтеров для представления даты и сумм в таблице операций. Для отображения даты применяется платформенный
DateFormatter
, которому передается формат по ключу из пакета локализованных сообщений. Таким образом строка формата может быть разной для разных языков — для русского дата разделена точками, а для английского — символами /.Для того, чтобы суммы отображались без дробной части, а 0 не отображался совсем, в проекте создан класс
DecimalFormatter
— он и используется в колонках сумм. Редактор операции
Здесь интереснее: операция может быть одного из трех типов (приход, расход, перевод), и экран редактирования должен выглядеть для них по-разному.
Первые два экрана на первый взгляд кажутся одинаковыми, но на самом деле это не так: визуальные компоненты работают с разными атрибутами сущности
Operation
— расход с acc1
и amount1
, доход с acc2
и amount2
. Эту изменчивость можно было бы реализовать полностью в коде контроллера, но я решил сделать это более декларативно — разнеся отличающиеся части экрана в отдельные фреймы.Фреймов три — по количеству типов операции. Все они располагаются в том же пакете, что и сам экран редактирования операции. Чаще всего фреймы подключаются статически — используя компонент
iframe
в XML-дескрипторе экрана. Нам это не подходит, так как нужно выбирать нужный фрейм в зависимости от типа операции. Поэтому в XML-дескрипторе экрана operation-edit.xml
определен только контейнер для фрейма — компонент groupBox
с идентификатором frameContainer
, а собственно создание и вставка фрейма в экран выполняется в контроллере OperationEdit
: @Inject
private GroupBoxLayout frameContainer;
private OperationFrame operationFrame;
@Override
public void init(Map<String, Object> params) {
...
String frameId = operation.getOpType().name().toLowerCase() + "-frame";
operationFrame = openFrame(frameContainer, frameId, params);
Здесь
OperationFrame
— интерфейс, который реализуют контроллеры фреймов типов операции. Через него удобно единообразно управлять всеми тремя фреймами — инициализировать и валидировать их.В методе
init()
контроллера OperationEdit
есть еще один интересный момент — регистрируется листенер, срабатывающий после коммита операции: @Override
public void init(Map<String, Object> params) {
...
getDsContext().addListener(new DsContext.CommitListenerAdapter() {
@Override
public void afterCommit(CommitContext context, Set<Entity> result) {
LeftPanel leftPanel = App.getLeftPanel();
if (leftPanel != null)
leftPanel.refreshBalance();
}
});
}
Этот листенер обновляет содержимое левой панели, отображающей текущий баланс.
У фреймов типов операции есть следующая общая особенность — текстовые поля, работающие с суммами, не присоединены к источнику данных. Сделано это для того, чтобы в поле можно было вводить арифметическое выражение, а система рассчитывала бы сумму.
Рассмотрим
expense-frame.xml
. В нем объявлен компонент textField
с идентификатором amountField
. В контроллере ExpenseFrame
используется бин AmountCalculator
, в котором инкапсулирована логика расчета суммы: @Inject
private TextField amountField;
@Inject
private AmountCalculator amountCalculator;
@Override
public void postInit(Operation item) {
amountCalculator.initAmount(amountField, item.getAmount1());
…
}
@Override
public void postValidate(ValidationErrors errors) {
BigDecimal value = amountCalculator.calculateAmount(amountField, errors);
…
}
Этот же бин, определенный на слое Web Client, используется и в двух других контроллерах фреймов. Метод
initAmount()
бина устанавливает в текстовом поле текущую сумму, отформатированную по типу данных BigDecimal
. Просто указать datatype = decimal
для компонента нельзя, так как в этом случае в него можно будет ввести только число, а нам нужно иметь возможность вводить и арифметические выражения. Метод calculateAmount()
проверяет выражение на корректность с помощью regexp, а затем выполняет его как выражение на Groovy через интерфейс Scripting
. Результатом будет число, которое и возвращается контроллеру экрана для простановки в операцию.Отчет по категориям
Этот интерактивный отчет реализуется экраном
categories-report.xml
. Интересен он в первую очередь тем, что содержит два кастомных источника данных типа CategoryAmountDatasource
. Класс источника данных указан в атрибуте datasourceClass
элемента collectionDatasource
. Для этих источников данных указан и JPQL-оператор, однако он не используется и присутствует только потому, что Студия автоматически генерирует текст запроса, если его не указать. На самом деле источник данных CategoryAmountDatasource
переопределяет метод loadData()
и вместо загрузки данных через DataService
по JPQL-запросу, обращается к сервису ReportService
, передавая ему нужные параметры:public class CategoryAmountDatasource extends CollectionDatasourceImpl<CategoryAmount, UUID> {
private ReportService service = AppBeans.get(ReportService.NAME);
@Override
protected void loadData(Map<String, Object> params) {
...
Date fromDate = (Date) params.get("from");
Date toDate = (Date) params.get("to");
...
List<CategoryAmount> list = service.getTurnoverByCategories(fromDate, toDate, categoryType, currency.getCode(), ids);
for (CategoryAmount categoryAmount : list) {
data.put(categoryAmount.getId(), categoryAmount);
}
...
}
Параметры устанавливаются контроллером экрана в методе
refresh()
источника данных — см. методы refreshDs1()
, refreshDs2()
класса CategoriesReport
. Сервис возвращает список экземпляров неперсистентной сущности CategoryAmount
, и источник данных сохраняет их в своей коллекции data. Таким образом таблицы, связанные с этими источниками данных, отображают экземпляры CategoryAmount
как любые другие сущности, загруженные из БД обычным способом.Интересно устроена функциональность кнопки Исключить, позволяющая убрать из рассмотрения выбранную категорию.
В дескрипторе
categories-report.xml
объявлены две такие кнопки — для левой и правой таблицы. Каждая из кнопок связана с действием excludeCategory
своей таблицы. Однако для таблиц в XML-дескрипторе не объявлено никаких действий. Как же это работает? Дело в том, что действия для таблиц в данном случае добавляются в методе init()
контроллера экрана: см. метод initExcludedCategories()
. В этом методе также “вспоминается” список ранее исключенных категорий, запомненных с помощью сервиса UserDataService
.Действие типа
ExcludeCategoryAction
при срабатывании вызывает метод excludeCategory()
, который через ComponentsFactory
создает контейнер и надпись с кнопкой-ссылкой, соответствующие исключаемой категории, и помещает новый контейнер внутрь объявленного заранее в дескрипторе контейнера excludedBox
. Для каждой кнопки создается листенер, при срабатывании которого весь контейнер, в котором находится кнопка вместе с надписью, удаляется из родительского контейнера. Кроме того, обновляются источники данных, переформировывая списки категорий.Вообще, экран отчета по категориям является довольно нестандартным вариантом использования платформы, поэтому в нем много вручную написанной логики, которая обычно спрятана внутри стандартных вариантов взаимодействия компонентов.
Благодарности
Некоторые идеи я почерпнул из замечательного сервиса zenmoney.ru, которым пользовался некоторое время. Все open-source библиотеки и фреймворки, входящие в состав платформы, перечислены в окне Help -> About -> Credits.
Продолжение следует
В следующей статье об этом же приложении я планирую рассказать об устройстве блока responsive UI, который написан на Backbone.js + Bootstrap и взимодействует со средним слоем через REST API. Кроме того, постараюсь немного изменить тему основного UI и дополнить его новым UI-компонентом, чтобы проиллюстрировать возможности кастомизации интерфейса в проектах.