即使当今有大量可用的库,有时也很难找到一个提供特定任务所需特定功能的库。与其花时间寻找完美的库,我建议自己制作实现;即使它是专门为一个项目量身定制的。
有一次,我发现自己需要一个库,可以轻松地将数据类转换为 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() + ")"; } }
该类由三个字段组成:、 和 。此外,它还有一个销售公式和一条带有总数的结论行:和 .这些字段使用注释,表示列的位置和名称。date
sold
pricePerUnit
unitsSold
totalSales
@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 页面上找到。