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

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

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

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

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

Это третья часть. В первой — откуда взялся JasperReports и почему ему не хватало модульности, во второй — один стандарт и три идеи, на которых держится библиотека. Дальше идёт практика, по одной теме на часть. Здесь — как подключить библиотеку, написать первый модульный отчёт и что после этого происходит с шаблоном при сборке.

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

Требования: Java 17+ и JasperReports 6.x или 7.x — версию движка задаёте вы. Автоконфигурация и прекомпиляция приезжают со стартером, собранным под Spring Boot 3.3; ядро библиотеки от Spring не зависит вовсе и работает и без него.

Подключение

Стартер приносит ядро, процессор и автоконфигурацию, а заодно и сам JasperReports: в версии 3.0.0 движок приезжает транзитивно, версии 7.0.6. Поэтому объявляйте его явно и той версии, которая нужна вам — прямая зависимость перебивает транзитивную. Это не формальность: диалекты JRXML у шестой и седьмой версий несовместимы, и версия движка определяет, в каком из них процессор напишет ваш шаблон.

<dependency>
    <groupId>io.github.hhdevr</groupId>
    <artifactId>jasper-modular-starter</artifactId>
    <version>3.0.0</version>
</dependency>

<dependency>
    <groupId>net.sf.jasperreports</groupId>
    <artifactId>jasperreports</artifactId>
    <version>${jasperreports.version}</version>
</dependency>

Аннотационный процессор подключается в компилятор вместе с той же версией JasperReports — он читает и пишет шаблоны её API:

<plugin>
    <groupId>org.apache.maven.plugins</groupId>
    <artifactId>maven-compiler-plugin</artifactId>
    <configuration>
        <annotationProcessorPaths>
            <path>
                <groupId>io.github.hhdevr</groupId>
                <artifactId>jasper-modular-processor</artifactId>
                <version>3.0.0</version>
            </path>
            <path>
                <groupId>net.sf.jasperreports</groupId>
                <artifactId>jasperreports</artifactId>
                <version>${jasperreports.version}</version>
            </path>
        </annotationProcessorPaths>
    </configuration>
</plugin>

В седьмой версии движка экспорт в PDF вынесен в отдельный артефакт jasperreports-pdf — в стартер он намеренно не включён, добавьте его сами. И укажите пакет с отчётами для прекомпиляции на старте:

jasper:
  modular:
    base-package: com.example.reports

Модуль и отчёт

Субрепорт — класс, унаследованный от SubreportModule и помеченный @JasperSubreport:

@Getter
@Setter
@AllArgsConstructor
@JasperSubreport(templatePath = "/reports/sub_items.jrxml")
public class ItemsModule extends SubreportModule {

    private List<LineItem> items;
    private BigDecimal subtotal;

    @Override
    public boolean isEmpty() {
        return items == null || items.isEmpty();
    }
}

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

Корневой отчёт — класс от ModularReport с @JasperModularReport. Его поля и есть содержимое отчёта:

@Getter
@Setter
@JasperModularReport(templatePath = "/reports/invoice.jrxml")
public class InvoiceReport extends ModularReport {

    private String customerName;
    private String invoiceNumber;
    private BigDecimal total;
    private ItemsModule items;
}

Геттеры и сеттеры здесь только для удобства: поля отчётов и модулей рантайм читает рефлексией напрямую. Геттеры обязательны в другом месте — у классов-элементов коллекций, об этом в следующей части.

Имена параметров — от имени поля

Скаляры становятся параметрами шаблона с теми же именами: customerName, invoiceNumber, total. Для поля items типа ItemsModule в шаблоне отчёта появятся два параметра: itemsReport — скомпилированный шаблон модуля и itemsMapParameter — карта его данных.

Имя берётся из поля, а не из класса модуля. Это важно, когда один модуль стоит в отчёте дважды:

private AddressModule billTo;   // billToReport, billToMapParameter
private AddressModule shipTo;   // shipToReport, shipToMapParameter

Два поля одного типа — два независимых субрепорта со своими данными. Если двум полям всё-таки достанется одно имя — например, поле с тем же именем объявлено и в родительском классе, — сборка упадёт и попросит переименовать одно из них.

Рендер

InvoiceReport report = new InvoiceReport();
report.setCustomerName("Acme Corp");
report.setInvoiceNumber("INV-001");
report.setTotal(new BigDecimal("1500.00"));
report.setItems(new ItemsModule(lineItems, subtotal));

JasperPrint print = new JasperModularRenderer().render(report);
byte[] pdf = JasperExportManager.exportReportToPdf(print);

render() возвращает стандартный JasperPrint, дальше — любой экспортёр JasperReports: PDF, XLSX, HTML.

Что происходит при сборке

По умолчанию процессор работает в режиме INJECT:

  1. Находит существующий шаблон — в target/classes, куда Maven кладёт ресурсы до компиляции, или на своём classpath.

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

  3. Сверяет шаблон с классом — об этом отдельная часть серии.

  4. Кладёт результат в target/generated-sources/annotations по тому же пути. Шаблон в src процессор не меняет никогда.

Дальше вы открываете сгенерированный файл в Jaspersoft Studio, расставляете новые элементы и копируете его обратно в src/main/resources. Если шаблона ещё нет, процессор предупредит и сгенерирует заготовку с нуля.

Для поля items в шаблон отчёта добавится вот что (сокращённо, без uuid):

<parameter name="itemsReport" class="net.sf.jasperreports.engine.JasperReport"/>
<parameter name="itemsMapParameter" class="java.util.Map"/>

<band height="100" splitType="Stretch">
    <subreport>
        <reportElement positionType="Float" x="0" y="0" width="555" height="100" isRemoveLineWhenBlank="true"/>
        <parametersMapExpression><![CDATA[$P{itemsMapParameter}]]></parametersMapExpression>
        <dataSourceExpression><![CDATA[new net.sf.jasperreports.engine.JREmptyDataSource()]]></dataSourceExpression>
        <subreportExpression><![CDATA[$P{itemsReport}]]></subreportExpression>
    </subreport>
</band>

Здесь и работает механизм, ради которого всё затевалось. Субрепорт получает ровно два значения — свой скомпилированный шаблон и одну карту, а JasperReports сам раскладывает карту в параметры субрепорта: это встроенный механизм REPORT_PARAMETERS_MAP, о котором мало кто знает. В sub_items.jrxml при этом объявлены обычные параметры items и subtotal — их процессор тоже сгенерировал, из полей ItemsModule. Никаких <subreportParameter> по одному на каждое поле.

Режим задаётся в аннотации: INJECT — по умолчанию, как описано выше; CREATE — новая заготовка с чистого листа, существующий шаблон игнорируется, ориентация страницы берётся из orientation; NONE — процессор класс не трогает и не проверяет.

Кто владеет шаблоном

Если процессор пишет в шаблон, а человек правит тот же шаблон в Studio, чей это файл? Это классическая проблема кодогенерации в артефакт, который редактируют руками, так что стоит сказать, как библиотека делит работу.

Шаблон в src/main/resources принадлежит людям: разработчику и тому, кто верстает отчёт. Процессор туда не пишет никогда. При сборке он берёт шаблон, дописывает только недостающий вайринг и кладёт результат в target/generated-sources. Всё, что в шаблоне уже есть, он находит по имени и оставляет как было, так что вёрстка, стили и расставленные руками элементы не трогаются. Класс владеет именами и вайрингом, человек в Studio — вёрсткой.

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

В следующей части — коллекции, повторяющиеся субрепорты, что роняет сборку и как устроен рантайм.

Код — github.com/hhdevr/jasper-modular-library, пример — github.com/hhdevr/jasper-modular-sample.