前言
本文思維導圖
一、需求描述
實現一個頁面上傳excel的功能,並對excel中的內容做解析,最后存儲在數據庫中。
二、代碼實現
需求實現思路:
- 先對上傳的文件做校驗和解析,這里我們通過
ExcelUtil
工具類來實現; - 解析得到的數據進行批量插入。
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中常見的幾個注解(持續更新...)
@Controller
:標識一個該類是Spring MVC controller處理器,用來創建處理http請求的對象。@PostMapping
、@GetMapping
、@RequestMapping
:三者都是用於將HTTP請求映射到特定處理程序(接口)的方法注解,其中@PostMapping
、@GetMapping
屬於組合注解,分別等效於@RequestMapping(method = RequestMethod.POST)
和@RequestMapping(method = RequestMethod.GET)
@RequestParam
:用於將請求中的參數映射到處理程序中的參數。如這里的使用方式:
在調用處,其參數名是"file"(相當於key值),而在處理程序中,file的值映射到updateFile中。@RequestParam("file") MultipartFile 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 批量插入/更新
批量插入有多種方式,思路有以下兩種:
- 在程序中循環遍歷逐條更新。
- 一次性更新所有數據(更准確的說是一條sql語句來更新所有數據,逐條更新的操作放到數據庫端,在業務代碼端展現的就是一次性更新所有數據)。
3.3.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>
注意:
- 被"trim"標簽包裹的“foreach”中不需要 “separator、open、close”屬性;
- 不能省略每句的
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