Springboot+MybatisPlus+EasyExcel实现文件导入数据

记录一下写Excel文件导入数据所经历的问题。

springboot提供的文件处理MultipartFile 有关方法,我没有具体看文档,但目测比较复杂,

遂了解学习了一下别的文件上传方法,本文第1节记录的是springboot原始的导入文件方法写法,第二节大概介绍了一下EasyExcel的使用,第三节是EasyExcel的实际使用。

只看实现,请直接跳转第三节。

1 原springboot写法,不使用EasyExcel

看了一下springboot原版写法如下,大致意思是写个工具类ExcelReadUtil来处理文件内容读取和转化,ServiceImpl实现文件存储,代码量很可怕。

1.1 SalaryController

java 复制代码
    public R<?> addSalaryInfo(@RequestParam("file") MultipartFile file, @RequestParam("importTime") String importTime, @RequestParam("type") Integer type, @RequestParam("fileId") Long fileId) {
        salaryService.addSalaryInfo(file, importTime, type, fileId);
        return R.ok("操作成功");
    }

1.2 ServiceImpl

java 复制代码
    @Override
    @Transactional(rollbackFor = Throwable.class)
    public void addSalaryInfo(MultipartFile file, String importTime, Integer type, Long fileId) {
        SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM");
        if (file == null || "".equals(file.getOriginalFilename())) {
            throw new PMValidationException("上传附件为空");
        }
        if (StringUtils.isEmpty(type) || Objects.isNull(SalaryType.getByCode(type))) {
            throw new PMValidationException("薪酬类型错误");
        }
        Date importDate;
        try {
            importDate = sdf.parse(importTime);
        } catch (ParseException e) {
            throw new PMValidationException("薪酬所属月份格式错误");
        }
        long loginUserId = userRemoteService.getLoginUser().getData().getUserId();
        List<Map<String, Object>> maps = ExcelReadUtil.readExcelByRC(file, 2, -1, true);

        SalaryInfo salaryInfo = new SalaryInfo();
        salaryInfo.setImportTime(importDate);
        salaryInfo.setCreateBy(loginUserId);
        salaryInfo.setUpdateBy(loginUserId);
        salaryInfo.setType(type);
        salaryInfo.setFileId(fileId);
        salaryInfoMapper.insert(salaryInfo);
        if (SalaryType.PAYABLE_SALARY.getCode() == type) {
            insertSalary(maps, loginUserId, importDate, salaryInfo.getId());
        } else {
            insertOrUpdateOtherSalary(maps, loginUserId, type, importDate);
        }
    }
 	public void insertSalary(List<Map<String, Object>> maps, Long loginUserId, Date importDate, Long salaryInfoId) {
        List<SalaryData> salaryInfoList = new LinkedList<>();
        maps.forEach(map -> {
            String deptName = String.valueOf(map.get(SalaryField.DEPT_NAME) == null ? "" : map.get(SalaryField.DEPT_NAME));
            String nickName = String.valueOf(map.get(SalaryField.NICK_NAME) == null ? "" : map.get(SalaryField.NICK_NAME));
            String userNo = String.valueOf(map.get(SalaryField.USER_NO) == null ? "" : map.get(SalaryField.USER_NO));
            BigDecimal salary = null;
            if (map.get(SalaryField.SALARY) != null) {
                salary = new BigDecimal(String.valueOf(map.get(SalaryField.SALARY)));
            }
            BigDecimal performance = null;
            if (map.get(SalaryField.PERFORMANCE) != null) {
                performance = new BigDecimal(String.valueOf(map.get(SalaryField.PERFORMANCE)));
            }
            BigDecimal seniorityPay = null;
            if (map.get(SalaryField.SENIORITY_PAY) != null) {
                seniorityPay = new BigDecimal(String.valueOf(map.get(SalaryField.SENIORITY_PAY)));
            }
            BigDecimal postSalary = null;
            if (map.get(SalaryField.POST_SALARY) != null) {
                postSalary = new BigDecimal(String.valueOf(map.get(SalaryField.POST_SALARY)));
            }
            BigDecimal tenementSubsidy = null;
            if (map.get(SalaryField.TENEMENT_SUBSIDY) != null) {
                tenementSubsidy = new BigDecimal(String.valueOf(map.get(SalaryField.TENEMENT_SUBSIDY)));
            }
            BigDecimal communicateSubsidy = null;
            if (map.get(SalaryField.COMMUNICATE_SUBSIDY) != null) {
                communicateSubsidy = new BigDecimal(String.valueOf(map.get(SalaryField.COMMUNICATE_SUBSIDY)));
            }
            BigDecimal trafficFee = null;
            if (map.get(SalaryField.TRAFFIC_FEE) != null) {
                trafficFee = new BigDecimal(map.get(SalaryField.TRAFFIC_FEE) == null ? "" : String.valueOf(map.get(SalaryField.TRAFFIC_FEE)));
            }
            BigDecimal mealFee = null;
            if (map.get(SalaryField.MEAL_FEE) != null) {
                mealFee = new BigDecimal(String.valueOf(map.get(SalaryField.MEAL_FEE)));
            }
            BigDecimal changeFee = null;
            if (map.get(SalaryField.CHANGE_FEE) != null) {
                changeFee = new BigDecimal(String.valueOf(map.get(SalaryField.CHANGE_FEE)));
            }
            BigDecimal salaryPayable = null;
            if (map.get(SalaryField.SALARY_PAYABLE) != null) {
                salaryPayable = new BigDecimal(map.get(SalaryField.SALARY_PAYABLE) == null ? "" : String.valueOf(map.get(SalaryField.SALARY_PAYABLE)));
            }
            BigDecimal endowmentInsurance = null;
            if (map.get(SalaryField.ENDOWMENT_INSURANCE) != null) {
                endowmentInsurance = new BigDecimal(String.valueOf(map.get(SalaryField.ENDOWMENT_INSURANCE)));
            }
            BigDecimal medicalInsurance = null;
            if (map.get(SalaryField.MEDICAL_INSURANCE) != null) {
                medicalInsurance = new BigDecimal(String.valueOf(map.get(SalaryField.MEDICAL_INSURANCE)));
            }
            BigDecimal unemploymentInsurance = null;
            if (map.get(SalaryField.UNEMPLOYMENT_INSURANCE) != null) {
                unemploymentInsurance = new BigDecimal(String.valueOf(map.get(SalaryField.UNEMPLOYMENT_INSURANCE)));
            }
            BigDecimal accumulationFundDeduct = null;
            if (map.get(SalaryField.ACCUMULATION_FUND) != null) {
                accumulationFundDeduct = new BigDecimal(String.valueOf(map.get(SalaryField.ACCUMULATION_FUND)));
            }
            BigDecimal checkoff = null;
            if (map.get(SalaryField.CHECKOFF) != null) {
                checkoff = new BigDecimal(String.valueOf(map.get(SalaryField.CHECKOFF)));
            }
            BigDecimal preTaxTotal = null;
            if (map.get(SalaryField.PRE_TAX_TOTAL) != null) {
                preTaxTotal = new BigDecimal(String.valueOf(map.get(SalaryField.PRE_TAX_TOTAL)));
            }
            BigDecimal personalIncomeTax = null;
            if (map.get(SalaryField.PERSONAL_INCOME_TAX) != null) {
                personalIncomeTax = new BigDecimal(String.valueOf(map.get(SalaryField.PERSONAL_INCOME_TAX)));
            }
            BigDecimal realSalary = null;
            if (map.get(SalaryField.REAL_SALARY) != null) {
                realSalary = new BigDecimal(String.valueOf(map.get(SalaryField.REAL_SALARY)));
            }

            SalaryData salaryData = new SalaryData();
            salaryData.setSalaryInfoId(salaryInfoId);
            salaryData.setImportTime(importDate);
            salaryData.setCreateBy(loginUserId);
            salaryData.setUpdateBy(loginUserId);
            salaryData.setDeptName(deptName);
            salaryData.setNickName(nickName);
            salaryData.setUserNo(userNo);
            salaryData.setSalary(salary);
            salaryData.setPerformance(performance);
            salaryData.setSeniorityPay(seniorityPay);
            salaryData.setPostSalary(postSalary);
            salaryData.setTenementSubsidy(tenementSubsidy);
            salaryData.setCommunicateSubsidy(communicateSubsidy);
            salaryData.setTrafficFee(trafficFee);
            salaryData.setMealFee(mealFee);
            salaryData.setChangeFee(changeFee);
            salaryData.setSalaryPayable(salaryPayable);
            salaryData.setEndowmentInsurance(endowmentInsurance);
            salaryData.setMedicalInsurance(medicalInsurance);
            salaryData.setUnemploymentInsurance(unemploymentInsurance);
            salaryData.setAccumulationFundDeduct(accumulationFundDeduct);
            salaryData.setCheckoff(checkoff);
            salaryData.setPreTaxTotal(preTaxTotal);
            salaryData.setPersonalIncomeTax(personalIncomeTax);
            salaryData.setRealSalary(realSalary);
            if (!StringUtils.isEmpty(userNo)) {
                salaryInfoList.add(salaryData);
            }
        });

里面重点在ExcelReadUtil.readExcelByRC()调用了自定义工具类方法,其余类代码就不过多解释

1.3 ExcelReadUtil

这个工具类封装了主要的Excel处理方法,并将读取的文件内容转换为List<Map<String, Object>>格式的数据结构。

  1. getWorkbook方法根据文件输入流和文件类型创建相应的工作簿对象(HSSFWorkbook 或 XSSFWorkbook),以便对Excel文件进行操作。
  2. convertCellValueToString方法将Excel单元格的内容转换为字符串形式。它根据单元格类型(如数字、字符串、布尔值等)进行相应的转换处理。
  3. handleData方法处理Excel数据内容,遍历指定范围内的所有行(由StatrRow和EndRow决定),并将每一行的数据转化为一个Map,其中键为列名(可以从Excel表头获得),值为单元格内容。
  4. readExcelByRC方法是对外提供的接口,接收MultipartFile类型的文件对象以及读取Excel的起始行、结束行和是否存在表头标志。此方法首先校验参数合理性,然后通过文件输入流获取工作簿,接着调用handleData方法处理数据,并在最后确保资源正确关闭。
java 复制代码
@Slf4j
public class ExcelReadUtil {

    /**
     * 根据文件后缀名类型获取对应的工作簿对象
     * @param inputStream 读取文件的输入流
     * @param fileType    文件后缀名类型(xls或xlsx)
     * @return 包含文件数据的工作簿对象
     */
    private static Workbook getWorkbook(InputStream inputStream, String fileType) throws IOException {
        //用自带的方法新建工作薄
        Workbook workbook = WorkbookFactory.create(inputStream);
        return workbook;
    }

    //将单元格内容转换为字符串
    private static String convertCellValueToString(Cell cell) {
        if (cell == null) {
            return null;
        }
        String returnValue = null;
        switch (cell.getCellType()) {
            case NUMERIC:
                //数字
                Double doubleValue = cell.getNumericCellValue();
                // 格式化科学计数法,取一位整数,如取小数,值如0.0,取小数点后几位就写几个0
                DecimalFormat df = new DecimalFormat("0");
                returnValue = df.format(doubleValue);
                break;
            case STRING:
                //字符串
                returnValue = cell.getStringCellValue();
                break;
            case BOOLEAN:
                //布尔
                Boolean booleanValue = cell.getBooleanCellValue();
                returnValue = booleanValue.toString();
                break;
            case BLANK:
                // 空值
                break;
            case FORMULA:
                // 公式
                returnValue = cell.getCellFormula();
                break;
            case ERROR:
                // 故障
                break;
            default:
                break;
        }
        return returnValue;
    }

    /**
     * 处理Excel内容转为List<Map<String,Object>>输出
     * workbook:已连接的工作薄
     * StatrRow:读取的开始行数(默认填0,0开始,传过来是EXcel的行数值默认从1开始,这里已处理减1)
     * EndRow:读取的结束行数(填-1为全部)
     * ExistTop:是否存在头部(如存在则读取数据时会把头部拼接到对应数据,若无则为当前列数)
     */
    private static List<Map<String, Object>> handleData(Workbook workbook, int StatrRow, int EndRow, boolean ExistTop) {
        //声明返回结果集result
        List<Map<String, Object>> result = new ArrayList<>();
        //声明一个Excel头部函数
        ArrayList<String> top = new ArrayList<>();
        //解析sheet(sheet是Excel脚页)
        /**
         *此处会读取所有脚页的行数据,若只想读取指定页,不要for循环,直接给sheetNum赋值,脚页从0开始(通常情况Excel都只有一页,所以此处未进行进一步处理)
         */
        for (int sheetNum = 0; sheetNum < workbook.getNumberOfSheets(); sheetNum++) {
            Sheet sheet = workbook.getSheetAt(sheetNum);
            // 校验sheet是否合法
            if (sheet == null) {
                continue;
            }
            //如存在头部,处理头部数据
            if (ExistTop) {
                int firstRowNum = sheet.getFirstRowNum();
                Row firstRow = sheet.getRow(firstRowNum);
                if (null == firstRow) {
                    log.warn("解析Excel失败,在第一行没有读取到任何数据!");
                }
                for (int i = 0; i < firstRow.getLastCellNum(); i++) {
                    top.add(convertCellValueToString(firstRow.getCell(i)));
                }
            }
            //处理Excel数据内容
            int endRowNum;
            //获取结束行数
            if (EndRow == -1) {
                endRowNum = sheet.getPhysicalNumberOfRows();
            } else {
                endRowNum = EndRow <= sheet.getPhysicalNumberOfRows() ? EndRow : sheet.getPhysicalNumberOfRows();
            }
            //遍历行数
            for (int i = StatrRow - 1; i < endRowNum; i++) {
                Row row = sheet.getRow(i);
                if (null == row) {
                    continue;
                }
                Map<String, Object> map = new HashMap<>();
                //获取所有列数据
                for (int y = 0; y < row.getLastCellNum(); y++) {
                    if (top.size() > 0) {
                        if (top.size() >= y) {
                            map.put(top.get(y), convertCellValueToString(row.getCell(y)));
                        } else {
                            map.put(String.valueOf(y + 1), convertCellValueToString(row.getCell(y)));
                        }
                    } else {
                        map.put(String.valueOf(y + 1), convertCellValueToString(row.getCell(y)));
                    }
                }
                result.add(map);
            }
        }
        return result;
    }

    /**
     * 根据行数和列数读取Excel
     * fileName:Excel文件路径
     * startRow:读取的开始行数(默认填0)
     * endRow:读取的结束行数(填-1为全部)
     * existTop:是否存在头部(如存在则读取数据时会把头部拼接到对应数据,若无则为当前列数)
     * 返回一个List<Map<String,Object>>
     */
    public static List<Map<String, Object>> readExcelByRC(MultipartFile file, int startRow, int endRow, boolean existTop) {
        //判断输入的开始值是否少于等于结束值e
        if (startRow > endRow && endRow != -1) {
            log.warn("输入的开始行值比结束行值大,请重新输入正确的行数");
            return null;
        }
        //声明返回的结果集
        List<Map<String, Object>> result = new ArrayList<>();
        //声明一个工作薄
        Workbook workbook = null;
        //声明一个文件输入流
        FileInputStream inputStream = null;
        try {
            // 获取Excel后缀名,判断文件类型
            String fileType = file.getOriginalFilename();
            // 获取Excel工作簿
            inputStream = (FileInputStream) file.getInputStream();
            workbook = getWorkbook(inputStream, fileType);
            //处理Excel内容
            result = handleData(workbook, startRow, endRow, existTop);
        } catch (Exception e) {
            log.warn("解析Excel失败,文件名:" + file.getName() + " 错误信息:" + e.getMessage());
        } finally {
            try {
                if (null != workbook) {
                    workbook.close();
                }
                if (null != inputStream) {
                    inputStream.close();
                }
            } catch (Exception e) {
                log.warn("关闭数据流出错!错误信息:" + e.getMessage());
                return null;
            }
        }
        return result;
    }
}

2 EasyExcel

参考EasyExcel官方文档,对EasyExcel的介绍如下

浅略看了一下文档,EasyExcel直接实现了前面涉及的文件读取,文件内容转化,省略了很多代码。而且使用门槛非常低,最简单的实现甚至不需要写其他代码,主要涉及的方法有

①注解绑定列字段 ②内容转化Convert ③监听器处理内容读取 ④EasyExcel.read()使用

2.1 常用方法

读取 Excel 文件的方法:

read:读取 Excel 文件的入口方法,用于读取 Excel 文件中的数据。

head:指定 Excel 文件的头部信息,即指定 Excel 文件中数据的起始行,默认为第一行。

registerReadListener:注册读取监听器,用于处理 Excel 文件读取过程中的事件。

sheet:指定要读取的 Excel 文件的 sheet,可以通过索引或者 sheet 名称来指定。

doRead:执行读取操作,开始读取 Excel 文件中的数据。

写入 Excel 文件的方法:

write:写入 Excel 文件的入口方法,用于创建 Excel 文件并写入数据。

head:指定 Excel 文件的头部信息,即指定 Excel 文件中数据的起始行,默认为第一行。

sheet:指定要写入的 Excel 文件的 sheet,可以通过索引或者 sheet 名称来指定。

doWrite:执行写入操作,将数据写入到 Excel 文件中。

其他常用方法:

finish:完成 Excel 文件的读写操作,释放资源。

withXXX:一些配置方法,如 withTemplate、withEncrypt 等,用于指定模板文件、加密等。

2.2 添加依赖

使用EasyExcel,第一件事是在pom.xml添加依赖:

xml 复制代码
 <dependency>
       <groupId>com.alibaba</groupId>
       <artifactId>easyexcel</artifactId>
       <version>3.1.1</version>
 </dependency>

2.3 文档内容对象

要获取Excel中文档列名对应的对象属性,在前面的做法是在Service中将读取到的内容一一和实体类属性绑定,可以跳回1.2节看看以前的写法有多繁琐。EasyExcel这里直接提供了一个注解@ExcelProperty,加在实体类属性上直接实现了绑定属性,例如

java 复制代码
@Data
@TableName("budget_info")
public class BudgetUploadPO {
    @TableId(value = "id", type = IdType.AUTO)
    @ExcelIgnore
    //会在文档中忽略字段id
    private String id;

    @ExcelProperty
    //会默认绑定Excel中列名"projectCode"
    private String projectCode;

    @ExcelProperty("项目名称")
    //绑定列名"项目名称"
    private String projectName;

    @ExcelProperty("预算金额")
    private String budgetNum;
}

具有的属性如下:

value:指定 Excel 列的标题。默认值为 "",表示将使用 Java 对象属性名作为 Excel 列标题。

index:指定 Excel 列的索引,即列的位置。默认值为 -1,表示按照 value 指定的标题进行匹配。当 Excel 文件中的列标题与 Java 对象的属性名不匹配时,可以使用 index 属性指定列的位置。

converter:指定数据转换器,用于将 Excel 文件中的数据转换为 Java 对象属性的类型。默认值为 DefaultConverter.class,表示使用 EasyExcel 默认的转换器。可以自定义转换器,比如把文档中的"性别"男女转化为"0""1"存数据库。

format:指定 Excel 列的格式。默认值为 "",表示不指定格式。可以在这里指定日期格式、数字格式等。

use1904windowing:是否使用 Excel 1904 窗口模式。默认值为 false。当日期使用 1904 窗口模式时,Excel 使用 1904 年 1 月 1 日作为第一个日期。如果 Excel 文件中使用了 1904 窗口模式,需要设置此属性为 true。

2.4 最简单的使用(同步读取)

下面的代码中EasyExcel.read() 是EasyExcel最常用的方法,其中 doReadSync() 表示开启同步读取,同步异步机制 就不过多赘述,总之调用该方法时将等待该任务完成才能执行其他任务,所以不推荐使用同步读取,当读取文件较小的时候可以这样做,可以省掉写监听器。

java 复制代码
// 将读取到的Excel内容转为List,接下来只需要对数据执行需要的操作
  List<DataEntity> list = EasyExcel.read(inputStream).head(DataEntity.class).sheet().doReadSync();
// 或也可以不指定class,返回一个list,返回每条数据的键值对 表示所在的列 和所在列的值
  List<Map<Integer, String>> listMap = EasyExcel.read(inputStream).sheet().doReadSync();

2.5 监听器(异步读取)

先解释一下同步异步读取的区别:

2.5.1 同步读取和异步读取的区别
  1. 同步读取使用doReadSync(),异步使用doRead()
  2. 同步读取需要new数据对象来接收数据,以及另行处理数据
  3. 异步读取需要new监听器对象,对数据的处理会在监听器方法中实现,可选不接收数据

当异步调用EasyExcel.read()方法时,对读取到内容的操作是通过监听器来处理的,可以是自己声明的监听器类或是在方法中实现一个内部类,例如:

2.5.2 自定义监听器类

自定义监听类DemoDataListener,实现EasyExcel提供的接口 ReadListener< T > ,重写invoke()和doAfterAllAnalysed()方法

这两个监听器方法分别会在每条数据解析时和所有数据解析完成时调用

java 复制代码
// 有个很重要的点 DemoDataListener 不能被spring管理,要每次读取excel都要new,然后里面用到spring可以构造方法传进去
@Slf4j
public class DemoDataListener implements ReadListener<DemoData> {
    // 每隔100条存储数据库,实际使用中可以1000条,然后清理list ,方便内存回收
    private static final int BATCH_COUNT = 100;
    // 缓存的数据
    private List<DemoData> cachedDataList = ListUtils.newArrayListWithExpectedSize(BATCH_COUNT);
    // 假设这个是一个DAO,当然有业务逻辑这个也可以是一个service。当然如果不用存储这个对象没用。
    private DemoDAO demoDAO;

    public DemoDataListener() {
        // 这里是demo,所以随便new一个。实际使用如果到了spring,请使用下面的有参构造函数
        demoDAO = new DemoDAO();
    }
    
    //如果使用了spring,请使用这个构造方法。每次创建Listener的时候需要把spring管理的类传进来
    public DemoDataListener(DemoDAO demoDAO) {
        this.demoDAO = demoDAO;
    }

    //每一条数据解析都会来调用
    @Override
    public void invoke(DemoData data, AnalysisContext context) {
        log.info("解析到一条数据:{}", JSON.toJSONString(data));
        cachedDataList.add(data);
        // 达到BATCH_COUNT了,需要去存储一次数据库,防止数据几万条数据在内存,容易OOM
        if (cachedDataList.size() >= BATCH_COUNT) {
            saveData();
            // 一批存储完成后清理list,再存下一批
            cachedDataList = ListUtils.newArrayListWithExpectedSize(BATCH_COUNT);
        }
    }

    // 所有数据解析完成了 都会来调用
    @Override
    public void doAfterAllAnalysed(AnalysisContext context) {
        // 这里也要保存数据,确保最后遗留的数据也存储到数据库
        saveData();
        log.info("所有数据解析完成!");
    }

    //存储数据库的方法,需要自己去demoDAO实现save()方法
    private void saveData() {
        log.info("{}条数据,开始存储数据库!", cachedDataList.size());
        demoDAO.save(cachedDataList);
        log.info("存储数据库成功!");
    }
}

调用EasyExcel.read(文件流,对象类,监听器实例)直接往read()中传参

java 复制代码
	EasyExcel.read(inputStream, DataDemo.class, new DemoDataListener())
			.sheet()
			.doRead(); 
		
== 注意:
	1. 异步必须是doRead()
	2. DemoDataListener会根据自己方法自动处理数据
2.5.3 内部监听器类

当逻辑较少时也可以不自行实现监听器,在方法内部类实现逻辑,例如以下2种写法

java 复制代码
    /**
     * 最简单的读
     * 1. 创建excel对应的实体对象 
     * 2. 由于默认一行行的读取excel,所以需要创建excel一行一行的回调监听器,参照DemoDataListener
     * 3. 直接读即可
     */
    @Test
    public void simpleRead() {
        // 写法1:JDK8+ ,不用额外写一个DemoDataListener
        // since: 3.0.0-beta1
        // 这里默认每次会读取100条数据 然后返回过来 直接调用使用数据就行
        // 具体需要返回多少行可以在`PageReadListener`的构造函数设置
        EasyExcel.read(inputStream, DemoData.class, new PageReadListener<DemoData>(dataList -> {
            for (DemoData demoData : dataList) {
                log.info("读取到一条数据{}", JSON.toJSONString(demoData));
            }
        })).sheet().doRead();
java 复制代码
        // 写法2:
        // 匿名内部类 不用额外写一个DemoDataListener
        // 这里 需要指定读用哪个实体类class去读,然后读取第一个sheet 文件流会自动关闭
        EasyExcel.read(inputStream, DemoData.class, new ReadListener<DemoData>() {
            //单次缓存的数据量
            public static final int BATCH_COUNT = 100;
            //临时存储到List
            private List<DemoData> cachedDataList = ListUtils.newArrayListWithExpectedSize(BATCH_COUNT);
            @Override
            public void invoke(DemoData data, AnalysisContext context) {
                cachedDataList.add(data);
                if (cachedDataList.size() >= BATCH_COUNT) {
                    saveData();
                    // 存储完成清理 list
                    cachedDataList = ListUtils.newArrayListWithExpectedSize(BATCH_COUNT);
                }
            }
            @Override
            public void doAfterAllAnalysed(AnalysisContext context) {
                saveData();
            }
            private void saveData() {
                //自行实现存储数据库方法
                log.info("{}条数据,开始存储数据库!", cachedDataList.size());
                log.info("存储数据库成功!");
            }
        }).sheet().doRead();
    }

2.6 EasyExcel总结

EasyExcel提供的支持还有很多,可以自行看一下官方文档

在此总结一下读取文件的流程

  • 在实体类字段上用@ExcelProperty来绑定,对于Excel文件不存在的内容,如id,使用@ExcelIgnore忽略
  • 根据数据量决定同步读取还是异步读取,同步读取可以省略监听器类,自行处理获取的数据
  • 异步读取需要监听器对象,可以自定义实现类或是匿名内部类,在监听器可以实现数据处理
  • 以上不管是同步还是异步读取,所调用的保存方法都要自行在DAO实现

3 批量保存方法

3.1基于Mybatis的批量保存

上面各种数据处理,最终都涉及到了有关批量保存的方法,批量保存速度是比单条插入时间复杂度要低的,如前方官方文档里说的是自行在DAO实现一个批量保存方法,提供给监听器调用。但我也思考如何使用MybatisP去实现,降低代码耦合性。

参考这个前辈的文章

他是这样的思路:实体类PersonPO就不说了

  1. BatchInsertMapper< T >
    声明一个含批量插入方法的Mapper接口
java 复制代码
//批量插入的Mapper, 用xml配置文件自定义批量插入,避免MyBatis的逐条插入降低性能
public interface BatchInsertMapper<T> {
    void batchInsert(List<T> list);
}
  1. PersonMapper
    PersonPO类对应的Mapper在继承BaseMapper的同时,也继承BatchInsertMapper接口,需要实现批量插入方法
java 复制代码
//(Person)表数据库访问层
@Mapper
public interface PersonMapper extends BaseMapper<PersonPO>, BatchInsertMapper<PersonPO> {
}
  1. PersonMapper.xml
    自己写SQL语句实现批量插入方法
xml 复制代码
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.spring.accumulator.dao.PersonMapper">
    <insert id="batchInsert" parameterType="list">
        insert into wangrubin_db.person
        (name, age, male)
        values
        <foreach collection="list" item="item" index="index" separator=",">
            (
            #{item.name},
            #{item.age},
            #{item.male}
            )
        </foreach>
    </insert>
</mapper>

把监听类抽象出来

  1. ExcelImportListener抽象监听器
java 复制代码
@Slf4j
public abstract class ExcelImportListener<T> implements ReadListener<T> {
    private static final int BATCH_SIZE = 100;
    private List<T> cacheList = new ArrayList<>(BATCH_SIZE);

    @Override
    public void invoke(T po, AnalysisContext analysisContext) {
        cacheList.add(po);
        if (cacheList.size() >= BATCH_SIZE) {
            log.info("完成一批Excel记录的导入,条数为:{}", cacheList.size());
            getMapper().batchInsert(cacheList);
            cacheList = new ArrayList<>(BATCH_SIZE);
        }
    }

    @Override
    public void doAfterAllAnalysed(AnalysisContext analysisContext) {
        getMapper().batchInsert(cacheList);
        log.info("完成最后一批Excel记录的导入,条数为:{}", cacheList.size());
    }

    //获取批量插入的Mapper
    protected abstract BatchInsertMapper<T> getMapper();
}

定义一个组件类,里面写了一个ExcelImportListener 的子类匿名内部类。重写了 getMapper() 方法,并返回了 personMapper 对象。

  1. ExcelComponent组件
java 复制代码
@Slf4j
@Component
public class ExcelComponent {

    @Resource
    private PersonMapper personMapper;

    // Excel文件分批导入数据库
    public void importPersonFile(MultipartFile file) throws IOException {
        EasyExcel.read(file.getInputStream())
                .head(PersonPO.class)
                .registerReadListener(new ExcelImportListener<PersonPO>() {
                    @Override
                    protected BatchInsertMapper<PersonPO> getMapper() {
                        return personMapper;
                    }
                }).sheet().doRead();
    }
}
  1. ImportController
    最后在Controller注入组件调用方法就行
java 复制代码
@RestController
@RequestMapping("/excel")
public class ImportController {
    @Resource
    private ExcelComponent excelComponent;

    @PostMapping("/import-person")
    public void importPersonFile(@RequestParam("file") MultipartFile file) throws IOException {
        excelComponent.importPersonFile(file);
    }

他的设计还是很有亮点,回调监听器体现了策略模式,监听器方法构成了一个模板方法模式,这些方法定义了算法的骨架,具体的步骤由子类实现。

基于抽象与接口BatchInsertMapper、ExcelComponent、ExcelImportListener,可以实现业务代码进一步拆分与模块化。如果需要再添加其他对象的上传,只需要

1.对应实体类

2.对应Mapper继承BaseMapper和BatchInsertMapper< T >

3.对应Mapper.xml批量插入语句

4.ExcelComponent增加注入对应Mapper,实现对应importXXXFile方法

5.Controller逻辑

3.2 基于MybatisPlus的批量保存

前面3.1提到了别人的写法,声明了抽象的监听器类和一个component,这样的封装可以降低代码耦合和复写,但是有个核心问题,以后有导入文件需求的所有类都要自己在Mapper实现批量插入方法。

他的监听器关键代码:

java 复制代码
    @Override
    public void invoke(T po, AnalysisContext analysisContext) {
        cacheList.add(po);
        if (cacheList.size() >= BATCH_SIZE) {
        //调用了Mapper的批量插入方法
            getMapper().batchInsert(cacheList);
            cacheList = new ArrayList<>(BATCH_SIZE);
        }
    }

    //获取批量插入的Mapper,这个Mapper需要实现batchInsert(cacheList)方法
    protected abstract BatchInsertMapper<T> getMapper();

我看了一下,不管是Mybatis还是MybatisPlus,其提供的BaseMapper里面是没有批量插入方法的。

但是,IService里面有实现saveBatch方法 ,需要接收一个List和一个size(size非必须)

我思考是否可以把监听器改为获取一个IService类型的service ,然后就能调用saveBatch()方法了呢?

java 复制代码
   	//移除 protected abstract BatchInsertMapper<T> getMapper();
   	
	//获取service
    private final IService<T> service;

    public ExcelImportListener(IService<T> service) {
        this.service = service;
    }

再修改Component类

java 复制代码
//    @Resource
//    private PersonMapper personMapper;
//
//    // Excel文件分批导入数据库
//    public void importPersonFile(MultipartFile file) throws IOException {
//        EasyExcel.read(file.getInputStream())
//            .head(PersonPO.class)
//            .registerReadListener(new ExcelImportListener<PersonPO>() {
//                @Override
//                protected BatchInsertMapper<PersonPO> getMapper() {
//                    return personMapper;
//                }
//            }).sheet().doRead();
//    }

    public void importExcel(MultipartFile file, Class<T> clazz, IService<T> service ) throws IOException {
        EasyExcel.read(file.getInputStream())
            .head(clazz)
            .registerReadListener(new ExcelImportListener<>(service))
            .sheet()
            .doRead();
    }

这样就能在不同的controller调用component,指定不同的实体类和Service,且不需要再自行实现Mapper方法

最终代码

代码经验证导入excel到数据库有效,但我认为在泛型应用和异常处理上还存在问题,希望有人指点。

以下是全部代码:

数据库表

entity.BudgetUploadPO

java 复制代码
@Data
@TableName("project_budget_info")
public class BudgetUploadPO {
    @TableId(value = "id", type = IdType.AUTO)
    @ExcelIgnore
    private String id;

    @ExcelProperty("")
    private String projectCode;

    @ExcelProperty("项目名称")
    private String projectName;

    @ExcelProperty("预算金额")
    private String budgetNum;
}

component.ExcelComponent

java 复制代码
@Slf4j
@Component
public class ExcelComponent<T> {
    public void importExcel(MultipartFile file, Class<T> clazz, IService<T> service ) throws IOException {
        EasyExcel.read(file.getInputStream())
            .head(clazz)
            .registerReadListener(new ExcelImportListener<>(service))
            .sheet()
            .doRead();
    }
}

controller.BudgetUploadController

java 复制代码
@RestController
@RequestMapping("/budget")
public class BudgetUploadController {

    @Autowired
    ExcelComponent<BudgetUploadPO> excelComponent;

    @Autowired
    private BudgetUploadService budgetUploadService;

    @Operation(description = "上传文件")
    @PostMapping("/upload")
    public void uploadExcel2(@RequestParam("file") MultipartFile file) throws IOException {
        excelComponent.importExcel(file, BudgetUploadPO.class, budgetUploadService);
    }
}

listener.ExcelImportListener

java 复制代码
@Slf4j
public class ExcelImportListener<T> implements ReadListener<T> {

    private static final int BATCH_SIZE = 100;
    
    private List<T> cacheList = new ArrayList<>(BATCH_SIZE);
    
    private final IService<T> service;

    public ExcelImportListener(IService<T> service) {
        this.service = service;
    }
    @Override
    public void invoke(T po, AnalysisContext analysisContext) {
        cacheList.add(po);
        if (cacheList.size() >= BATCH_SIZE) {
            log.info("完成一批Excel记录的导入,条数为:{}", cacheList.size());
            service.saveBatch(cacheList);
            cacheList = new ArrayList<>(BATCH_SIZE);
        }
    }
    @Override
    public void doAfterAllAnalysed(AnalysisContext analysisContext) {
            service.saveBatch(cacheList);
        log.info("完成最后一批Excel记录的导入,条数为:{}", cacheList.size());
    }
}

mapper.BudgetUploadMapper

java 复制代码
@Mapper
public interface BudgetUploadMapper extends BaseMapper<BudgetUploadPO>{
}

service.BudgetUploadService

java 复制代码
public interface BudgetUploadService extends IService<BudgetUploadPO> {
}

service.impl.BudgetUploadUploadServiceImpl

java 复制代码
@Service
public class BudgetUploadUploadServiceImpl extends ServiceImpl<BudgetUploadMapper, BudgetUploadPO> implements BudgetUploadService {
}

若要增加其他实体的上传方法,在对应controller调用component类方法,以同样的方式指定对应实体类和Service即可。

相关推荐
Code_流苏2 分钟前
VSCode搭建Java开发环境 2024保姆级安装教程(Java环境搭建+VSCode安装+运行测试+背景图设置)
java·ide·vscode·搭建·java开发环境
良许Linux4 分钟前
0.96寸OLED显示屏详解
linux·服务器·后端·互联网
求知若饥16 分钟前
NestJS 项目实战-权限管理系统开发(六)
后端·node.js·nestjs
禁默1 小时前
深入浅出:AWT的基本组件及其应用
java·开发语言·界面编程
Cachel wood1 小时前
python round四舍五入和decimal库精确四舍五入
java·linux·前端·数据库·vue.js·python·前端框架
Code哈哈笑1 小时前
【Java 学习】深度剖析Java多态:从向上转型到向下转型,解锁动态绑定的奥秘,让代码更优雅灵活
java·开发语言·学习
gb42152871 小时前
springboot中Jackson库和jsonpath库的区别和联系。
java·spring boot·后端
程序猿进阶1 小时前
深入解析 Spring WebFlux:原理与应用
java·开发语言·后端·spring·面试·架构·springboot
zfoo-framework1 小时前
【jenkins插件】
java