实现Excel文件的上传和解析

时间:2024-01-27 13:06:51

前言

本文思维导图
Alt

一、需求描述

实现一个页面上传excel的功能,并对excel中的内容做解析,最后存储在数据库中。

二、代码实现

需求实现思路:

  1. 先对上传的文件做校验和解析,这里我们通过ExcelUtil工具类来实现;
  2. 解析得到的数据进行批量插入。

2.1 接口定义

    @PostMapping(path = "/batchMaintainBankBalance")
    @ResponseBody
    public ResultDto<Object> batchMaintainBankBalance(@RequestParam("file") MultipartFile updateFile) {
        try {
            Response response = balanceBankResultBiz.batchMaintainBankBalance(updateFile);
            if (response.isSucc()) {
                return ResultDto.success(response);
            } else {
                return ResultDto.fail(response);
            }
        } catch (Exception e) {
            logger.error("excel上传异常", e);
            return ResultDto.fail(ResultStatusEnum.FAIL.getCode(), "excel上传异常");
        }
    }

来看看这一小段代码,我们可以挖掘多少知识点

2.1.1 web中常见的几个注解(持续更新...)

  1. @Controller:标识一个该类是Spring MVC controller处理器,用来创建处理http请求的对象。
  2. @PostMapping@GetMapping@RequestMapping:三者都是用于将HTTP请求映射到特定处理程序(接口)的方法注解,其中@PostMapping@GetMapping属于组合注解,分别等效于@RequestMapping(method = RequestMethod.POST)@RequestMapping(method = RequestMethod.GET)
  3. @RequestParam:用于将请求中的参数映射到处理程序中的参数。如这里的使用方式:
    @RequestParam("file") MultipartFile updateFile
    
    在调用处,其参数名是"file"(相当于key值),而在处理程序中,file的值映射到updateFile中。

2.1.2 MultipartFile

MultipartFile是spring类型,代表HTML中form data方式上传的文件,包含二进制数据+文件名称。在这里,它就代表了上传的excel。

2.1.3 Response

public class Response<T> implements Serializable {

    private static final Boolean SUCCESS = true;
    private static final Boolean FAILED = false;

    private Boolean status;
    private T data;
    private String msg;

    public static<T>  Response<T> succ(T data) {
        return new Response<T>().setStatus(true).setData(data);
    }
    public static<T>  Response<T> succMsg(String msg) {

        return new Response<T>().setStatus(true).setMsg(msg);
    }
    public static<T>  Response<T> succ() {

        return new Response<T>().setStatus(true);
    }
    public static<T>  Response<T> failed(T data) {

        return new Response<T>().setStatus(false).setData(data);
    }
    public static<T>  Response<T> failedMsg(String msg) {

        return new Response<T>().setStatus(false).setMsg(msg);
    }
    public static<T>  Response<T> failed() {

        return new Response<T>().setStatus(false);
    }
    public static<T>  Response<T> build(Boolean status, T data) {

        return new Response<T>().setStatus(status).setData(data);
    }
    public static Boolean  isSucc(Response response) {

        if(response == null) {
            return false;
        }
        return response.getStatus();
    }
    public  Boolean  isSucc() {

        if(this == null) {
            return false;
        }
        return this.getStatus();
    }

}

Response是一个对结果的通用封装,通过泛型来允许对于存放不同类型的返回数据,是一种在程序经常用到的类。这里提一下接口中也用到的ResultDto,其实与Response结构是几乎一样的,只是该代码是实际项目中的代码,实际项目中,对于接口的通用返回类型使用了ResultDto,对于service层的返回值类型使用了Response

2.1.4 ResultStatusEnum

public enum ResultStatusEnum {
	SUCCESS("0000", "成功"), 
	FAIL("1111", "系统异常"),
	UNKNOWN("99999", "未知异常");

	private String code;
	private String message;

	ResultStatusEnum(String code, String message) {
		this.code = code;
		this.message = message;
	}

	public String getCode() {
		return code;
	}

	public String getMessage() {
		return message;
	}

	public void setMessage(String message) {
		this.message = message;
	}
}

在项目中一般会对错误或异常定义一个枚举类,以上。

2.2 文件校验

接下来我们需要对excel文件本身做一些简单的校验

public void checkExcelFile(MultipartFile multipartFile) throws Exception {
    if (null == multipartFile || multipartFile.isEmpty()) {
        throw new Exception(ResultStatusEnum.FILE_UPLOAD_ERROR.getCode(), ResultStatusEnum.FILE_UPLOAD_ERROR.getMessage());
    }
    if (!multipartFile.getOriginalFilename().endsWith(".xls") &&
            !multipartFile.getOriginalFilename().endsWith(".xlsx")) {
        logger.info(multipartFile.getOriginalFilename() + "不是excel文件");
        throw new Exception(ResultStatusEnum.FILE_UPLOAD_PARAM_ERROR.getCode(), multipartFile.getOriginalFilename() + "不支持的文件格式");
    }
    if (multipartFile.getSize() > 20 * 1024 * 1024) {
        logger.info(multipartFile.getOriginalFilename() + "[" + multipartFile.getSize() + "]超过20M");
        throw new Exception(ResultStatusEnum.FILE_UPLOAD_PARAM_ERROR.getCode(), multipartFile.getOriginalFilename() + "超过20M");
    }
}

2.3 文件解析

接下来需要将excel文件中的内容解析出来,映射为我们需要的java对象。

InputStream inputStream = null;
inputStream = updateFile.getInputStream();
// 获取excel列名
Field[] fields = new BankBalanceExcelDto().getClass().getDeclaredFields();
List<String> columnName = new ArrayList<>();
Arrays.stream(fields).forEach(field -> columnName.add(field.getName()));
List<BankBalanceExcelDto> listList = ExcelUtil.readObjectList(inputStream, BankBalanceExcelDto.class, columnName, checkExcelError);

2.3.1 BankBalanceExcelDto

BankBalanceExcelDto是与excel中的列名一一对应的一个类,excel中的数据即是映射到该类实例中。

@Getter
@Setter
@ToString
public class BankBalanceExcelDto {
    /**
     * 交易日期
     */
    @ExcelExportAnnotation(column = 0, excelHeadName = "交易日期",checkNull=true)
    private String transDateFormat;

    /**
     * 账号
     */
    @ExcelExportAnnotation(column = 1, excelHeadName = "账号",checkNull=true, checkLength = true,valueLength = 300)
    private String bankAccountNo;

2.3.2 ExcelUtil.readObjectList

该方法将inputStream按照columnName的格式映射到BankBalanceExcelDto.class,错误放入checkExcelError中。具体实现详见附录:ExcelUtil

2.4 数据落库

这一部分就是将2.3中解析得到的数据,插入/更新到数据库中存储。这里我们做的是一个批量上传的任务,下文主要讲讲几个对批量上传操作优化的思路和方法。

三、批量上传性能优化

以上实现的功能,在实际使用过程中,导入1000条数据,发现导入时间超过了20秒,这个是用户不能接受的,所以我们必须做性能优化。

3.1 性能分析工具—Spring StopWatch

StopWatch 是 Spring 自带的可用于统计程序各模块运行时间的一个类,具体使用方式很简单,如下:

import org.springframework.util.StopWatch;

// 创建一个 StopWatch 实例
StopWatch stopWatch = new StopWatch();
// 参数为 任务名
stopWatch.start("校验excel文件格式和大小”);
…校验文件格式和大小模块…
stopWatch.stop();
// 同一个监控可监控多个任务
stopWatch.start("查询库中待维护数据”);
…查询库中待维护数据…
stopWatch.stop();
//打印出所有任务的信息
logger.info(stopWatch.prettyPrint());

通过该工具,我们可以针对程序中比较慢的模块做优化,做到有的放矢,对症下药。
接下来分享几个我具体在优化时的几个思路和做法

3.2 静态数据的查询放在循环外

对于一些查询固定数据的操作,不要放到循环内,导致反复查询;或者对于同一类数据的查询,可以一次性查出总集合,放入缓存中,在for循环中可以利用lambda表达式进行高效地筛选。
如:

for (BankBalanceExcelHasRowDto balanceExcelHasRowDto : bankBalanceExcelHasRowDtos) {
    int row = balanceExcelHasRowDto.getRowNum();
    //校验所有待维护的账号&币种,登录人是否有权限
    //根据账号+币种+交易日期+待维护的状态获取 账号负责人和单据状态
    BalanceBankResult query = new BalanceBankResult();
    query.setBankAccountNo(balanceExcelHasRowDto.getBankAccountNo());
    query.setCurrency(balanceExcelHasRowDto.getCurrency());
    query.setBalanceDate(DateUtil.parseDateStr2Date(balanceExcelHasRowDto.getTransDateFormat(), DateUtil.DATE_FORMAT));
    query.setBalanceStatus(BankBalanceManagementStatusEnum.TO_MAINTAIN.getCode());
    // 根据条件查询该记录---A
    BankBalanceDetailQueryResponseExtend queryResponse = balanceBankResultMapperX.queryBankBalanceDetailByAccount(query);
    if(Objects.isNull(queryResponse)){
        return Response.failedMsg("账号:"+balanceExcelHasRowDto.getBankAccountNo() + "币种"+balanceExcelHasRowDto.getCurrency()
        +"在交易日期"+balanceExcelHasRowDto.getTransDateFormat()+"无待维护数据");
    }
}

可以看到我们在循环内做了一个条件查询(A),每一条数据都会做一次查询。我们可以优化为:在循环外将数据先一次性查询出来,当然,需要给这个查询尽量多的查询条件,不然一次查询的数据太多,也会导致查询缓慢和占用过多内存的问题。
优化后如下:

ate yesterDay = DateUtil.addDays(new Date(), -1, 0, 0, 0);
Date date = holidayServiceBiz.getPrevWorkDay();
//获取上一个工作日至上一个自然日的所有存在状态为待维护的数据 --- B
BankBalanceQueryRequestExtend query = new BankBalanceQueryRequestExtend();
query.setTransDateStart(date);
query.setTransDateEnd(yesterDay);
query.setBalanceStatus(BankBalanceManagementStatusEnum.TO_MAINTAIN.getCode());
List<BankBalanceQueryResponseExtend> queryResponseExtendList = 
for (BankBalanceExcelHasRowDto balanceExcelHasRowDto : bankBalanceExcelHasRowDtos) {
    int row = balanceExcelHasRowDto.getRowNum();
    //2.校验所有待维护的账号&币种,登录人是否有权限
    BankBalanceQueryResponseExtend queryResponse = null;
    if (CollectionUtils.isNotEmpty(queryResponseExtendList)) {
        List<BankBalanceQueryResponseExtend> bankBalanceQueryResponseExtends = queryResponseExtendList.stream().filter(b -> b.getBankAccountNo().equals(balanceExcelHasRowDto.getBankAccountNo()))
                .filter(b -> b.getCurrency().equals(balanceExcelHasRowDto.getCurrency()))
                .filter(b -> b.getBalanceDate().equals(DateUtil.parseDateStr2Date(balanceExcelHasRowDto.getTransDateFormat(), DateUtil.DATE_FORMAT)))
                .collect(Collectors.toList());
        if (CollectionUtils.isEmpty(bankBalanceQueryResponseExtends)) {
            return Response.failedMsg("账号:" + balanceExcelHasRowDto.getBankAccountNo() + "币种" + balanceExcelHasRowDto.getCurrency()
                    + "在交易日期" + balanceExcelHasRowDto.getTransDateFormat() + "无待维护数据");
        }
        queryResponse = bankBalanceQueryResponseExtends.get(0);
    }
}

以上,注释 B 部分。

3.3 批量插入/更新

批量插入有多种方式,思路有以下两种:

  1. 在程序中循环遍历逐条更新。
  2. 一次性更新所有数据(更准确的说是一条sql语句来更新所有数据,逐条更新的操作放到数据库端,在业务代码端展现的就是一次性更新所有数据)。

3.3.1 逐条更新

  1. 逐条更新方法一
    这种方式显然是最简单,也最不容易出错的,即便出错也只是影响到当条出错的数据,而且可以对每条数据都比较可控,更新失败或成功,从什么内容更新到什么内容,都可以在逻辑代码中获取。代码可能像下面这个样子:
updateBatch(List<MyData> datas){
    for(MyData data : datas){
        try{
            myDataDao.update(data);//更新一条数据,mybatis中如下面的xml文件的update
        }
        catch(Exception e){
            ...//如果更新失败可以做一些其他的操作,比如说打印出错日志等
        }
    }
}

//mybatis中update操作的实现
<update>
    update mydata
    set   ...
    where ...
</update>

这种方式最大的问题就是效率问题,逐条更新,每次都会连接数据库,然后更新,再释放连接资源。即使有数据库连接池的存在,这种损耗在数据量较大的时候也会出现效率问题。因
2. 逐条更新方法二
通过循环,依次执行多条update的sql

<update id="updateBatch"  parameterType="java.util.List">  
    <foreach collection="list" item="item" index="index" open="" close="" separator=";">
        update course
        <set>
            name=${item.name}
        </set>
        where id = ${item.id}
    </foreach>      
</update>

之所以说这也是逐条更新,是因为它只是将循环从业务代码转移到了sql中,从sql语句中我们也能看到,数据库其实是循环执行的多条update语句。一条记录update一次,性能比较差,容易造成阻塞。

3.3.2 批量插入/更新

批量更新

    <update id="updateBankBalances" parameterType="java.util.List">
        update balance_bank_result
        <trim prefix="set" suffixOverrides=",">
            <trim prefix="total_balance =case" suffix="end,">
                <foreach collection="list" item="item" index="index">
                    <if test="item.totalBalance !=null">
                        when id=#{item.id} then #{item.totalBalance}
                    </if>
                </foreach>
            </trim>
            <trim prefix="available_balance =case" suffix="end,">
                <foreach collection="list" item="item" index="index" >
                    <if test="item.availableBalance !=null">
                        when id=#{item.id} then #{item.availableBalance}
                    </if>
                </foreach>
            </trim>
            <trim prefix="version =case" suffix="end,">
                <foreach collection="list" item="item" index="index">
                    WHEN id=#{item.id} THEN version + 1
                </foreach>
            </trim>
        </trim>
        where id in
        <foreach collection="list" index="index" item="item" separator="," open="(" close=")">
            #{item.id,jdbcType=BIGINT}
        </foreach>
        AND balance_status = 1
    </update>

注意:

  1. 被"trim"标签包裹的“foreach”中不需要 “separator、open、close”属性;
  2. 不能省略每句的WHEN id=#{item.id}
    最后,我们看看批量插入的实现,相较于更新来说,更简单:

批量插入

 <insert id="batchInsertBalanceBankSynTask" useGeneratedKeys="true" keyProperty="id" keyColumn="id">
    insert into balance_bank_syn_task (business_id, syn_status, runtimes,
    bank_account_no, currency, balance_date,
    create_time, last_update_time)
    values
    <foreach item="item" collection="list" separator=",">
      (#{item.businessId,jdbcType=BIGINT}, #{item.synStatus,jdbcType=INTEGER}, #{item.runtimes,jdbcType=INTEGER},
      #{item.bankAccountNo,jdbcType=VARCHAR}, #{item.currency,jdbcType=VARCHAR}, #{item.balanceDate,jdbcType=DATE},
      #{item.createTime,jdbcType=TIMESTAMP}, #{item.lastUpdateTime,jdbcType=TIMESTAMP})
    </foreach>
  </insert>

参考文献

批量更新-https://www.cnblogs.com/eternityz/p/12284760.html

附录:
ExcelUtil