Если вы когда-нибудь работали с JasperReports в Java-проекте, вы знаете это чувство: всё вроде работает, но каждое изменение в отчёте - это боль. Данные передаются через хрупкие механизмы, бизнес-логика утекает в XML, а субрепорты - единственный способ навести порядок - сами по себе настолько неудобны, что проще не трогать.
За последние пару лет я несколько раз столкнулся с репортингом на JasperReports. Каждый раз одни и те же проблемы: монолитные шаблоны, неочевидный поток данных, ручная синхронизация между Java и JRXML. В какой-то момент я решил это системно исправить - и написал библиотеку jasper-modular, которая решает две ключевые проблемы:
Снижает сложность работы с JasperReports за счёт модульности, а саму модульность делает простой, понятной и автоматической
Делает данные и весь процесс построения отчёта «видимым» - всё описано типизированными Java-объектами, никакой магии в XML
Библиотека - open source, работает со Spring Boot 3.x/4.x и поддерживает JasperReports 6.x и 7.x.
GitHub: jasper-modular-library Пример: jasper-modular-sample
Почему JasperReports - это больно
JasperReports - мощный инструмент. Но работать с ним тяжело, и чем больше отчёт - тем тяжелее. Вот типичная эволюция: сначала один JRXML-шаблон, всё просто. Потом добавляется вторая секция, третья, таблица, ещё одна таблица. Шаблон растёт, данных становится больше, и в какой-то момент вы понимаете, что у вас один гигантский XML-файл, в котором перемешаны дизайн, логика выборки данных и бизнес-правила.
Правильный ответ на это - модульность. Разбить отчёт на маленькие изолированные блоки, каждый со своими данными и дизайном. Блоки можно менять независимо, тестировать отдельно, переиспользовать в других отчётах.
JasperReports поддерживает это через субрепорты. Но использовать их в чистом виде - отдельный вид страдания.
Как обычно решают проблему данных
Прежде чем показать что делает библиотека, давайте посмотрим на типичные подходы к передаче данных в субрепорты. Каждый из них работает - но каждый тащит сложность в JRXML, подальше от Java, где её можно контролировать.
Единый JSON на всех
Все данные сериализуются в один большой JSON и передаются во все субрепорты через JsonDataSource. Каждый субрепорт вытаскивает нужное через JSON-путь прямо в JRXML:ё
<subreport>
<dataSourceExpression>
<![CDATA[((net.sf.jasperreports.engine.data.JsonDataSource)$P{REPORT_DATA_SOURCE})
.subDataSource("secondChapter.guidelinesModule")]]>
</dataSourceExpression>
<subreportExpression><![CDATA[$P{GuidelinesModule}]]></subreportExpression>
</subreport>
Логика выборки данных - в XML. Опечатка в JSON-пути - пустая секция без ошибки компиляции. А параметр REPORT_DATA_SOURCE нигде явно не объявлен - ни в субрепорте, ни в родительском отчёте. Это неявный built-in JasperReports, который пробрасывается автоматически. Когда субрепорты вложены друг в друга, REPORT_DATA_SOURCE уходит по цепочке всё глубже - и понять, откуда пришли данные, читая шаблон в изоляции, практически невозможно.
SQL прямо в шаблоне
Субрепорт получает REPORT_CONNECTION - то же JDBC-соединение, что и корневой отчёт - и выполняет свой SQL-запрос:
<subreportParameter name="departmentId">
<subreportParameterExpression><![CDATA[$F{id}]]></subreportParameterExpression>
</subreportParameter>
<connectionExpression><![CDATA[$P{REPORT_CONNECTION}]]></connectionExpression>
Бизнес-логика и SQL оседают в JRXML. Запрос невидим для компилятора и IDE - ни проверки типов, ни рефакторинга.
Проброс REPORT_DATA_SOURCE
Датасорс родительского отчёта передаётся в субрепорт напрямую. Проблема: JRDataSource - consumable объект. После того как его строки прочитаны, он исчерпан и не может быть использован повторно. Первый субрепорт отрисуется нормально, второй получит пустые данные. Баг, который можно искать часами.
Ручной дриллинг параметров
Каждое значение, нужное субрепорту, объявляется отдельным <parameter> в родительском JRXML и пробрасывается вручную:
<parameter name="customerName" class="java.lang.String"/>
<parameter name="invoiceNumber" class="java.lang.String"/>
<parameter name="total" class="java.math.BigDecimal"/>
<parameter name="itemsReport" class="net.sf.jasperreports.engine.JasperReport"/>
<subreport>
<subreportParameter name="customerName">
<subreportParameterExpression><![CDATA[$P{customerName}]]></subreportParameterExpression>
</subreportParameter>
<subreportParameter name="invoiceNumber">
<subreportParameterExpression><![CDATA[$P{invoiceNumber}]]></subreportParameterExpression>
</subreportParameter>
<subreportParameter name="total">
<subreportParameterExpression><![CDATA[$P{total}]]></subreportParameterExpression>
</subreportParameter>
<subreportExpression><![CDATA[$P{itemsReport}]]></subreportExpression>
</subreport>
В Java - то же самое руками:
Map<String, Object> params = new HashMap<>();
params.put("customerName", invoice.getCustomerName());
params.put("invoiceNumber", invoice.getInvoiceNumber());
params.put("total", invoice.getTotal());
params.put("itemsReport", JasperCompileManager.compileReport("items.jrxml"));
JasperFillManager.fillReport(rootReport, params, new JREmptyDataSource());
При глубокой вложенности субрепортов это десятки <subreportParameter> записей в шаблоне и десятки params.put() в Java, которые нужно поддерживать синхронно. Добавили поле - обновите три файла: Java-класс, JRXML родителя, JRXML субрепорта. Рассинхронизация и опечатки неизбежны.
Как это решает jasper-modular
Библиотека использует принципиально другой подход. Вместо поштучного дриллинга параметров каждый субрепорт всегда получает ровно два параметра:
<prefix>Report - скомпилированный объект JasperReport
<prefix>MapParameter - единая Map<String, Object> со всеми данными субрепорта
Внутри субрепорта мапа автоматически распаковывается в отдельные параметры через встроенный механизм JasperReports - REPORT_PARAMETERS_MAP. Это малоизвестная возможность: когда Map передаётся как источник параметров, каждый ключ становится доступен как $P{key} в шаблоне - без единого <subreportParameter>.
Оба параметра генерируются автоматически аннотационным процессором при компиляции. В рантайме рендерер обходит граф объектов через рефлексию, собирает мапу, компилирует и заполняет каждый субрепорт рекурсивно - всё в одном вызове render(). Разработчик не объявляет параметры, не прокидывает их, не думает о них.
Подключение
Библиотека поставляется отдельными стартерами для JasperReports 6.x и 7.x.
JasperReports 7.x:
<dependency>
<groupId>io.github.hhdevr</groupId>
<artifactId>jasper-modular-starter-jr7</artifactId>
<version>1.0.0</version>
</dependency>
JasperReports 6.x и старше:
<dependency>
<groupId>io.github.hhdevr</groupId>
<artifactId>jasper-modular-starter-jr6</artifactId>
<version>1.0.0</version>
</dependency>
Аннотационный процессор запускается при mvn compile и генерирует JRXML. Добавляем в compiler plugin:
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<configuration>
<annotationProcessorPaths>
<path>
<groupId>io.github.hhdevr</groupId>
<artifactId>jasper-modular-processor-jr7</artifactId>
<version>1.0.0</version>
</path>
</annotationProcessorPaths>
</configuration>
</plugin>
Для PDF-экспорта в 7-й версии нужен отдельный модуль (намеренно не включён в стартер):
<dependency>
<groupId>net.sf.jasperreports</groupId>
<artifactId>jasperreports-pdf</artifactId>
<version>7.0.6</version>
</dependency>
Как выглядит код
Корневой отчёт
@Getter
@Setter
@JasperModularReport(templatePath = "/reports/company_report.jrxml")
public class CompanyReport extends ModularReport {
private TitleSubModule titleSubModule;
private FinancialSubModule financialSubModule;
}
Два поля - два субрепорта. Объявлены как обычные Java-поля. Никакого XML.
Субрепорт
@Getter
@Setter
@JasperSubreport(templatePath = "/reports/sub_title_report.jrxml", prefix = "Title")
public class TitleSubModule extends SubreportModule {
private CompanyDetails companyDetails;
private String period;
private String currency;
@Override
public boolean isEmpty() {
return companyDetails == null;
}
}
Атрибут prefix определяет имена параметров в родительском JRXML: TitleReport и TitleMapParameter.
Вложенность любой глубины
Субрепорты могут содержать другие субрепорты:
@Getter
@Setter
@JasperSubreport(templatePath = "/reports/sub_financial_report.jrxml")
public class FinancialSubModule extends SubreportModule {
private RevenueSubModule revenueSubModule;
private ExpenseSubModule expenseSubModule;
private ProfitSubModule profitSubModule;
@Override
public boolean isEmpty() {
return revenueSubModule == null
&& expenseSubModule == null
&& profitSubModule == null;
}
}
В sample-проекте полное дерево выглядит так:
CompanyReport (@JasperModularReport)
+-- TitleSubModule (@JasperSubreport)
| +-- CompanyDetails, period, currency
+-- FinancialSubModule (@JasperSubreport)
+-- RevenueSubModule (@JasperSubreport)
| +-- totalRevenue, growthPercent, List<RevenueItem>
+-- ExpenseSubModule (@JasperSubreport)
| +-- totalExpenses, growthPercent, List<ExpenseItem>
+-- ProfitSubModule (@JasperSubreport)
+-- grossProfit, operatingProfit, netProfit, margin, List<ProfitBreakdown>
Каждый модуль - отдельный класс со своим JRXML-шаблоном. Корневой отчёт просто объявляет их полями - всё остальное делается автоматически.
Коллекции
Для полей-коллекций @JasperCollection управляет тем, какой компонент генерируется в JRXML - list или table:
@JasperCollection(columnWidth = 150)
private List<RevenueItem> items;
columnWidth задаёт ширину каждой колонки в пикселях. Общая ширина компонента = количество полей * columnWidth.
Исключение полей
Поле, которое не должно попасть в отчёт:
@JasperIgnore
private transient String internalCache;
Генерация JRXML
При mvn compile аннотационный процессор проходит по всем классам с @JasperModularReport и @JasperSubreport и обновляет их JRXML-шаблоны в target/generated-sources/.
Новый отчёт (mode = GenerationMode.CREATE) - процессор создаёт готовый к использованию JRXML со всеми <parameter>, <dataset>, компонентами для коллекций и band’ами для субрепортов. Остаётся открыть его в Jaspersoft Studio и добавить дизайн.
Существующий отчёт (mode = INJECT, по умолчанию) - процессор добавляет только недостающие элементы в ваш шаблон. Всё что уже есть - стили, layout, выражения - не трогается.
Ручное управление (mode = NONE) - процессор ничего не делает, вы управляете JRXML полностью сами.
Рабочий процесс:
Java-класс с аннотациями
|
mvn compile
|
target/generated-sources/your_report.jrxml
|
Jaspersoft Studio - добавить/обновить дизайн
|
src/main/resources/reports/your_report.jrxml
Рендеринг
Собираем данные и отдаём корневой объект рендереру:
CompanyReport report = new CompanyReport();
report.setTitleSubModule(buildTitle());
report.setFinancialSubModule(buildFinancials());
JasperPrint print = new JasperModularRenderer<>().render(report);
Один вызов render() делает всё:
Компилирует каждый JRXML (или достаёт из кэша)
Обходит поля через рефлексию, собирает Map<String, Object>
Рекурсивно компилирует и заполняет все субрепорты
Оборачивает коллекции в JRBeanCollectionDataSource
Вызывает JasperFillManager.fillReport()
Возвращает JasperPrint, готовый к экспорту в любой формат
Циклические зависимости (A -> B -> A) ловятся автоматически - выбрасывается JasperModularException с понятным сообщением, а не StackOverflowError.
Экспорт
JasperPrint - формато-нейтральный объект. Экспортируем во что угодно.
PDF:
ByteArrayOutputStream out = new ByteArrayOutputStream();
JRPdfExporter exporter = new JRPdfExporter();
exporter.setExporterInput(new SimpleExporterInput(print));
exporter.setExporterOutput(new SimpleOutputStreamExporterOutput(out));
exporter.exportReport();
XLSX (нужна зависимость jasperreports-excel-poi):
ByteArrayOutputStream out = new ByteArrayOutputStream();
JRXlsxExporter exporter = new JRXlsxExporter();
exporter.setExporterInput(new SimpleExporterInput(print));
exporter.setExporterOutput(new SimpleOutputStreamExporterOutput(out));
exporter.exportReport();
Spring MVC контроллер
@RestController
@RequestMapping("/sample")
public class SampleController {
@GetMapping(value = "/pdf", produces = MediaType.APPLICATION_PDF_VALUE)
public ResponseEntity<Resource> printPdf() throws JasperModularException, JRException {
JasperPrint print = new JasperModularRenderer<>().render(CompanyReportData.buildModule());
ByteArrayOutputStream out = new ByteArrayOutputStream();
JasperExportManager.exportReportToPdfStream(print, out);
return ResponseEntity.ok()
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=report.pdf")
.body(new ByteArrayResource(out.toByteArray()));
}
@GetMapping(value = "/xlsx")
public ResponseEntity<Resource> printXlsx() throws JasperModularException, JRException {
JasperPrint print = new JasperModularRenderer<>().render(CompanyReportData.buildModule());
ByteArrayOutputStream out = new ByteArrayOutputStream();
JRXlsxExporter exporter = new JRXlsxExporter();
exporter.setExporterInput(new SimpleExporterInput(print));
exporter.setExporterOutput(new SimpleOutputStreamExporterOutput(out));
exporter.exportReport();
return ResponseEntity.ok()
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=report.xlsx")
.contentType(MediaType.parseMediaType(
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"))
.body(new ByteArrayResource(out.toByteArray()));
}
}
Философия данных
Библиотека намеренно использует POJO-DTO как единственный способ передачи данных в отчёт. Никакого SQL в JRXML, никаких JSON-трансформаций. Вы получаете данные как угодно - JPA, JDBC, внешний API - делаете все вычисления в Java и передаёте готовые объекты:
List<RevenueItem> items = revenueRepository.findByPeriod(period);
double total = items.stream().mapToDouble(RevenueItem::getAmount).sum();
double growth = calculateGrowth(items);
RevenueSubModule revenue = new RevenueSubModule();
revenue.setTotalRevenue(total);
revenue.setGrowthPercent(growth);
revenue.setItems(items);
JRXML содержит только дизайн. Вся бизнес-логика остаётся в Java - где компилятор ловит ошибки, IDE помогает с навигацией и рефакторингом, а тесты проверяют вычисления без рендеринга PDF.
Прекомпиляция при старте
По умолчанию при запуске Spring Boot приложения все шаблоны прекомпилируются и кэшируются:
jasper:
modular:
precompile-enabled: true
base-package: com.example.reports
Если какой-то шаблон не компилируется - приложение не запустится. Сломанные шаблоны ловятся на этапе деплоя, а не на первом запросе пользователя.
Итого
jasper-modular существенно облегчает работу с JasperReports. Библиотека вводит модульный подход, в котором отчёт описывается как дерево простых POJO c упакованной под капот логикой через аннотации и наследование — каждый модуль отвечает за свою секцию, содержит свои данные и имеет свой JRXML-шаблон. Аннотационный процессор генерирует всю JRXML-обвязку при компиляции, а рантайм автоматически собирает, компилирует и заполняет весь отчёт целиком — включая все вложенные субрепорты и их данные. Под капотом для проброса данных используется малоизвестный механизм Map-параметров JasperReports, который полностью устраняет ручной дриллинг. В результате данные видимы и типизированы, модули переиспользуемы, а JRXML содержит только визуальный дизайн.
В результате: - Отчёт описывается Java-классами, а не XML - Субрепорты - это просто поля, без ручного wiring’а - Модули можно переиспользовать в разных отчётах - Данные типизированы и видимы - никакой магии - JRXML содержит только визуальный дизайн
Библиотека поддерживает JasperReports 6.x и 7.x, работает со Spring Boot 3.3+ и 4.x, требует Java 17+.
Если вы работаете с JasperReports и хотите упростить жизнь - попробуйте. Буду рад обратной связи и pull request’ам.
