记录一次EasyExcel的使用

1.这篇文章写什么?

在工作中碰到了这样一个需求,要从数据库里面读取一段时间内,每天某些时刻对应的温度数据,一天可能会设置有多个时间点,导出到excel中的模板需要支持多种格式的,比如一个月,一个季度,上半年,下半年等,并且需要支持灵活的配置导出的时间段范围。超过温度的点需要用红色来进行标识

2.为什么写这篇文章

在工作中碰到了需要导出excel报表的需求,写这篇文章记录从需求到实现的过程,供后续参考和改进

3.如何实现?

要实现导出excel,首先就想到了easyexcel,它可以支持读、写、填充excel,针对现在这个需求,如果需要自定义时间的话可以通过配置字典,前端读取字典用dayjs来进行解析,把解析到的时间范围给到后端,后端再根据时间范围去导出数据,至于excel方面,想到了先用占位符在excel模板中预设好位置,然后用代码进行填充,主要的实现流程就大概有了

4.具体实现

根据上面的主要流程,下面就来进行每一步的细化,首先是前端

4.1 解析时间

通过字典来读取时间范围的代码,至于为什么不用后端来实现,之前也考虑过,不过因为无法确定具体的时间,就没用后端去实现,因为一个月不固定有多少天,不固定有多少周,如果用后端去实现的话可能会得不到准确的时间,反观前端的dayjs,就可以很简单的实现这个功能,通过解析dayjs代码来实现时间的获取,代码如下:

TS 复制代码
<template>
  <div>
    {{getDates()}}
  </div>
</template>

<script lang="ts" setup>
import dayjs from "dayjs";
const getDates = () =>{
  const str =  "const startTime = dayjs().startOf('year').format('YYYY-MM-DD HH:mm:ss');const endTime = dayjs().endOf('year').format('YYYY-MM-DD HH:mm:ss');return [startTime, endTime]"
  const timeFunc = new Function('dayjs', str);
  const data =  timeFunc(dayjs)
  console.log(data[0])
  console.log(data[1])
  return timeFunc(dayjs)
}
</script>

用str来模拟从字段中读取到的时间范围代码,用dayjs解析出来,这样页面上就会直接打印出当前的时间: [ "2023-01-01 00:00:00", "2023-12-31 23:59:59" ]

到此,前端解析时间的任务就完成了

4.2 导出excel

万丈高台起于垒土,要实现本次需求,首先要能导出excel,然后再考虑样式,最后再考虑现有可行方案的兼容性,能否兼容多种格式的模板。经过考察官网,发现了两种看起来比较可行和符合目前需求的导出方式: 首先把依赖导入先:

XML 复制代码
<dependency>  
    <groupId>com.alibaba</groupId>  
    <artifactId>easyexcel</artifactId>  
    <version>3.2.1</version>  
</dependency>

方式1: 定义一个模板,里面就搞几个占位符,每个占位符替换一个内容

直接上代码:

JAVA 复制代码
@Test
public void simpleFill() {
    // 模板注意 用{} 来表示你要用的变量 如果本来就有"{","}" 特殊字符 用"\{","\}"代替
    String templateFileName = "F:/excel/" + "simple.xlsx";
    // 方案1 根据对象填充
    String fileName = "F:/excel/" + System.currentTimeMillis() + ".xlsx";
    // 这里会填充到第一个sheet, 然后文件流会自动关闭
    Map<String, Object> map = new HashMap<>();
    map.put("name", "张三");
    map.put("number", 5.2);
    map.put("month", 5);
    EasyExcel.write(fileName).withTemplate(templateFileName)
            .sheet().doFill(map);
}

看效果,替换成功了,能正常替换,这种方式能跑:

方式2 定义一个模板,里面搞几个占位符,以.开头的为循环的占位符

直接上代码:

JAVA 复制代码
    @Test
    public void horizontalFill() {
        String templateFileName = "F:/excel/" + "simpleHeng.xlsx";
        String fileName = "F:/excel/" + System.currentTimeMillis() + "heng" + ".xlsx";
        // 方案1
        try (ExcelWriter excelWriter = EasyExcel.write(fileName).withTemplate(templateFileName)
                .build()) {
            WriteSheet writeSheet = EasyExcel.writerSheet().build();
            excelWriter.write(data(),writeSheet);
        }
    }
JAVA 复制代码
private List<FillData> data() {
    List<FillData> list = ListUtils.newArrayList();
    for (int i = 0; i < 10; i++) {
        FillData fillData = new FillData();
        list.add(fillData);
        fillData.setName("张三" + i);
        fillData.setNumber(5.2);
        fillData.setDate(new Date());
    }
    return list;
}
JAVA 复制代码
@Getter
@Setter
@EqualsAndHashCode
public class FillData {
    private String name;
    private double number;
    private Date date;
}

看效果,替换成功(虽然哪里感觉不对劲):

现在基本的替换模板实现了,那么就进一步深入,给表格里面的内容添加颜色样式

经过网上搜索了一下,发现自定义样式需要写一个类去实现CellWriteHandler,从而自定义样式

JAVA 复制代码
@Slf4j
public class CustomCellWriteHandler implements CellWriteHandler {

    @Override
    public void afterCellDispose(CellWriteHandlerContext context) {
        Cell cell = context.getCell();
        Workbook workbook = context.getWriteWorkbookHolder().getWorkbook();
        CellStyle cellStyle = workbook.createCellStyle();
        Font writeFont = workbook.createFont();
        writeFont.setColor(IndexedColors.RED.getIndex());
        writeFont.setBold(true);
        cellStyle.setFont(writeFont);
        cell.setCellStyle(cellStyle);
        context.getFirstCellData().setWriteCellStyle(null);
    }
}

然后在原有导出代码的基础上加个registerWriteHandler(new CustomCellWriteHandler())

JAVA 复制代码
EasyExcel.write(fileName).withTemplate(templateFileName)
        .registerWriteHandler(new CustomCellWriteHandler())
        .sheet().doFill(map);

然后就可以看到导出的效果了

再试试第二种方式,也就是循环写入的,加个样式:

JAVA 复制代码
WriteSheet writeSheet = EasyExcel.writerSheet().registerWriteHandler(new CustomCellWriteHandler()).build();

然后导出,发现这种方式的导出的excel未能成功设置颜色 ,后面也尝试了很多次,确定了这种方式没法带上样式,所以就放弃这种方式了

到这里就只剩下excel中每个位置的都用一个占位符来占位替换的方式了

4.3 实现思路

确定了可行的实现方式,现在就要在这种方式上思考如何实现兼容多种时间段,多种模板的导出。需求中每天可能会设置若干个点,也可能需要导出不同时间段的温度数据,例如月、季度、半年等,或者是其他更细的时间点,每次的模板可能也不大一样,例如下面的几种:

老王的模板

老张的模板

老李的模板

可以看出需要兼容不同的模板确实是个伤脑筋的事情,后面发现了个比较折中的办法:因为目前能实现excel上导出带样式的就只有替换占位符的方法,如何在不改代码的前提下适配多种模板呢,就直接把模板上每个格子都对应一个占位符,只要后端每次都是按照固定规则去生成数据的,那么只要设置好模板里每个格子的占位符就可以解决问题了。只要每次生成数据的顺序都是按照{t1}、{t2}、{t3}这样,依次类推下去,只要模板配置好占位符即可。例如每天一个监测点,那{t1}就对应第一天监测点的温度,{t2}就对应第二天监测点的温度,如果每天两个监测点,那么{t1}和{t2}就分别对应第一天的第一、第二个监测点的温度。这样就可以实现导出月、季度、半年的数据而不用改代码了,如果要导出某个时间段的,例如1月15日到2月15日的,这种时间段的话就使用大一级的模板,使用季度的模板即可兼容。

4.4 代码实现

有了上面的思路,大致实现流程就出来了

4.4.1 前端解析字符串,获取时间范围

这个上面有提及,这里就不再赘述,跳过

4.4.2 后端获取每天配置的时间点

由于配置的时间点是12、13、15这种,并不是具体的一个时间,因此需要结合时间范围来解析成具体的时间,首先需要获取时间段里面的每一天,然后再根据配置的时间点得到每个具体的时间点,获取到了时间点之后就可以去数据库中查询这个时间点对应的数据,模拟代码如下: 首先说明,这里引入了hutool来处理时间

XML 复制代码
<dependency>  
    <groupId>cn.hutool</groupId>  
    <artifactId>hutool-all</artifactId>  
    <version>5.8.18</version>  
</dependency>
JAVA 复制代码
public static void main(String[] args) {
        DateTime startTime = DateUtil.parse("2023-07-01 00:00:00");
        DateTime endTime = DateUtil.parse("2023-10-31 23:59:59");
        List<Date> timeRangeDates = getTimeRangeDates(startTime, endTime);
        List<String> times = Arrays.asList("12");
        Map<DateTime, Float> resultMap = getTemperature(timeRangeDates, times);
    }

    /**
     * 获取要导出数据的Map, key为时间,value为时间对应的值
     *
     * @param timeRangeDates 时间范围
     * @param times          每天定义的时间点
     * @return 导出的map
     */
    private static Map<DateTime, Float> getTemperature(List<Date> timeRangeDates, List<String> times) {
        Map<DateTime, Float> resultMap = new HashMap<>();
        for (Date timeRangeDate : timeRangeDates) {
            for (String time : times) {
                // eg: 12 ==> 2023-11-07 12:00:00
                String tempTime = DateUtil.formatDateTime(timeRangeDate).substring(0, 11) + time + ":00:00";
                DateTime recordTime = DateUtil.parse(tempTime);
                // 如果是将来的时间,就不执行查询,认为是没有数据的
                if (recordTime.isAfter(new Date())) {
                    resultMap.put(recordTime, null);
                    continue;
                }
                // 模拟从数据库拿到的温度,10.5 °C
                resultMap.put(recordTime, selectTemperature());
            }
        }
        return resultMap;
    }

    /**
     * 模拟从数据库查询数据,随机生成2到10之间的一个数字,保留一位小数
     *
     * @return 2到10之间的一个数字, 保留一位小数
     */
    private static float selectTemperature() {
        Random random = new Random();
        float minValue = 2.0f;
        float maxValue = 10.0f;
        float range = maxValue - minValue;
        float randomFloat = (random.nextFloat() * range) + minValue;
        DecimalFormat df = new DecimalFormat("#.0");
        String formattedFloat = df.format(randomFloat);
        return Float.parseFloat(formattedFloat);
    }

    /**
     * 获取起止时间内的每一天
     *
     * @param start 开始时间
     * @param end   结束时间
     * @return 每天
     */
    private static List<Date> getTimeRangeDates(Date start, Date end) {
        List<Date> dates = new ArrayList<>();
        DateTime current = DateUtil.date(start);
        DateTime endTime = DateUtil.date(end);
        while (current.isBefore(endTime)) {
            dates.add(current.toJdkDate());
            current = current.offset(DateField.DAY_OF_MONTH, 1);
        }
        return dates;
    }

经过上面一通操作,就可以得到了一个Map,里面的key是时间点,value是时间点对应的温度。 按道理来说,map已经构造好了,直接编写模板导出就完事了,就在这时,又碰到了一个问题,map上的{t1}、{t2}等占位符如何跟时间匹配的问题,模板上肯定是每个月都以最大的时间来定义,也就是31天,但并不是每个月都有31天,因此要在导出数据的时候把那些应该为空的占位符也计算出来,拿老王的模板来举例子:

今年是2023年,9月只有30天,那么模板上31号的占位符应该导出的时候就应该设置个空值,也就是在碰到{t93}的时候需要把值设置为空,然后再导出excel

完整代码如下:

JAVA 复制代码
package com.example.demo.test;

import cn.hutool.core.date.DateField;
import cn.hutool.core.date.DateTime;
import cn.hutool.core.date.DateUtil;
import com.alibaba.excel.EasyExcel;
import com.example.demo.handler.CustomCellWriteHandler;

import java.text.DecimalFormat;
import java.time.YearMonth;
import java.time.ZoneId;
import java.util.*;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.stream.Collectors;

public class ExportTest {

    public static void main(String[] args) {
        DateTime startTime = DateUtil.parse("2023-07-01 00:00:00");
        DateTime endTime = DateUtil.parse("2023-12-31 23:59:59");
        List<Date> timeRangeDates = getTimeRangeDates(startTime, endTime);
        List<String> times = Collections.singletonList("12");
        Map<DateTime, Float> resultMap = getTemperature(timeRangeDates, times);
        // 获取日期范围内的月份
        List<Integer> distinctMonths = getDistinctMonths(timeRangeDates);
        // 获取月份对应的天数
        Map<Integer, Integer> daysInMonths = getDaysInMonths(timeRangeDates);
        // 获取应该置空的下标
        List<Integer> emptyIndexList = getEmptyIndexList(daysInMonths, times.size());
        // 获取理论上一共有多少天,按照一个月31天算
        int totalDaysCount = getTotalDaysCount(distinctMonths, timeRangeDates.size());
        Map<String, Object> exportDataMap = new HashMap<>();
        // i快 j慢
        for (int i = 0, j = 0; i < totalDaysCount; i++) {
            int currentDateIndex = j;
            int currentCount = (i + 1) * times.size() - (times.size() - 1);
            for (String time : times) {
                // 本月不足31天的填充null
                if (emptyIndexList.contains(currentCount)) {
                    exportDataMap.put("t" + currentCount++, null);
                    continue;
                }
                // 12 ==> 2023-10-25 12:00:00
                String tempTime = DateUtil.formatDateTime(timeRangeDates.get(currentDateIndex))
                        .substring(0, 11) + time + ":00:00";
                if (currentDateIndex == j) {
                    j++;
                }
                // 根据date查询数据
                DateTime recordTime = DateUtil.parse(tempTime);
                exportDataMap.put("t" + currentCount++, resultMap.get(recordTime));
            }
        }
        // 模板注意 用{} 来表示你要用的变量 如果本来就有"{","}" 特殊字符 用"\{","\}"代替
        String templateFileName = "F:/excel/" + "老王.xlsx";
        // 方案1 根据对象填充
        String fileName = "F:/excel/" + System.currentTimeMillis() + "老王.xlsx";
        EasyExcel.write(fileName).withTemplate(templateFileName)
                .registerWriteHandler(new CustomCellWriteHandler())
                .sheet().doFill(exportDataMap);
    }

    private static int getTotalDaysCount(List<Integer> distinctMonths, int timeRangeSize) {
        if (timeRangeSize <= 31) {
            return Math.min(distinctMonths.size() * 31, timeRangeSize);
        }
        return distinctMonths.size() * 31;
    }
    
    private static List<Integer> getEmptyIndexList(Map<Integer, Integer> daysInMonths, int pointCount) {
        List<Integer> list = new ArrayList<>();
        AtomicInteger count = new AtomicInteger();
        daysInMonths.forEach((key, value) -> {
            // 本月的开始长度
            int monthLength = count.get();
            // 总本月虚拟长度
            count.addAndGet(31 * pointCount);
            // 本月实际长度
            int realLength = value * pointCount;
            // 本月开始下标
            int startIndex = monthLength + realLength;
            // 多出的下标
            int extraCount = count.get() - realLength - monthLength;
            // 记录需要存空值的占位符位置
            for (int i = startIndex + 1; i <= startIndex + extraCount; i++) {
                list.add(i);
            }
        });
        return list;
    }
    
    /**
     * 获取今年的某些月份的天数
     *
     * @param timeRangeDates 时间范围
     * @return 该月份对应的年
     */
    private static Map<Integer, Integer> getDaysInMonths(List<Date> timeRangeDates) {
        List<Integer> distinctMonths = getDistinctMonths(timeRangeDates);
        Date firstDate = timeRangeDates.get(0);
        Calendar calendar = Calendar.getInstance();
        calendar.setTime(firstDate);
        int currentYear = calendar.get(Calendar.YEAR);
        return distinctMonths.stream()
                .collect(Collectors.toMap(
                        month -> month,
                        month -> YearMonth.of(currentYear, month).lengthOfMonth()
                ));
    }

    /**
     * 获取时间范围内的所有月份、去重、转成String类型
     *
     * @param timeRangeDates 时间范围集合
     * @return 月份集合
     */
    public static List<Integer> getDistinctMonths(List<Date> timeRangeDates) {
        return timeRangeDates.stream()
                .map(date -> date.toInstant().atZone(ZoneId.systemDefault()).toLocalDate().getMonthValue())
                .distinct()
                .sorted()
                .collect(Collectors.toList());
    }

    /**
     * 获取要导出数据的Map, key为时间,value为时间对应的值
     *
     * @param timeRangeDates 时间范围
     * @param times          每天定义的时间点
     * @return 导出的map
     */
    private static Map<DateTime, Float> getTemperature(List<Date> timeRangeDates, List<String> times) {
        Map<DateTime, Float> resultMap = new HashMap<>();
        for (Date timeRangeDate : timeRangeDates) {
            for (String time : times) {
                // eg: 12 ==> 2023-11-07 12:00:00
                String tempTime = DateUtil.formatDateTime(timeRangeDate).substring(0, 11) + time + ":00:00";
                DateTime recordTime = DateUtil.parse(tempTime);
                // 如果是将来的时间,就不执行查询,认为是没有数据的
                if (recordTime.isAfter(new Date())) {
                    resultMap.put(recordTime, null);
                    continue;
                }
                // 模拟从数据库拿到的温度,10.5 °C
                resultMap.put(recordTime, selectTemperature());
            }
        }
        return resultMap;
    }

    /**
     * 模拟从数据库查询数据,随机生成2到10之间的一个数字,保留一位小数
     *
     * @return 2到10之间的一个数字, 保留一位小数
     */
    private static float selectTemperature() {
        Random random = new Random();
        float minValue = 2.0f;
        float maxValue = 10.0f;
        float range = maxValue - minValue;
        float randomFloat = (random.nextFloat() * range) + minValue;
        DecimalFormat df = new DecimalFormat("#.0");
        String formattedFloat = df.format(randomFloat);
        return Float.parseFloat(formattedFloat);
    }

    /**
     * 获取起止时间内的每一天
     *
     * @param start 开始时间
     * @param end   结束时间
     * @return 每天
     */
    private static List<Date> getTimeRangeDates(Date start, Date end) {
        List<Date> dates = new ArrayList<>();
        DateTime current = DateUtil.date(start);
        DateTime endTime = DateUtil.date(end);
        while (current.isBefore(endTime)) {
            dates.add(current.toJdkDate());
            current = current.offset(DateField.DAY_OF_MONTH, 1);
        }
        return dates;
    }
}

自定义设置样式

JAVA 复制代码
package com.example.demo.handler;

import com.alibaba.excel.write.handler.CellWriteHandler;
import com.alibaba.excel.write.handler.context.CellWriteHandlerContext;
import lombok.extern.slf4j.Slf4j;
import org.apache.poi.ss.usermodel.*;

@Slf4j
public class CustomCellWriteHandler implements CellWriteHandler {

    @Override
    public void afterCellDispose(CellWriteHandlerContext context) {
        Cell cell = context.getCell();
        double numericCellValue;
        try {
            numericCellValue = cell.getNumericCellValue();
        } catch (Exception e) {
            log.error("解析到了非数值类型,直接返回");
            return;
        }
        if (numericCellValue >= 2 && numericCellValue <= 8) {
            return;
        }
        Workbook workbook = context.getWriteWorkbookHolder().getWorkbook();
        CellStyle cellStyle = workbook.createCellStyle();
        Font writeFont = workbook.createFont();
        writeFont.setColor(IndexedColors.RED.getIndex());
        writeFont.setBold(true);
        cellStyle.setFont(writeFont);
        cell.setCellStyle(cellStyle);
        context.getFirstCellData().setWriteCellStyle(null);
    }
}

运行效果如下: 9月31日有占位符,但是9月只有30日,因此31日无数据,并且数据不在2和8范围之间的数据都可以用红色字体展示

至于返回数据给前端这里就省略了,毕竟文件都能生成了,其他的都是小问题了

5. 小结

本次的设计实现了支持不同模板,按照月(月初到月末)、季度、半年的数据进行导出,初步实现了功能,但是面对更复杂的场景,例如需要支持不同模板在某一段时间的导出功能暂时没法实现。不过也算是积累了一次使用easyexcel导出报表的经验,以此来记录本次开发的过程,为后续类似功能的开发提供借鉴

代码存放链接: gitee.com/szq2021/eas... (主要是怕找不着了,先存着)

相关推荐
计算机学姐1 小时前
基于python+django+vue的影视推荐系统
开发语言·vue.js·后端·python·mysql·django·intellij-idea
JustinNeil1 小时前
简化Java对象转换:高效实现大对象的Entity、VO、DTO互转与代码优化
后端
青灯文案11 小时前
SpringBoot 项目统一 API 响应结果封装示例
java·spring boot·后端
微尘82 小时前
C语言存储类型 auto,register,static,extern
服务器·c语言·开发语言·c++·后端
计算机学姐2 小时前
基于PHP的电脑线上销售系统
开发语言·vscode·后端·mysql·编辑器·php·phpstorm
码拉松3 小时前
千万不要错过,优惠券设计与思考初探
后端·面试·架构
白总Server4 小时前
MongoDB解说
开发语言·数据库·后端·mongodb·golang·rust·php
计算机学姐4 小时前
基于python+django+vue的家居全屋定制系统
开发语言·vue.js·后端·python·django·numpy·web3.py
程序员-珍5 小时前
SpringBoot v2.6.13 整合 swagger
java·spring boot·后端
海里真的有鱼5 小时前
好文推荐-架构
后端