實現Excel文件的上傳和解析


前言

本文思維導圖
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


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM