即使当今有大量可用的库,有时找到一个能够提供特定任务所需的特定功能的库也很困难。我建议您编写自己的实现,而不是花时间寻找完美的库;即使它是专门为一个项目量身定制的。
有一次,我发现自己需要一个可以轻松将数据类转换为 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 ,它可以有效地处理 .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 页面上找到。