Incluso con la multitud de bibliotecas disponibles hoy en día, a veces puede resultar complicado encontrar una que ofrezca la funcionalidad específica necesaria para una tarea concreta. En lugar de perder tiempo buscando la biblioteca perfecta, recomiendo crear su propia implementación; incluso si está diseñado específicamente para un proyecto.
Una vez, me encontré en la necesidad de una biblioteca que pudiera convertir fácilmente clases de datos en un documento de Excel para generar informes. Cuando no pude encontrar una biblioteca adecuada, decidí desarrollar una funcionalidad adaptada a mis necesidades específicas.
Mi ambición era diseñar una biblioteca similar a Jackson, que usaría anotaciones para transformar una lista de clases de datos en un documento de Excel según lo dictaran las anotaciones.
Quiero compartir la biblioteca que creé, con la esperanza de que pueda beneficiar a otros o inspirarlos a crear su propio mapeador para sus tareas únicas. Exploremos cómo desarrollar en Java un asignador de datos de este tipo para lograr esto:
de esto:
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); } }
Identifiquemos los principales elementos esenciales para el mapeo en Excel. Básicamente, requerimos una columna de Excel. Este componente fundamental del informe debe mostrar claramente el nombre de la columna y el valor correspondiente en cada fila.
Además, debemos incorporar soporte para celdas de fórmula, lo que nos permitirá utilizar valores y presentar los resultados de forma dinámica. Al final de la columna, es esencial una fórmula concluyente, ya sea que represente un promedio, una suma o cualquier otra métrica pertinente para el usuario final.
Más allá de las meras celdas de datos, también deberíamos integrar funciones para gestionar fácilmente el estilo de las celdas.
Después de identificar los elementos esenciales, el siguiente paso es elaborar las anotaciones necesarias. La anotación inicial incorporará metadatos sobre el estilo de celda. Esta anotación abarcará atributos fundamentales junto con sus valores predeterminados:
@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; }
Los elementos de estilo primarios cruciales para la creación de informes se transmiten como atributos. A continuación, se puede iniciar una anotación de columna de 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; }
Esta anotación abarca posibles nombres de columnas (para mapeo desde Excel) y un campo obligatorio: ' position
'. Esto determinará la ubicación de la columna y será fundamental en los cálculos de fórmulas. Además, detallará el estilo tanto del encabezado como de la celda.
Excelente. Ahora, formulemos una anotación específica para fórmulas de Excel. Anticipando una fórmula dinámica que depende de la posición de la fila, esta anotación será exclusiva de los métodos:
@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; }
Por último, introduzcamos una anotación para la fórmula final, que normalmente ocupa la última fila en Excel, resumiendo o ilustrando el resultado acumulativo de una columna. Dado que la anotación de la fórmula también exige su aplicabilidad únicamente a los métodos:
@Target({ElementType.METHOD}) @Retention(RetentionPolicy.RUNTIME) public @interface ColumnExcelTotalFormula { boolean useValue() default false; int position(); ColumnExcelStyle cellStyle() default @ColumnExcelStyle; }
Después de crear todas las anotaciones necesarias, el siguiente paso es crear una clase de servicio. La biblioteca principal utilizada será Apache POI , eficaz para trabajar con archivos .xls y .xlsx. Esta clase utilizará los atributos de las anotaciones para generar un informe de Excel.
El método principal acepta una lista de objetos como entrada y devuelve un libro preparado.
Para mayor flexibilidad, se introducirá un método sobrecargado para permitir la especificación tanto del nombre del archivo como del nombre de la hoja para la generación de informes:
<T> Workbook createWorkbookFromObject(List<T> reportObjects) { return createWorkbookFromObject(reportObjects, 0, "Report"); } <T> Workbook createWorkbookFromObject(List<T> reportObjects, int startRowNumber, String sheetName) { ... }
Para extraer información sobre la clase mediante la reflexión, se selecciona cualquier elemento de la matriz. Después de acceder a los detalles de la clase, se puede establecer la primera fila. La utilización de datos de la anotación permite la creación de celdas con sus respectivos nombres.
Si falta un nombre, el nombre del campo de clase puede servir como alternativa:
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()); } }
Al diseñar el encabezado, recuerde asignar un estilo a cada celda. Los parámetros de estilo se pueden derivar de la anotación @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()); }
Luego, el proceso pasa a generar filas en el informe en función de los datos de la matriz proporcionada. Al iterar sobre los datos, se forman filas sucesivas:
for (T report : reportObjects) { Row bodyRow = sheet.createRow(proceedRowNumber); createCellsFromDeclaredExcelColumns(bodyRow, report); proceedRowNumber++; }
Los descriptores de propiedad se adquieren para utilizar captadores en lugar de otorgar acceso directo a los campos:
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()); } }
Con el descriptor de propiedad se forma una celda:
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()); } }
A continuación, centrémonos en el procesamiento de las anotaciones @ColumnExcelFormula
. Elaborar fórmulas resulta un poco más complejo que simplemente extraer un valor de un campo. Se espera que un método genere una fórmula, que posteriormente se asigna a la celda.
El método debe devolver consistentemente una cadena y aceptar el número de fila como argumento, asegurando un uso preciso de los datos de las celdas adyacentes.
Por lo tanto, corresponde al controlador verificar que se cumplan estas condiciones antes de formar la celda con la fórmula especificada:
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()); } }
El último paso consiste en crear una fila para mostrar los resultados finales. Es importante destacar que esta fila debe generarse solo una vez, independientemente de la cantidad de objetos transmitidos al controlador. Para ello se prevé una anotación en un método estático.
Este método recibe como argumentos tanto el número de la fila inicial como la fila actual donde se creará una instancia de la celda.
Proporcionar el número de la fila inicial es vital, ya que permite que el método diseñe una fórmula que aproveche el resultado agregado de toda la columna:
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()); } } }
La funcionalidad principal ya está implementada y es hora de verla en acción. Construyamos un informe simple para demostrar su funcionamiento. Para ello, creemos una clase ' Sales
' e incorporemos todas las anotaciones necesarias:
@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() + ")"; } }
La clase consta de tres campos: date
, sold
y pricePerUnit
. Además, cuenta con una fórmula de ventas y una línea final con los totales: unitsSold
y totalSales
. Los campos utilizan la anotación @ColumnExcel
, que indica la posición y el nombre de la columna.
La anotación @ColumnExcelStyle
define el estilo tanto para el encabezado como para las celdas de datos individuales:
@ColumnExcel( position = 0, applyNames = {"Date"}, headerStyle = @ColumnExcelStyle( fontColor = WHITE, cellColor = DARK_BLUE, isCentreAlignment = true), cellStyle = @ColumnExcelStyle( cellColor = GREY_25_PERCENT, cellTypePattern = DATE) )
Como se discutió anteriormente, al crear una fórmula, el método debe aceptar un parámetro que indique el número de fila. Este requisito es evidente en la firma del método:
public String sales(int rowNum) { return new CellAddress(rowNum, 1).formatAsString() + "*" + new CellAddress(rowNum, 2).formatAsString(); }
Dado el número de filas y los índices de columnas, resulta factible elaborar cualquier fórmula específica.
Dentro de la clase, los métodos destinados a las fórmulas finales son estáticos y requieren dos parámetros: el número de la fila inicial y el número de la fila final:
public static String unitsSold(int firstRowNum, int lastRowNum) { return "SUM(" + new CellAddress(firstRowNum, 1).formatAsString() + ":" + new CellAddress(lastRowNum - 1, 1).formatAsString() + ")"; }
Ahora, iniciemos el método:
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); } }
Y examine el informe generado:
Escribir una biblioteca especializada para una tarea específica resultó sencillo. La biblioteca diseñada cumple con los requisitos e incluye la funcionalidad planificada. El enfoque que utiliza anotaciones facilita la personalización rápida y conveniente de estilos de celda, modificación de fórmulas y creación de informes dinámicos a partir de diversas fuentes de datos.
Por lo tanto, la próxima vez que sea difícil encontrar una biblioteca adecuada, podría ser beneficioso considerar el desarrollo de una personalizada.
Naturalmente, presentar cada línea de código en este artículo no fue factible; por lo tanto, sólo se resaltaron los métodos principales esenciales para la operación del mapeador. El código completo está disponible en mi página de GitHub .