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

За последние пару лет я несколько раз столкнулся с репортингом на JasperReports. Каждый раз одни и те же проблемы: монолитные шаблоны, неочевидный поток данных, ручная синхронизация между Java и JRXML. В какой-то момент я решил это системно исправить - и написал библиотеку jasper-modular, которая решает две ключевые проблемы:

  1. Снижает сложность работы с JasperReports за счёт модульности, а саму модульность делает простой, понятной и автоматической

  2. Делает данные и весь процесс построения отчёта «видимым» - всё описано типизированными 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() делает всё:

  1. Компилирует каждый JRXML (или достаёт из кэша)

  2. Обходит поля через рефлексию, собирает Map<String, Object>

  3. Рекурсивно компилирует и заполняет все субрепорты

  4. Оборачивает коллекции в JRBeanCollectionDataSource

  5. Вызывает JasperFillManager.fillReport()

  6. Возвращает 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’ам.