前言
本文思维导图
一、需求描述
实现一个页面上传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