Оглавление серии

  1. Откуда взялся Jasper и почему он так устроен

  2. Один стандарт и три идеи

  3. Подключение и что делает процессор при сборке

  4. Коллекции, рантайм и границы подхода — эта статья

Это последняя часть серии. В третьей мы подключили библиотеку, написали первый модульный отчёт и посмотрели, что процессор дописывает в шаблон при сборке. Здесь — всё остальное: поля-коллекции и повторяющиеся субрепорты, что роняет сборку, как собирается карта параметров в рантайме, что происходит на старте приложения, как библиотека уживается с обеими версиями движка, пример целиком, переход с 2.0.x и то, за что за всё это приходится платить.

Коллекции

Поле-коллекция бинов — private List<LineItem> items; — превращается в три вещи:

  • параметр items класса JRBeanCollectionDataSource;

  • датасет items с полями по геттерам LineItem: имя — по правилам JavaBeans, класс — по возвращаемому типу геттера;

  • компонент, который прогоняет датасет: по умолчанию таблица (table) с заголовками колонок по именам свойств, список (list) — через @JasperCollection(type = CollectionComponentType.LIST). Ширину колонки задаёт columnWidth, по умолчанию 100.

Геттеры у элемента обязательны: JRBeanCollectionDataSource читает значения через них. Поэтому record в роли элемента коллекции отклоняется при сборке, а для элемента-интерфейса поля датасета берутся по его геттерам.

Коллекция простых значений — строк, чисел, дат, enum — устроена иначе: у элемента нет свойств, которые стоило бы читать. Для private List<String> highlights; процессор генерирует датасет с единственным полем:

<subDataset name="highlights">
    <field name="_THIS" class="java.lang.String"/>
</subDataset>

_THIS — зарезервированное имя JasperReports (JRAbstractBeanDataSource.CURRENT_BEAN_MAPPING): в такое поле кладётся сам элемент коллекции, и в ячейке пишется $F{_THIS}. Заголовок колонки берётся из имени параметра.

Пустая или null-коллекция в карту не попадает, а сгенерированные компоненты помечены removeLineWhenBlank — пустой блок схлопывается. @JasperIgnore на поле исключает его из генерации и заполнения; на поле класса-элемента — из сгенерированного датасета.

Повторяющиеся субрепорты

Если элемент коллекции сам является модулем — private List<DepartmentModule> departments; с @JasperSubreport на DepartmentModule, — это уже не таблица, а повторяющийся субрепорт: по экземпляру на каждый элемент. Процессор генерирует параметр departmentsDataSource, датасет departmentsDataset с двумя полями — params и report — и компонент list, который для каждой строки рисует субрепорт из $F{report} с параметрами из $F{params}.

В рантайме каждый элемент приносит свой скомпилированный шаблон, поэтому в одном списке могут жить наследники модуля с разными шаблонами. Элементы, у которых isEmpty() вернул true, и null пропускаются. @JasperCollection здесь не действует.

Что проверяется при сборке

В третьей версии процессор не только дописывает шаблон, но и сверяет его с классом. Сборка падает, если:

  • шаблон объявляет параметр или датасет, который порождается полями, но ни одно поле класса его больше не порождает — поле переименовали или удалили;

  • класс параметра в шаблоне не совпадает с типом поля;

  • два поля дают одно имя параметра;

  • класс с @JasperModularReport не наследует ModularReport, класс с @JasperSubreport не наследует SubreportModule или на классе стоят обе аннотации;

  • два класса указывают на один templatePath;

  • элемент коллекции — record или коллекция модулей объявлена с типом без @JasperSubreport.

Сообщение говорит, что делать. Например, после переименования поля items в positions:

Template declares 'itemsReport' but no field of InvoiceReport produces it. If the field was renamed, rename the parameter and its $P{itemsReport} references in the template; if it was removed, delete the parameter and the element that uses it.

Предупреждением, без остановки сборки, процессор отмечает коллекции, для которых нечего генерировать: сырой List, List<?> или тип элемента без читаемых свойств.

Граница проверки — контракт, который порождает процессор: коллекции JRBeanCollectionDataSource и параметры вида …Report, …MapParameter, …DataSource с соответствующими классами. Остальные параметры и выражения вроде $P{companyDetails}.getName() остаются вашими — их проверит только компиляция шаблона в самом JasperReports.

Рантайм

render() делает три шага: берёт скомпилированный шаблон (компиляция — один раз на путь, дальше из кэша), собирает карту параметров из полей и заполняет отчёт от JREmptyDataSource. Данные всегда едут параметрами, поэтому соединение с базой шаблону не передаётся вовсе.

Карта собирается обходом полей вверх по иерархии класса:

  • null-поля и поля с @JasperIgnore пропускаются;

  • скаляры кладутся как есть;

  • коллекции бинов — свежим JRBeanCollectionDataSource на каждый рендер. Классический баг «передал датасорс в субрепорт — получил пустой блок» здесь нельзя написать: одноразовому курсору взяться неоткуда;

  • модуль — своим шаблоном и своей изолированной картой. Два модуля могут оба завести поле title и не столкнутся;

  • цикл модулей (A → B → A) даёт исключение с цепочкой имён вместо StackOverflowError.

Карту можно проверить обычным юнит-тестом, не генерируя PDF: report.fillMapParameters() публичен. Скаляры и коллекции проверяются вообще без шаблонов; для полей-субрепортов шаблоны должны лежать на тестовом classpath — модуль компилирует свой шаблон, когда попадает в карту.

Прекомпиляция на старте

Стартер регистрирует JasperReportPrecompiler: на старте приложения он находит в jasper.modular.base-package классы с аннотациями — включая наследников, унаследовавших аннотацию, — и компилирует каждый шаблон по одному разу в общий кэш. Если хоть один шаблон не компилируется, исключение пробрасывается и приложение не стартует. Обычный контекстный тест @SpringBootTest в CI краснеет на битом шаблоне — задолго до того, как отчёт кто-то запросит. Самая живая джасперовская боль последних лет — «работает в Studio, умирает на сервере» — ловится здесь, при деплое, а не от бухгалтерии в конце месяца.

Кэш очищается при каждом старте, так что перезапуск через devtools подхватывает изменённые шаблоны. Отключается прекомпиляция через jasper.modular.precompile-enabled: false; без base-package она пропускается с предупреждением.

JasperReports 6 и 7

Сама библиотека от версии движка не зависит: в ядре и процессоре JasperReports объявлен в provided-скоупе, версию приносите вы (в 3.0.0 стартер дополнительно тянет 7.0.6 транзитивно — поэтому движок и объявляют явно). Скомпилированные .jasper на диск не пишутся, так что намеренный слом их совместимости в седьмой версии нас не касается. Всё проверено на 6.21.5 и 7.0.6.

А вот шаблоны у каждой версии свои. Классический JRXML седьмой движок не читает, новый формат не читает шестой, и одного файла на обе версии не бывает. Процессор пишет шаблоны в диалекте той версии JasperReports, что лежит у него на пути. Пример к библиотеке поэтому состоит из двух модулей — sample-jr6 и sample-jr7: Java-код в них одинаковый, шаблоны разные.

Пример целиком

jasper-modular-sample — финансовый отчёт компании, собранный из модулей:

CompanyReport (@JasperModularReport)
├── title: TitleSubModule
│   └── companyDetails, period, currency, итоги, List<String> highlights
├── financial: FinancialSubModule
│   ├── revenue: RevenueSubModule — List<RevenueItem>
│   ├── expense: ExpenseSubModule — List<ExpenseItem>
│   └── profit: ProfitSubModule — List<ProfitBreakdown>
└── departments: List<DepartmentSubModule> — повторяющийся субрепорт
    └── name, headcount, budget, List<EmployeeItem>

Корневой шаблон почти пуст: заголовок, колонтитул и по банде на каждое поле. Отделы начинаются с новой страницы — разрыв перед списком добавлен руками, как и положено дизайну.

Переход с 2.0.x

Третья версия ломает совместимость в пяти местах:

  • имена параметров субрепортов теперь от поля: вместо <prefix>Report и <prefix>MapParameter, где префиксом был атрибут prefix или имя класса модуля, — <field>Report и <field>MapParameter. Старые параметры и их $P{...} в шаблонах нужно переименовать — сборка сама перечислит, какие;

  • атрибут prefix из @JasperSubreport удалён;

  • коллекция бинов без @JasperCollection теперь генерируется таблицей, а не списком; уже созданные компоненты не меняются;

  • SubreportModule.getOrder() и isStartNewPage() удалены — на рендер они никогда не влияли;

  • шаблон и базовые классы проверяются при сборке.

Подробности — в разделе «Migrating from 2.0.x» в README.

Шероховатости и границы

  • Перенос сгенерированного шаблона из target/generated-sources в ресурсы — ручной шаг и самое неудобное место цикла. Процессор переписывает файл целиком в своём форматировании, а новые элементы кладёт отдельными бандами в конец detail с раскладкой по умолчанию: банда коллекции высотой 90, строка таблицы 30, колонка 100 пикселей, заголовки — имена полей. Дизайн — дальше в Studio.

  • Компоненты коллекций и субрепорты процессор ищет в detail. Если перенести сгенерированный элемент в другую банду, при следующей сборке он появится в detail снова — это следствие модели, где структуру задают модули, а не баг.

  • Описан и проверен Maven. В Gradle ресурсы и скомпилированные классы лежат в разных каталогах, процессор не находит существующий шаблон и выдаёт заготовку с предупреждением.

  • Коллекция внутри элемента коллекции генерируется только полем датасета; вложенный list с new JRBeanCollectionDataSource($F{...}) пишете сами.

  • Объектная модель материализует данные в память — для стотысячестрочных выгрузок честнее курсор с виртуализатором.

  • Вёрстку библиотека не рисует и экспорт не делает: дизайн — в Jaspersoft Studio, экспорт — стандартными средствами JasperReports.

Цена проверки при компиляции

Есть ещё один размен, и это не баг, который когда-нибудь починят, а само устройство подхода. Шаблоны лежат в ресурсах приложения, а структура отчёта — в Java-классах. Значит, любое изменение проходит через сборку и деплой: и новое поле, и правка, которая касается одной только вёрстки.

Во многих компаниях отчёты меняют каждую неделю и намеренно мимо релизного цикла: аналитик правит шаблон на сервере отчётов, и это работает в тот же день. С модульным подходом так не выйдет: каждое изменение идёт обычным путём релиза, зато со всеми проверками из предыдущих частей. Если отчёты у вас меняются чаще, чем выкатывается приложение, подход вас затормозит, и лучше знать об этом заранее.

Библиотека открытая, Apache 2.0: github.com/hhdevr/jasper-modular-library, на Maven Central — io.github.hhdevr:jasper-modular-starter, рядом есть репозиторий с примером. Вопросы и критика — в issues.