从数据类到 Excel:使用注释构建 Java 映射器

即使当今有大量可用的库,有时也很难找到一个提供特定任务所需特定功能的库。与其花时间寻找完美的库,我建议自己制作实现;即使它是专门为一个项目量身定制的。

有一次,我发现自己需要一个库,可以轻松地将数据类转换为 Excel 文档以生成报告。当我找不到合适的库时,我决定开发适合我特定需求的功能。

我的目标是设计一个类似于 Jackson 的库,它将使用注释将数据类列表转换为由注释指示的 Excel 文档。

我想分享我创建的库,希望它能使其他人受益,或者激励他们为他们独特的任务创建自己的映射器。让我们探讨如何在 Java 中开发这样的数据映射器来实现这一点:

由此:

void demoReport() {
    var excelMapper = new ExcelMapperImpl();

    var fileName = "demo-out-" + LocalTime.now() + ".xlsx";
    List<Demo> demos = generateDemos();

    try (Workbook workbook = excelMapper.createWorkbookFromObject(demos);
         var fileOutputStream = new FileOutputStream(fileName)) {
        workbook.write(fileOutputStream);
    }
}

定义批注

让我们确定 Excel 映射必不可少的主要元素。从本质上讲,我们需要一个 Excel 列。报表的这一基本组成部分应清楚地显示每行中的列名称和相应的值。

此外,我们必须加入对公式单元格的支持,使我们能够利用值并动态呈现结果。在列的末尾,结论公式是必不可少的,无论它代表最终用户的平均值、总和还是任何其他相关指标。

除了单纯的数据单元之外,我们还应该集成功能以轻松管理单元样式。

确定基本元素后,下一步是制作必要的注释。初始注释将嵌入有关单元格样式的元数据。此注释将包含基本属性及其默认值:

@Retention(RetentionPolicy.RUNTIME)
public @interface ColumnExcelStyle {
    ExcelColumnDataFormat cellTypePattern() default ExcelColumnDataFormat.NONE;
    IndexedColors cellColor() default IndexedColors.AUTOMATIC;

    boolean isWrapText() default false;

    boolean isCentreAlignment() default false;

    boolean isFramed() default true;

    ExcelColumnFont fontName() default ExcelColumnFont.DEFAULT;

    short fontSize() default -1;

    boolean isFontBold() default false;

    ExcelColumnCellTextColor fontColor() default ExcelColumnCellTextColor.AUTOMATIC;
}

对报表创建至关重要的主要样式元素以属性的形式传达。在此之后,可以启动 Excel 列注释:

@Target({ElementType.FIELD})
@Retention(RetentionPolicy.RUNTIME)
public @interface ColumnExcel {
    String[] applyNames() default {};

    int position();

    ColumnExcelStyle headerStyle() default @ColumnExcelStyle(
            fontColor = ExcelColumnCellTextColor.BLACK,
            isCentreAlignment = true,
            isFontBold = true,
            fontSize = 14,
            isWrapText = true);

    ColumnExcelStyle cellStyle() default @ColumnExcelStyle;
}

此批注包含可能的列名称(用于从 Excel 进行映射)和必填字段 – ”。这将确定列的位置,并有助于公式计算。此外,它还将详细说明标题和单元格的样式。position

非常好。现在,让我们制定特定于 Excel 公式的注释。预计动态公式取决于行的位置,此注释将独占于方法:

@Target({ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
public @interface ColumnExcelFormula {
    String name() default "";

    int position();

    ColumnExcelStyle headerStyle() default @ColumnExcelStyle(
            fontColor = ExcelColumnCellTextColor.BLACK,
            isCentreAlignment = true,
            isFontBold = true,
            fontSize = 14,
            isWrapText = true);

    ColumnExcelStyle cellStyle() default @ColumnExcelStyle;
}

最后,让我们介绍一个最终公式的注释,该公式通常占据Excel中的最后一行,总结或说明列的累积结果。鉴于公式的注释还要求其仅适用于方法:

@Target({ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
public @interface ColumnExcelTotalFormula {
    boolean useValue() default false;

    int position();

    ColumnExcelStyle cellStyle() default @ColumnExcelStyle;
}

使用 Apache POI 构建 Excel Reporting Service

在创建所有必要的注释之后,下一步是创建服务类。使用的核心库将是Apache POI,对于处理.xls和.xlsx文件非常有效。此类将使用批注的属性来生成 Excel 报告。

主要方法接受对象列表作为输入,并返回准备好的工作簿。

为了增加灵活性,将引入一种重载方法,以启用文件名和工作表名称的指定,以便生成报告:

<T> Workbook createWorkbookFromObject(List<T> reportObjects) {
    return createWorkbookFromObject(reportObjects, 0, "Report");
}

<T> Workbook createWorkbookFromObject(List<T> reportObjects, 
                                      int startRowNumber, 
                                      String sheetName) {
...
}

若要使用反射提取有关类的信息,请选择数组中的任何元素。访问类详细信息后,可以建立第一行。利用注释中的数据可以创建具有各自名称的单元格。

如果缺少名称,则类字段的名称可以用作替代项:

private <T> void createHeaderFromDeclaredExcelColumns(Row row, Class<T> clazz, PropertyDescriptor propertyDescriptor) {
    try {
        Field field = clazz.getDeclaredField(propertyDescriptor.getName());

        ColumnExcel columnExcel = field.getDeclaredAnnotation(ColumnExcel.class);
        if (nonNull(columnExcel)) {
            String headerName = columnExcel.applyNames().length > 0 ? columnExcel.applyNames()[0] : field.getName();
            createHeader(row, columnExcel.position(), headerName, columnExcel.headerStyle());
        }
    } catch (NoSuchFieldException e) {
        log.debug(e.getLocalizedMessage());
    }
}

在设计标题时,请记住为每个单元格指定一个样式。样式参数可以从注解中派生:@ColumnExcelStyle

private void createHeader(Row row, int position, String name, ColumnExcelStyle columnExcelStyle) {
    Cell cell = row.createCell(position);
    cell.setCellValue(name);

    setCellFormatting(cell, columnExcelStyle);
    row.getSheet().autoSizeColumn(cell.getColumnIndex());
}

然后,该过程将转向根据提供的数组中的数据在报表中生成行。通过遍历数据,形成连续的行:

for (T report : reportObjects) {
    Row bodyRow = sheet.createRow(proceedRowNumber);
    createCellsFromDeclaredExcelColumns(bodyRow, report);
    proceedRowNumber++;
}

获取属性描述符是为了利用 getter,而不是授予对字段的直接访问权限:

private <T> void createCellsFromDeclaredExcelColumns(Row row, T tObject) {
    try {
        PropertyDescriptor[] propertyDescriptors = Introspector.getBeanInfo(tObject.getClass()).getPropertyDescriptors();

        for (var propertyDescriptor : propertyDescriptors) {
            createCellFromDeclaredExcelColumns(row, tObject, propertyDescriptor);
        }
    } catch (IntrospectionException ex) {
        log.debug(ex.getLocalizedMessage());
    }
}

使用属性描述符,将形成一个单元格:

private <T> void createCellFromDeclaredExcelColumns(Row row, T tObject, PropertyDescriptor propertyDescriptor) {
    try {
        Field field = tObject.getClass().getDeclaredField(propertyDescriptor.getName());
        Method readMethod = propertyDescriptor.getReadMethod();

        ColumnExcel columnExcel = field.getDeclaredAnnotation(ColumnExcel.class);
        if (nonNull(columnExcel)) {
            Class<?> returnType = readMethod.getReturnType();
            Cell cell = row.createCell(columnExcel.position());

            Object invokeResult = readMethod.invoke(tObject);
            if (nonNull(invokeResult)) {
                defineAndAssignCellValue(returnType, cell, invokeResult, readMethod);
            }
            setCellFormatting(cell, columnExcel.cellStyle());
        }
    } catch (NoSuchFieldException | InvocationTargetException | IllegalAccessException e) {
        log.debug(e.getLocalizedMessage());
    }
}

接下来,让我们将注意力转向处理注释。事实证明,制作公式比简单地从字段中提取值要复杂一些。应方法生成一个公式,该公式随后分配给单元格。@ColumnExcelFormula

该方法应始终如一地返回字符串并接受行号作为参数,从而确保相邻单元格的准确数据使用。

因此,在使用指定的公式形成单元格之前,处理程序需要验证是否满足这些条件:

private <T> void createCellFromDeclaredExcelFormula(Row row, T tObject, Method readMethod) throws IllegalAccessException, InvocationTargetException {
    ColumnExcelFormula columnExcelFormula = readMethod.getDeclaredAnnotation(ColumnExcelFormula.class);
    if (columnExcelFormula != null) {
        Class<?> returnType = readMethod.getReturnType();
        Cell cell = row.createCell(columnExcelFormula.position());

        if (returnType.isAssignableFrom(String.class)) {
            cell.setCellFormula((String) readMethod.invoke(tObject, row.getRowNum()));
        } else {
            log.debug(" Return type for the method: " + readMethod.getName() + " with @ColumnExcelFormula annotation has to be String " +
                    "and now it's: " + returnType.getName() + " method is ignored for the reason");
        }
        setCellFormatting(cell, columnExcelFormula.cellStyle());
    }
}

最后一步是创建一行来显示结论结果。重要的是,无论转发给处理程序的对象数如何,此行都应仅生成一次。为此,需要静态方法中的注释。

此方法接收初始行和当前行的编号,其中单元格将作为参数实例化。

提供初始行号至关重要,使该方法能够设计一个公式来利用整个列的聚合结果:

private <T> void createTotalFormula(Class<T> tClazz, Row row, int firstRowNum) {
    Method[] methods = tClazz.getDeclaredMethods();
    for (Method method : methods) {
        ColumnExcelTotalFormula columnExcelTotalFormula = method.getAnnotation(ColumnExcelTotalFormula.class);
        if (columnExcelTotalFormula != null
                && method.getReturnType().isAssignableFrom(String.class)
                && method.getParameters().length == 2
                && Modifier.isStatic(method.getModifiers())
                && !Modifier.isPrivate(method.getModifiers())
        ) {
            String cellFormula = (String) method.invoke(tClazz, firstRowNum, row.getRowNum());
            Cell cell = row.createCell(columnExcelTotalFormula.position());
            cell.setCellFormula(cellFormula);

            if (columnExcelTotalFormula.useValue()) {
                cell = applyFormulasValue(cell);
            }
            setCellFormatting(cell, columnExcelTotalFormula.cellStyle());
        }
    }
}

制作报告

主要功能现在已经到位,是时候看看它的实际应用了。让我们构造一个简单的报表来演示其操作。为此,让我们创建一个 ” 类并合并所有必要的注释:Sales

@Data
@Accessors(chain = true)
public class Sales {

    @ColumnExcel(
            position = 0, applyNames = {"Date"},
            headerStyle = @ColumnExcelStyle(
                              fontColor = WHITE, 
                              cellColor = DARK_BLUE, 
                              isCentreAlignment = true),
            cellStyle = @ColumnExcelStyle(
                            cellColor = GREY_25_PERCENT, 
                            cellTypePattern = DATE))
    private LocalDate date;

    @ColumnExcel(
            position = 1, applyNames = {"Sold"},
            headerStyle = @ColumnExcelStyle(
                            fontColor = WHITE, 
                            cellColor = DARK_BLUE, 
                            isCentreAlignment = true),
            cellStyle = @ColumnExcelStyle(
                          cellColor = GREY_25_PERCENT))
    private Integer sold;

    @ColumnExcel(
            position = 2, 
            applyNames = {"Price Per Unit (USD)"},
            headerStyle = @ColumnExcelStyle(
                            fontColor = WHITE, 
                            cellColor = DARK_BLUE, 
                            isCentreAlignment = true),
            cellStyle = @ColumnExcelStyle(
                          cellColor = GREY_25_PERCENT, 
                          cellTypePattern = USD))
    private Double pricePerUnit;

    @ColumnExcelFormula(
            position = 3, 
            name = "Total Sales (USD)",
            headerStyle = @ColumnExcelStyle(
                             fontColor = WHITE, 
                             cellColor = DARK_BLUE, 
                             isCentreAlignment = true),
            cellStyle = @ColumnExcelStyle(
                            cellColor = GREY_25_PERCENT, 
                            cellTypePattern = USD))
    public String sales(int rowNum) {
        return new CellAddress(rowNum, 1).formatAsString()
                + "*"
                + new CellAddress(rowNum, 2).formatAsString();
    }

    @ColumnExcelTotalFormula(
            position = 0,
            cellStyle = @ColumnExcelStyle(
                           cellColor = LIGHT_BLUE))
    public static String total(int firstRowNum, int lastRowNum) {
        return "CONCATENATE(\"Total\")";
    }

    @ColumnExcelTotalFormula(
            position = 1,
            cellStyle = @ColumnExcelStyle(
                          cellColor = LIGHT_BLUE))
    public static String unitsSold(int firstRowNum, int lastRowNum) {
        return "SUM(" + new CellAddress(firstRowNum, 1).formatAsString() + ":"
                + new CellAddress(lastRowNum - 1, 1).formatAsString() + ")";
    }


    @ColumnExcelTotalFormula(
            position = 3,
            cellStyle = @ColumnExcelStyle(
                          isCentreAlignment = false, 
                          cellColor = LIGHT_BLUE, 
                          cellTypePattern = USD))
    public static String totalSales(int firstRowNum, int lastRowNum) {
        return "SUM(" + new CellAddress(firstRowNum, 3).formatAsString() + ":"
                + new CellAddress(lastRowNum - 1, 3).formatAsString() + ")";
    }
}

该类由三个字段组成:、 和 。此外,它还有一个销售公式和一条带有总数的结论行:和 .这些字段使用注释,表示列的位置和名称。datesoldpricePerUnitunitsSoldtotalSales@ColumnExcel

注释定义标题和单个数据单元格的样式:@ColumnExcelStyle

@ColumnExcel(
        position = 0, 
        applyNames = {"Date"},
        headerStyle = @ColumnExcelStyle(
                          fontColor = WHITE, 
                          cellColor = DARK_BLUE, 
                          isCentreAlignment = true),
        cellStyle = @ColumnExcelStyle(
                          cellColor = GREY_25_PERCENT, 
                          cellTypePattern = DATE)
)

如前所述,在创建公式时,该方法必须接受指示行号的参数。此要求在方法的签名中很明显:

    public String sales(int rowNum) {
        return new CellAddress(rowNum, 1).formatAsString()
                + "*"
                + new CellAddress(rowNum, 2).formatAsString();
    }

给定行号和列索引,制定任何特定公式都变得可行。

在类中,用于结束公式的方法是静态的,需要两个参数:起始行的编号和结束行的编号:

    public static String unitsSold(int firstRowNum, int lastRowNum) {
        return "SUM(" + 
                new CellAddress(firstRowNum, 1).formatAsString() + 
                ":" + 
                new CellAddress(lastRowNum - 1, 1).formatAsString() + 
                ")";
    }

现在,让我们启动该方法:

void salesReport() {
    var excelMapper = new ExcelMapperImpl();

    var fileName = "sales-out-" + LocalTime.now() + ".xlsx";
    List<Sales> sales = List.of(
                new Sales().setDate(LocalDate.of(2023, 1, 1))
                        .setSold(50)
                        .setPricePerUnit(10d),
                new Sales().setDate(LocalDate.of(2023, 1, 2))
                        .setSold(40)
                        .setPricePerUnit(11d),
                new Sales().setDate(LocalDate.of(2023, 1, 3))
                        .setSold(55)
                        .setPricePerUnit(9d);

    try (Workbook workbook = excelMapper.createWorkbookFromObject(sales);
         var fileOutputStream = new FileOutputStream(fileName)) {
        workbook.write(fileOutputStream);
    }
}

并检查生成的报告:

结论

事实证明,为特定任务编写一个专门的库是很简单的。构建的库符合要求,并包含计划的功能。利用注释的方法有助于快速方便地自定义单元格样式、修改公式以及从各种数据源创建动态报告。

因此,下次当一个合适的图书馆难以捉摸时,考虑开发一个个性化的图书馆可能是有益的。

当然,在本文中介绍每一行代码是不可行的;因此,仅突出显示了对映射器操作至关重要的主要方法。完整的代码可在我的 GitHub 页面上找到。