Оглавление серии
Один стандарт и три идеи — эта статья
Коллекции, проверки и рантайм — скоро
Прекомпиляция, версии движка и ограничения — скоро
Это вторая часть. В первой — откуда взялся JasperReports, почему роли MVC в нём есть, а границ между слоями нет, и что происходит с движком сейчас. Здесь — чего ждёт от инструмента современный разработчик и на каких идеях держится библиотека jasper-modular 3.0.0.
Чего ждёт современный разработчик
А ждёт он двух вещей, к которым привык за последние лет десять. Первая — слои разделены: данные отдельно, представление отдельно, MVC в той или иной форме. Вторая — интерфейс собирается как дерево из переиспользуемых блоков: DOM-дерево в HTML, компоненты во фронтенде, их аналоги в мобильной разработке. Блок написали один раз — и вставляете его куда нужно.
В Jasper нет ни того, ни другого в удобном виде, и ждать этого от его команды не стоит: они делают движок для документов, а не слой для приложений.
Вот этот разрыв — между тем, к чему привык разработчик, и тем, что даёт движок, — библиотека и закрывает. Сначала встроенными ограничениями, которые сокращают зоопарк вариантов до одного, а поверх них — тремя идеями.
Один стандарт и три идеи
Отсюда и замысел. Что остаётся как было:
движок JasperReports целиком;
шаблоны JRXML и Jaspersoft Studio для дизайна.
Что добавляется — то, чего в ядре нет:
один стандарт вместо зоопарка способов;
дерево вместо сетки: отчёт собирается из вложенных модулей;
слои: данные, маппинг и расчёты — в Java, вёрстка — в шаблоне;
объект как носитель: структура, данные и имена отчёта живут в Java-классах, а структура отчёта полностью повторяет дерево объектов.
На этом стандарте держатся три идеи из списка — дерево, слои и объект, — и по отдельности ни одна из них не новость. Смысл в том, что работают они только вместе: строгий контракт без композиции даёт тот же монолитный шаблон, только проверяемый, а композиция без контракта — это нынешний ад субрепортов. И все три даёт один ход: субрепорт становится полем Java-класса.
Один стандарт вместо зоопарка
Первое, что делает библиотека, — сознательно игнорирует все остальные способы, которыми Jasper позволяет доставать и передавать данные: весь тот зоопарк, что перечислен выше. Остаётся только то, что укладывается в модульный MVC-подход: данные — объектами, вайринг — генерируется. Вёрстки это не касается: всё, что Jasper умеет в оформлении, остаётся вашим. Старыми путями вы по-прежнему можете делать что угодно мимо библиотеки, но помогать вам в этом она намеренно не будет. Взамен она даёт единый стандарт — и обходить его в новых отчётах уже нет смысла.
Дерево вместо сетки
В классическом Jasper структуру отчёта задаёт движок. Вспомните туториал 2002 года: вся структура строится на секциях — title, pageHeader, columnHeader, groupHeader, detail, groupFooter, columnFooter, pageFooter, summary. То есть «структура» — это набор готовых ячеек. Они фиксированы, друг в друга не вкладываются, их состав определяет движок, а разработчик раскладывает содержимое по сетке, которую ему выдали.
Мой подход меняет владельца структуры. Библиотека обходит эту заранее созданную структуру и упрощает её, чтобы сделать аналог DOM-дерева гибким в построении и переиспользовании. Корневой отчёт — пустой контейнер: де-факто библиотека строит отчёт в одном блоке <detail>, в котором, как вложенные компоненты, в одинаковых бэндах живут все субрепорты, сколько бы их ни было. Для Jasper структура отчёта теперь — это, по сути, один тип тегов вместо девяти, и в рамках модульного подхода этого хватает. Титульный блок — это модуль титула, итоги — модуль итогов, так что для данных бэнды <title> и <summary> корневому шаблону не нужны. Модуль может содержать модули, те — свои, на любую глубину. Блок, написанный один раз, вставляется в любой отчёт одним полем. Тот же переход, что когда-то случился в вёрстке от таблиц к компонентам: не раскладывать всё по ячейкам, которые тебе дали, а описать свои компоненты и вложить их как хочешь.
Вот как выглядит дерево отчёта из репозитория с примером:
CompanyReport — корневой отчёт ├── title: TitleSubModule — титул: реквизиты, период, валюта, итоги ├── financial: FinancialSubModule — финансовый блок │ ├── revenue: RevenueSubModule — выручка и таблица статей │ ├── expense: ExpenseSubModule — расходы и таблица статей │ └── profit: ProfitSubModule — прибыль, маржа и таблица разбивки └── departments: List<DepartmentSubModule> — по блоку на каждый отдел └── название, численность, бюджет и таблица сотрудников
Каждый модуль — отдельный класс со своим шаблоном.
Бэнды при этом никуда не деваются — они уходят внутрь. В каждом конечном блоке по-прежнему живут его detail, групповые итоги и правила переноса, а у корневого отчёта — постраничная обвязка: номер страницы, колонтитулы. Раньше набор секций был структурой всего документа, теперь это внутреннее устройство отдельного блока.
Полноценным DOM это, конечно, не является: живого дерева, событий, повторного рендеринга здесь нет. Это дерево сборки документа, и для отчётов его хватает.
Слои: данные отдельно, вёрстка отдельно
Вторая идея — границы, которых Jasper не хватало с рождения. Тот самый единый стандарт их и проводит: раз данные приходят только готовыми объектами, шаблону негде сходить в базу. Никакого SQL в шаблоне, никаких JSON-путей: вы достаёте данные как привыкли — из репозиториев через JPA или JDBC, из внешнего API, а в отдельном микросервисе отчётов и прямо из запроса в контроллер. Дальше — как в остальном приложении: считаете на Java, мапите в типизированное дерево объектов и отдаёте его отчёту.
Заодно шаблон возвращается к своей законной роли — библиотека сняла с него чужую работу: добычу данных, знание о структуре модели, ручной вайринг. Осталось то, что и должно быть у представления: вёрстка, типографика, правила переноса.
Это, пожалуй, самое спорное место подхода, поэтому скажу о нём отдельно. Самодостаточный отчёт, который сам ходит в базу, — законная модель для систем бизнес-аналитики (BI) и отчётных серверов: там отчёт и есть приложение, а права, подключения и расписание берёт на себя сам сервер. Но внутри вашего приложения такой шаблон становится теневым слоем доступа к данным: мимо ваших транзакций, тестов и мониторинга, а отчасти и мимо прав — кто может запустить отчёт, приложение проверит, но что именно выберет запрос в шаблоне, уже нет. Можно было бы, конечно, сделать источник данных настраиваемым, — но данные всё равно пойдут мимо Java-репозитория.
Что спрашивали в прошлый раз
Сразу про версии и про то, чем серия отличается от той статьи. Она описывала версию 1.0.0 и была практическим обзором: подключение, код, генерация шаблона, экспорт, контроллер. С тех пор вышли две мажорные версии, так что часть примеров из неё уже не соберётся. Серия — про 3.0.0, и разбирает она другое: откуда взялись сами проблемы, на каких идеях построено решение и что именно происходит при сборке и в рантайме, по одной теме на часть. Возражение из комментариев при этом к версии не привязано, поэтому отвечаю на него здесь.
В комментариях к апрельской статье о библиотеке мне возразили ровно здесь: прелесть JasperReports как раз в полной инкапсуляции — всё, включая загрузку данных, можно положить в один отчёт, а если тянуть данные снаружи, понадобится ещё и обёртка, которая их достаёт.
Возражение справедливое, но сначала уточню свой же довод из той ветки: я сформулировал его слишком широко. Я написал тогда, что запрос внутри шаблона статичен и каждый новый фильтр требует правки JRXML. Это верно ровно до тех пор, пока сам запрос написан в шаблоне — тогда новый разрез данных действительно означает правку <queryString> или отдельную версию файла. Как только SQL собирают в Java и передают параметром через $P!{}, довод перестаёт работать: шаблона новый фильтр не касается. Сортировку вдобавок можно задать в рантайме встроенным REPORT_SORT_FIELDS, а filterExpression читает обычные $P{}. Но чтобы до этого дойти, формирование запроса уже пришлось вынести в Java — то есть сделать первый шаг ровно в ту сторону, о которой я и говорю.
Возражение в другом: зачем вообще отдавать выборку в шаблон. Чтобы он исполнил запрос, ему нужно живое соединение с базой — то самое REPORT_CONNECTION, которое приложение передаёт в fillReport. То есть доступы шаблон получает из вашего же приложения: коннект из вашего пула, права вашего пользователя базы. Больше, чем может приложение, шаблон не сделает — но и меньше тоже. Правила, которые обычно сужают выдачу, живут в репозитории и сервисах, а запрос идёт мимо них: если в базе лежат данные нескольких клиентов и приложение везде добавляет условие на организацию, в шаблоне это условие придётся не забыть написать руками. Транзакционную границу отчёт тоже не выбирает — в каком соединении его позвали, в том контексте он и читает, и занято оно всё время рендера. А менять этот SQL будет тот, кто правит вёрстку в Studio: правка в файле разметки меняет то, какие данные и в каком объёме уходят из базы.
И следствие, которое видно не сразу: шаблону, который сам ходит в базу, нужен доступ к базе. Значит рендер обязан жить там, откуда база видна, и вынести отчёты в отдельный сервис, в другую сеть или за периметр не выйдет, не протащив туда же соединение и пароль. Когда данные приезжают объектами, у рендера нет ни того, ни другого: он получает готовые объекты, раскладывает их по аннотированным классам и рисует. По сути это сервис без состояния — на входе объекты, на выходе PDF. Его можно держать отдельно от приложения и запускать в нескольких экземплярах: делить между ними нечего, кроме кэша скомпилированных шаблонов, который каждый экземпляр строит себе сам.
Что остаётся помимо этого:
$P!{}подставляет строку как есть: инъекция возможна, проверки типов нет никакой;поля
<field>и их классы живут в JRXML. Studio умеет вычитать их из запроса, но это ручной заход в Studio, а не проверка при сборке. Да, миграция схемы — проблема поддержки; вопрос лишь в том, поймает рассогласование полей и типов сборка или тот, кто запустил отчёт;данные такого запроса некому переиспользовать. Метод сервиса, возвращающий объекты, кормит и отчёт, и API, и выгрузку в Excel, и тест, а
queryStringвнутри JRXML кормит только свой шаблон.
Если запрос уже строится в Java, непонятно, зачем останавливаться на полпути. Выполните его там же и передайте в отчёт готовые объекты: тогда шаблон не касается базы вообще, движку не нужны ни соединение, ни доступы, а рендер остаётся одним шагом, который легко замерить.
Про обёртку, которая достаёт данные. В приложении на Spring она уже написана: репозиторий и сервис есть, отчёт становится ещё одним их потребителем, а его данные проверяются юнит-тестом без базы и без шаблона. Если же приложения нет и отчёт запускает аналитик на сервере отчётов, остаётся случай из абзаца выше — там инкапсуляция и выигрывает.
Вёрстку удобно двигать мышкой в Jaspersoft Studio, а логику — тестировать и рефакторить в коде. Вот пусть каждая часть и живёт там, где с ней удобно работать.
Объект как носитель: структура, данные и имена в одном месте
Третья идея — та, на которой держатся две первые. В классическом Jasper один и тот же смысл живёт в нескольких местах сразу: структура — в XML-шаблонах, данные — в Java-коде, а имена продублированы строками и там, и там. В библиотеке всё это переезжает в единую структуру Java-классов:
@JasperModularReport(templatePath = "/reports/invoice.jrxml") public class InvoiceReport extends ModularReport { private String invoiceNumber; private AddressModule billingAddress; private AddressModule shippingAddress; private List<ShipmentModule> shipments; // по субрепорту на каждую отгрузку }
Модули адреса и отгрузки, а также позиция счёта выглядят так:
@JasperSubreport(templatePath = "/reports/address.jrxml") public class AddressModule extends SubreportModule { private String city; private String street; private String zip; @Override public boolean isEmpty() { return city == null && street == null && zip == null; } } @JasperSubreport(templatePath = "/reports/shipment.jrxml") public class ShipmentModule extends SubreportModule { private String trackingNumber; private LocalDate shippedAt; private List<Item> items; // своя таблица внутри блока отгрузки @Override public boolean isEmpty() { return items == null || items.isEmpty(); } } public class Item { private String name; private int quantity; private BigDecimal price; }
У каждого модуля свой шаблон, базовый класс субрепорта и один обязательный метод isEmpty — пустой блок в отчёт просто не попадает. Item — обычный класс с полями, без всяких аннотаций: из списка таких объектов библиотека сама сделает в шаблоне простую таблицу Jasper (или список, если указать это в аннотации поля) и заполнит её данными. А если элементы списка — модули, как ShipmentModule, получится повторяющийся субрепорт: у каждой отгрузки свой блок со своим шаблоном, а внутри — своя таблица позиций.
InvoiceReport и есть отчёт. Его поля несут три вещи сразу. Структуру: поля-модули — это ветки дерева, их вложенность — вложенность блоков в документе. Данные: значения полей — это то, что попадёт в отчёт. И имена: имя поля становится именем параметра в шаблоне, а его тип — типом параметра. Из этого класса в шаблоне появятся параметр invoiceNumber, свои параметры для каждого из двух адресов и блоки отгрузок shipments, а таблица позиций — в шаблоне каждой отгрузки. Руками эти имена больше никто не печатает — ни в Java, ни в XML.
До одного следствия я дошёл не сразу. Один и тот же модуль адреса стоит в отчёте дважды — адресом плательщика и адресом доставки, — и это два разных места с разными данными. Поэтому в третьей версии имена параметров выводятся из имени поля — billingAddress, shippingAddress, — а не из класса AddressModule, как было раньше: место в дереве задаёт поле, тип лишь говорит, что это за блок.
Вот и связка всех трёх идей: дерево — это поля класса, контракт между данными и вёрсткой — это имена и типы полей, а данные — их значения. Экономия строк тут приятный бонус. Важнее, что структура отчёта теперь живёт в Java-модели: её видно в коде, она версионируется, рефакторится и переиспользуется обычными средствами языка. И шаблон идёт за ней следом: новое поле появится в нём само, а переименованный модуль или сменённый тип простого поля сборка не пропустит — об этом ниже.
Что спрятано под капотом
Всё, что выше, — это то, что пишет разработчик: классы, поля, по одному isEmpty на модуль и пара аннотаций, которые говорят, что вот этот класс — отчёт, вот этот — субрепорт, а шаблон лежит вот тут. Всё остальное — механическую работу, которую раньше делали руками, — библиотека забирает на себя.
На этапе компиляции аннотационный процессор читает эти классы и дописывает в шаблоны весь вайринг: параметры, датасеты, таблицы или списки для коллекций, бэнды с субрепортами. Дописывает только то, чего не хватает, — вёрстку, стили, руками расставленные элементы он не трогает никогда. И заодно сверяет шаблон с классом: если модуль или коллекцию переименовали или удалили, а в шаблоне остался их старый вайринг, или у простого поля сменился тип, сборка падает и говорит, что именно поправить.
На старте приложения шаблоны всех отчётов и модулей из указанного вами пакета компилируются заранее — это делает автоконфигурация Spring Boot, от вас нужна одна настройка. Битый шаблон роняет запуск, а значит, и обычный контекстный тест в CI: о поломке вы узнаёте при сборке и деплое, а не когда пользователь нажал «Сформировать».
В рантайме всё сводится к одному вызову — отрендерить объект. Под ним базовые классы, от которых наследуются ваши отчёты и модули, делают всю механику: компилируют и кэшируют шаблоны, обходят дерево объектов, собирают каждому блоку его собственную карту параметров, передают субрепорты вниз на любую глубину и заворачивают коллекции в источники данных. Корневое заполнение всегда идёт от пустого источника, соединения с базой шаблон не получает вовсе — данные приходят только из объектов, и всё.
В ванильном Jasper всё держится на договорённостях: имена совпадут, если их одинаково напечатать, а шаблон не полезет в базу, если его никто туда не направит. На своём пути библиотека на честное слово не полагается: опечатку в вайринге негде сделать, шаблон со старым вайрингом или не теми типами не соберётся, битый не задеплоится, а сама библиотека шаблон в базу не направит. Проверяется, правда, не всё, а только связка кода и шаблона: правильные ли данные вы положили в объект и как они свёрстаны — по-прежнему на вас. Зато главный класс ошибок — «добавил в данные новое поле и забыл подключить его в шаблоне», «переименовал модуль и забыл поправить XML», «добавил данные в корневой отчёт, но не передал в суб- или суб-суб-репорт» — больше не доезжает до пользователя.
Пойти в обход при этом никто не мешает: старые отчёты можно и дальше заполнять вручную — со своими параметрами, своим соединением и SQL в шаблоне. Библиотека такое простит, только проверять не станет. Поэтому переезжать на неё можно постепенно — по одному отчёту, пока остальные работают как раньше в том же приложении.
Руками остаётся одно: вернуть сгенерированный шаблон из target в ресурсы — это тот же файл, который потом уходит аналитику. Этот шаг сборка не проверяет: забудете — новое поле просто не появится в отчёте. Подробнее о нём — в частях про практику.
Кто что делает
И ещё одно следствие: работу разработчика и аналитика наконец можно разделить по-настоящему.
Разработчик описывает структуру и данные в Java, собирает проект — и получает шаблоны, в которых уже есть всё: параметры, коллекции, субрепорты, связанные между собой. Эти шаблоны можно отдать кому угодно — аналитику, дизайнеру, менеджеру, который заказывал отчёт, да хоть клиенту. Тот открывает их в Jaspersoft Studio и верстает что угодно: все переменные и все блоки уже внутри, руками больше ничего подключать не нужно. Ему не нужно знать, что происходит в коде, откуда берутся данные и как там ходит SQL. А разработчику не нужно долго разбираться, как в Jasper всё подцеплять вручную, обвязывать параметры и проверять их. Понадобились аналитику новые данные — разработчик добавляет поле в отчёт или модуль, и после следующей сборки оно само появится в шаблоне.
Конечно, всё это разработчик может делать и сам, и это тоже работает. Я шёл путём максимального упрощения при сохранении ценности: получить строгость контракта, не отказываясь ни от JRXML, ни от Jaspersoft Studio. Даже когда у вас один простой отчёт — построить его через библиотеку и сверстать визуал в Jaspersoft Studio — гораздо быстрее, чем делать это классическим путём. Как минимум избавляетесь от инжектинга полей и ручной связки с ними передаваемых данных: поля сгенерятся в шаблоне сами, а данные к ним привяжет библиотека.
Вместо вывода
Есть тут и своя ирония. Начал я с ИИ-агентов — ими и закончу. Подозреваю, что польза от этого текста достанется не только тем, кто его дочитал: через полгода индексация доедет, и на вопрос «как работать с JasperReports» ИИ-агент начнёт советовать кому-нибудь эту библиотеку. Если однажды такой совет получите вы — что это и зачем, рассказано здесь.
Дальше в серии — всё практическое: как подключить библиотеку, что именно процессор дописывает в шаблоны, как устроены коллекции и повторяющиеся субрепорты, как возвращать сгенерированный шаблон в ресурсы, полный список проверок при сборке и где у подхода шероховатости. Продолжение выложу в ближайшие дни.
Библиотека открытая, Apache 2.0: github.com/hhdevr/jasper-modular-library, на Maven Central — io.github.hhdevr:jasper-modular-starter, рядом есть репозиторий с примером. Работает она и с шестой, и с седьмой версией JasperReports — берёт ту, что подключена в вашем проекте, а процессор пишет шаблоны в её формате. Если попробуете на своей отчётности — буду рад обратной связи в issues, в том числе критической.

