Домашняя бухгалтерия на платформе CUBA
Цель этой статьи — рассказать о возможностях платформы 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, который скачает интернет платформу и другие библиотеки, а затем соберет приложение в подкаталоге build/tomcat. В процессе сборки вам будет предложено принять лицензионное соглашение на платформу CUBA.После этого нужно запустить сервер HSQL и создать БД в подкаталоге data проекта: gradlew startDbgradlew createDb
Для запуска Томката можно воспользоваться командой Gradlegradlew startлибо скриптами startup.* в подкаталоге build/tomcat/bin.Основной веб-интерфейс приложения доступен на http://localhost:8080/app, responsive UI — на http://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
@Override
public void init (Map
Рассмотрим 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
private ReportService service = AppBeans.get (ReportService.NAME);
@Override
protected void loadData (Map
В дескрипторе 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-компонентом, чтобы проиллюстрировать возможности кастомизации интерфейса в проектах.