数据导入导出是应用系统常见需求,而excel是主要的辅助工具,特别是数据导入。数据导出场景excel用的也相对最多,其他导出格式,如word、pdf,通常用于线下打印或电子传递,用报表工具处理更多一些。
java领域操作excel组件库主要是EasyPoi,功能比较齐备,但是用起来比较繁琐,需要额外做一些定制化开发工作,并且据说存在若干BUG以及性能问题。
阿里在EasyPoi基础上做了二次封装和优化,推出了EasyExcel开源项目。
官网:https://easyexcel.opensource.alibaba.com/docs/current/
以下资料来源于easyexcel官网
Java解析、生成Excel比较有名的框架有Apache poi、jxl。但他们都存在一个严重的问题就是非常的耗内存,poi有一套SAX模式的API可以一定程度的解决一些内存溢出的问题,但POI还是有一些缺陷,比如07版Excel解压缩以及解压后存储都是在内存中完成的,内存消耗依然很大。
easyexcel重写了poi对07版Excel的解析,一个3M的excel用POI sax解析依然需要100M左右内存,改用easyexcel可以降低到几M,并且再大的excel也不会出现内存溢出;03版依赖POI的sax模式,在上层做了模型转换的封装,让使用者更加简单方便
16M内存23秒读取75M(46W行25列)的Excel(3.2.1+版本)
当然还有极速模式能更快,但是内存占用会在100M多一点
1.首先必须有模板,直接读写excel,表格样式很难看
2.导入功能需要进行数据验证
3.需要数据转换,导入时将文本转换成编码、标识,导出时将编码转换为文本
从功能角度考虑,这的确是两个动作。但从业务角度考虑,主要目的是导入数据,上传文件只是辅助工作或者其中1个步骤,数据导入失败往往是因为数据存在问题,需要修改后重新上传,如拆分为两个动作,则需要临时存储以及及时清理。从用户体验角度,点击导入按钮,选择excel文件,开始上传,并导入数据,更直观方便。
理论上,excel导入数据与用户通过表单录入数据是类似的,但实际上还是有差异,表单录入有控件支撑,例如数据字典,传给后台的直接就是编码,而不是名称。而在excel中,用户输入的通常只能是名称,系统需要将名称转换为编码后存库。
为方便数据转换,有一些变通的作法,例如在数据导入的excel模板上预处理,提前设置好下拉列表,将编码和名称的拼接作为下拉项(如:用户状态表示为“正常 | NORMAL”),处理时直接截取即可,这种方式的优点是不需要做额外的查询和转换,适用于数据字典和少数据量的平铺型基础数据,缺点是需要进行预处理以及与系统同步。例如,系统运维人员对数据字典或基础数据进行了调整,就需要同步调整excel模板,繁琐易出错,也容易发生因忘记调整导致的不一致问题。
并且,该方案对于以下两种情况,难以实现:
1.对于树形结构,如导入用户时设置组织机构,下拉选择项一方面较多,难以选择,另一方面也无法直观体现出树形层次来。
2.大数据量,如物料,几百几千甚至上万,不适合将数据库中的数据在excel中再建立副本用于选择。
综合考虑以上因素,最终不采用excel模板预处理的方案,而是在导入数据后,存库前,由平台进行数据转换工作。
首先要说的是,excel导入数据,同样需要进行严格的数据验证,这是一个容易忽视的点。见过不少系统,通过系统表单录入进行重重验证,而在数据导入环节则放松,导致有问题的数据进入了系统,引发了后续一系列问题。最常见的就是某个字段不能为空,导入时不做验证,后续使用数据的环节,出现了空指针。还有一种情况,是导入的数据,出现了非预期的值,跟后端的数据字典或基础数据对应不上,后果也是显而易见的。
再说说实现方案。对于表单验证,用Hibernate validator组件实现的,具体是在vo对象上加注解的模式,这种模式简单灵活,具备通用性。excel导入复用该技术方案。EasyExcel功能组件,可以将读取到的excel行记录,映射为一个java的对象,然后自己来调用Hibernate validator组件触发验证就行了。
接下来面临的问题是,原来为表单录入实现的vo对象,是否也可以复用?粗略一想,应该复用,深入考虑,有问题。表单录入有UI控件处理和转换,Excel导入只能是原生的文本。例如,对于组织机构来说,机构类型这个字段,表单录入映射的是type字段,前端经过UI控件处理,返回的直接的就是编码COMPANY(公司)或DEPARTMENT(部门)等,后端vo验证的是type属性不是为空。而Excel输入的是类型一栏只能是公司/部门这样的中文描述,映射到了typeName字段。即相当于对于表单录入,需要验证type不能为空;对于Excel导入,需要验证typeName不能为空,这是有差异的。
对于这种差异性,实际Hibernate validator也考虑到了,有对应的解决方案,即进行验证分组。写法上比较奇怪,在类内部定义两个空接口,接口名作为组名,然后在验证注解的groups属性中指定需要采用哪个分组或哪些个分组,在早期的平台研发版本,采用过这种方案,示例代码如下:
package tech.popsoft.platform.core.modules.system.vo;
import com.alibaba.excel.annotation.ExcelIgnore;
import com.alibaba.excel.annotation.ExcelProperty;
import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import lombok.Data;
import lombok.EqualsAndHashCode;
import tech.popsoft.platform.common.base.BaseVO;
import javax.validation.constraints.NotBlank;
/**
* 组织机构 视图对象
* @author wqliu
*/
@Data
@EqualsAndHashCode(callSuper = true)
@ApiModel(value = "Organization对象", description = "组织机构")
public class OrganizationVO extends BaseVO {
private static final long serialVersionUID = 1L;
/**
* 表单录入
*/
public interface FormInput{
}
/**
* excel导入
*/
public interface ExcelImport{
}
@ApiModelProperty(value = "父标识")
private String parentId;
@ApiModelProperty(value = "名称")
@NotBlank(message = "名称不能为空",groups = {tech.popsoft.platform.core.modules.system.vo.UserVO.FormInput.class, tech.popsoft.platform.core.modules.system.vo.UserVO.ExcelImport.class})
@ExcelProperty("名称")
private String name;
@ApiModelProperty(value = "编码")
@ExcelProperty("编码")
private String code;
@ApiModelProperty(value = "类型")
@NotBlank(message = "请选择类型",groups = {tech.popsoft.platform.core.modules.system.vo.UserVO.FormInput.class})
@ExcelIgnore
private String type;
@ApiModelProperty(value = "备注")
@ExcelProperty("备注")
private String remark;
@ApiModelProperty(value = "状态")
@ExcelIgnore
private String status;
@ApiModelProperty(value = "排序号")
@ExcelProperty("排序号")
private String orderNo;
@ApiModelProperty(value = "类型名称")
@NotBlank(message = "类型不能为空",groups = {tech.popsoft.platform.core.modules.system.vo.UserVO.ExcelImport.class})
@ExcelProperty("类型")
private String typeName;
@ApiModelProperty(value = "状态名称")
@NotBlank(message = "状态不能为空",groups = {tech.popsoft.platform.core.modules.system.vo.UserVO.ExcelImport.class})
@ExcelProperty("状态")
private String statusName;
@ApiModelProperty(value = "上级名称")
@NotBlank(message = "上级不能为空",groups = {tech.popsoft.platform.core.modules.system.vo.UserVO.ExcelImport.class})
@ExcelProperty("上级名称")
private String parentName;
@ApiModelProperty(value = "忽略上级")
@ExcelIgnore
private Boolean ignoreParent;
}
像下面这样定义分组名。
/**
* 表单录入
*/
public interface FormInput{
}
/**
* excel导入
*/
public interface ExcelImport{
}
在属性注解的groups属性中设置该字段要分到哪个/哪些组中。
@NotBlank(message = "名称不能为空",groups = {tech.popsoft.platform.core.modules.system.vo.UserVO.FormInput.class, tech.popsoft.platform.core.modules.system.vo.UserVO.ExcelImport.class})
@ExcelProperty("名称")
private String name;
@ApiModelProperty(value = "类型")
@NotBlank(message = "请选择类型",groups = {tech.popsoft.platform.core.modules.system.vo.UserVO.FormInput.class})
@ExcelIgnore
private String type;
@ApiModelProperty(value = "类型名称")
@NotBlank(message = "类型不能为空",groups = {tech.popsoft.platform.core.modules.system.vo.UserVO.ExcelImport.class})
@ExcelProperty("类型")
private String typeName;
在需要进行数据验证的地方,如controller中,需要指定验证分组。
/**
* 新增
*/
@ApiOperation(value = "新增")
@PostMapping("/")
@SystemLog(value = "组织机构-新增")
@PreAuthorize("hasPermission(null,'system:organization:add')")
public ResponseEntity<Result> add(@Validated(OrganizationVO.FormInput.class) @RequestBody OrganizationVO vo) {
Organization entity = convert2Entity(vo);
organizationService.add(entity);
OrganizationVO newVO = convert2VO(entity);
return ResultUtil.success(newVO);
}
在excel导入时,将分组也一并指定
@PostMapping("/importExcel")
@Override
@PreAuthorize("hasPermission(null,'system:organization:import')")
@SystemLog(value = "导入excel", logRequestParam = false)
public ResponseEntity<Result> importExcel(MultipartFile file) {
SimpleReadDataListener readListener = new SimpleReadDataListener<OrganizationVO, Organization, OrganizationVO.ExcelImport>(organizationService) {
@Override
public Organization convertData(OrganizationVO vo) {
return convert2EntityForExcel(vo);
}
};
super.setReadListener(readListener);
return super.importExcel(file);
}
现在来看,建验证分组,复用vo对象的方式其实并不好,违反单一职责和开闭原则,特别是在系统只有少量功能才有Excel导入需求的情况下,再叠加低代码开发的代码生成因素,更适合分离。
即保持原VO不变,作为系统前后端交互的数据对象。
如该功能需要Excel导入,则新建一个VO,如OrganizationForImportVO,在其中定义导入相关的专有列,相对表单输入和Excel导入公用一个VO的情况,更优雅。
与Excel导入有关的新方法需要放到controller层,如下载模板、上传文件、导入数据、数据转换等,虽然可以在相应的实体类中追加,但存在的问题跟上面说的复用VO对象类似。更优雅的方案,是将这部分功能拆出来单独实现。即新建一个OrganizationExcelImportExtensionController,暴露的接口、权限控制编码与OrganizationController保持一致,这样做对前端而言是透明的,无感的,对于后端则是进行了功能分离。
而且,这部分功能同样有共性部分,将这部分共性抽离出来,实现一个父类ExcelImportExtension,来实现复用,同样是基于模板方法设计模式思想的实践。
先来说说整体工作,然后分别介绍下导入和导出。
第一步,当然是引入依赖,这里选的是最新版本,2023年5月6日更新的,可以说是新鲜出炉了~
在platform-common模块的pom文件中添加如下依赖。
<dependency>
<groupId>com.alibabagroupId>
<artifactId>easyexcelartifactId>
<version>3.3.1version>
dependency>
创建包extension,以及ExcelImportExtension类,用于公共处理
package tech.abc.platform.common.extension;
import com.alibaba.excel.EasyExcel;
import org.apache.commons.io.IOUtils;
import org.springframework.core.io.ClassPathResource;
import org.springframework.http.ResponseEntity;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.multipart.MultipartFile;
import tech.abc.platform.common.base.BaseController;
import tech.abc.platform.common.component.easyexcel.ExcelExceptionEnum;
import tech.abc.platform.common.component.easyexcel.ReadDataListener;
import tech.abc.platform.common.exception.CustomException;
import tech.abc.platform.common.exception.FileException;
import tech.abc.platform.common.utils.ResultUtil;
import tech.abc.platform.common.vo.Result;
import javax.servlet.http.HttpServletResponse;
import java.io.InputStream;
import java.io.OutputStream;
import java.lang.reflect.ParameterizedType;
import java.net.URLEncoder;
/**
* excel导入功能扩展
*
* @author wqliu
* @date 2023-05-17
*/
public class ExcelImportExtension<V, E> extends BaseController {
/**
* 数据监听器
*/
private ReadDataListener readListener;
/**
* 导入模板
*/
private String importTemplate;
/**
* 设置数据监听器
*
* @param readListener
*/
public void setReadListener(ReadDataListener readListener) {
this.readListener = readListener;
}
/**
* 设置导入模板
*/
protected void setImportTemplate(String importTemplate) {
this.importTemplate = importTemplate;
}
/**
* 下载导入模板
*/
public void downloadImportTemplate(HttpServletResponse response) {
ClassPathResource classPathResource = new ClassPathResource(importTemplate);
try (InputStream inputStream = classPathResource.getInputStream();
OutputStream outputStream = response.getOutputStream()) {
// 设置响应信息
response.setContentType("application/vnd.ms-excel");
response.setCharacterEncoding("utf-8");
// 这里URLEncoder.encode可以防止中文乱码
String fileName = URLEncoder.encode("导入模板.xlsx", "UTF-8");
response.setHeader("Content-disposition", "attachment;filename=" + fileName);
IOUtils.copy(inputStream, outputStream);
} catch (Exception exception) {
throw new CustomException(FileException.DOWNLOAD_FAILURE, exception.getMessage());
}
}
/**
* excel上传
*/
@Transactional(rollbackFor = Exception.class)
public ResponseEntity<Result> importExcel(MultipartFile file) {
if (file == null) {
throw new CustomException(FileException.UPLOAD_IS_NULL);
}
ResponseEntity<Result> result = null;
try {
EasyExcel.read(file.getInputStream(), this.getClazz(), this.readListener).sheet().doRead();
return ResultUtil.success();
} catch (Exception exception) {
long currentRowNo = this.readListener.getCurrentRowNo();
Throwable throwable = exception;
while (throwable.getCause() != null) {
throwable = throwable.getCause();
}
throw new CustomException(FileException.EXCEL_IMPORT_FAILURE, currentRowNo, throwable.getMessage());
}
}
/**
* 数据转换
*/
protected E convert2EntityForExcel(V vo) {
throw new CustomException(ExcelExceptionEnum.EXPORT_METHOD_UNIMPLEMENTED);
}
/**
* 通过父类获取运行时泛型类型
*
* @return
*/
private Class<V> getClazz() {
// 获得当前类型的带有泛型类型的父类
Class subclass;
ParameterizedType pd = (ParameterizedType) this.getClass().getGenericSuperclass();
Class<V> clazz = (Class) pd.getActualTypeArguments()[0];
return clazz;
}
}
EasyExcel在导入时采用监听器机制,来读取和解析数据,核心就是监听器,我们自己实现了ReadListener接口,并且该监听器需要多实体复用,使用了泛型。
同时需要注意的是,该监听器是有状态的,不能被spring管理,要每次读取excel都要new,然后里面使用到的服务类,通过构造方法的方法传进去。
package tech.abc.platform.common.component.easyexcel;
import com.alibaba.excel.context.AnalysisContext;
import com.alibaba.excel.read.listener.ReadListener;
import com.alibaba.fastjson.JSON;
import lombok.extern.slf4j.Slf4j;
import tech.abc.platform.common.base.BaseService;
import tech.abc.platform.common.exception.CommonException;
import tech.abc.platform.common.exception.CustomException;
import javax.validation.ConstraintViolation;
import javax.validation.Validation;
import javax.validation.Validator;
import javax.validation.ValidatorFactory;
import java.util.Set;
/**
* 读取数据监听器
*
* @author wqliu
* @date 2023-05-17
*/
@Slf4j
public class ReadDataListener<V, E> implements ReadListener<V> {
/**
* 当前处理行号
*/
private long currentRowNo = 0;
/**
* 服务
*/
private BaseService service;
/**
* 获取当前行号
*
* @return long 行号
*/
public long getCurrentRowNo() {
return currentRowNo;
}
/**
* 构造方法
* 每次创建Listener的时候需要把spring管理的类传进来
*
* @param service
*/
public ReadDataListener(BaseService service) {
this.service = service;
}
/**
* 解析数据
*
* @param data 单行记录
* @param context
*/
@Override
public void invoke(V data, AnalysisContext context) {
currentRowNo++;
log.info("解析到一条数据:{}", JSON.toJSONString(data));
// 处理数据
E entity = handleData(data);
// 保存数据
service.add(entity);
}
/**
* 所有数据解析完成了 都会来调用
*
* @param context
*/
@Override
public void doAfterAllAnalysed(AnalysisContext context) {
log.info("所有数据解析完成!");
}
/**
* 数据预处理,可转化实体类中的字典数据,也可以设置默认字段
*
* @param data
*/
public E handleData(V vo) {
// 数据校验
validateData(vo);
// 设置默认值
setDefaultValue(vo);
// 数据转换
return convertData(vo);
}
private void validateData(V vo) {
// 进行数据验证
ValidatorFactory vf = Validation.buildDefaultValidatorFactory();
Validator validator = vf.getValidator();
Set<ConstraintViolation<V>> set = validator.validate(vo);
for (ConstraintViolation<V> constraintViolation : set) {
throw new CustomException(CommonException.DATA_VALIDATE_FAILURE, constraintViolation.getMessage());
}
}
/**
* 设置默认值
*
* @param vo 视图对象
*/
protected void setDefaultValue(V vo) {
// 如无需设置,则该方法可为空
}
/**
* 转换数据
*
* @param vo 视图对象
* @return {@link E}
*/
protected E convertData(V vo) {
throw new CustomException(ExcelExceptionEnum.EXPORT_METHOD_UNIMPLEMENTED);
}
}
单条处理还是批量处理
EasyExcel提供的范例是解析出数据后,放到一个集合中,到了指定数据量,如300条,批量存库,这样从技术角度而言性能更高,但是用户通过excel整理的数据通常不规范,需要系统给出友好出错提示,具体是哪一行的那个属性有问题,此外,系统框架自身在创建业务实体时有处理逻辑(新增前验证是否为空、是否重复、是否存在,新增后触发相关对象的处理等),而且,用户通过excel导入的通常是小批量的主数据,几十条以内,至多几百条,成千上万罕见,因此调整为每次只处理1条数据。
包括了下载模板、导入Excel数据的处理,公共部分的代码还是抽取到了父类ExcelImportExtension,这里主要用于覆写,指定模板的路径,核心还是在于数据的读取和处理。
对于组织机构而言,有可能存在重名,如生产一部和生产二部两个部门下各存在一个名称为“1号车间”的部门,这时候通过上级名称就没法确定这条数据到底要导入到哪个部门下,因此引入了上级编码。编码是唯一的,系统处理时优先找编码,编码为空的时候再通过名称来处理,如还是找到多个同名部门,则放到未分配里,转人工处理。
package tech.abc.platform.system.controller.extension;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.BeanUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
import tech.abc.platform.common.annotation.SystemLog;
import tech.abc.platform.common.component.easyexcel.ReadDataListener;
import tech.abc.platform.common.exception.CustomException;
import tech.abc.platform.common.extension.ExcelImportExtension;
import tech.abc.platform.common.vo.Result;
import tech.abc.platform.system.constant.SystemConstant;
import tech.abc.platform.system.entity.Organization;
import tech.abc.platform.system.exception.OrganizationExceptionEnum;
import tech.abc.platform.system.service.OrganizationService;
import tech.abc.platform.system.vo.OrganizationForImportVO;
import javax.servlet.http.HttpServletResponse;
import java.util.List;
/**
* 实现Excel导入功能的组织机构控制器
*
* @author wqliu
* @date 2023-05-17
*/
@RestController
@RequestMapping("/system/organization")
@Slf4j
public class OrganizationExcelImportExtensionController extends ExcelImportExtension<OrganizationForImportVO, Organization> {
@Autowired
private OrganizationService organizationService;
@GetMapping("/downloadImportTemplate")
@Override
@PreAuthorize("hasPermission(null,'system:organization:downloadImportTemplate')")
@SystemLog(value = "下载excel模板", logResponseData = false)
public void downloadImportTemplate(HttpServletResponse response) {
super.setImportTemplate("/template/system/organization/import.xlsx");
super.downloadImportTemplate(response);
}
@PostMapping("/importExcel")
@Override
@PreAuthorize("hasPermission(null,'system:organization:import')")
@SystemLog(value = "导入excel", logRequestParam = false)
@Transactional
public ResponseEntity<Result> importExcel(MultipartFile file) {
ReadDataListener readListener = new ReadDataListener<OrganizationForImportVO, Organization>(organizationService) {
@Override
public Organization convertData(OrganizationForImportVO vo) {
return convert2EntityForExcel(vo);
}
};
super.setReadListener(readListener);
return super.importExcel(file);
}
@Override
protected Organization convert2EntityForExcel(OrganizationForImportVO vo) {
Organization entity = organizationService.init();
BeanUtils.copyProperties(vo, entity);
entity.setType(dictionaryUtil.getCodeByName("OrganizationType", vo.getTypeName()));
// 处理上级
if (StringUtils.isNotBlank(vo.getParentCode())) {
// 优先判断上级编码是否存在,若存在,则根据编码找上级
List<Organization> organizationList = organizationService.lambdaQuery()
.eq(Organization::getCode, vo.getParentCode()).list();
if (organizationList.size() == 1) {
// 找到数据,设置父级标识
entity.setOrganization(organizationList.get(0).getId());
} else {
// 未找到,抛出异常
throw new CustomException(OrganizationExceptionEnum.CODE_NOT_FOUND);
}
} else if (StringUtils.isNotBlank(vo.getParentName())) {
// 将上级名称转换为标识
List<Organization> organizationList = organizationService.lambdaQuery()
.eq(Organization::getName, vo.getParentName()).list();
if (organizationList.size() == 1) {
// 根据名称找到唯一的部门,设置部门标识
entity.setOrganization(organizationList.get(0).getId());
} else if (organizationList.size() == 0) {
throw new CustomException(OrganizationExceptionEnum.NAME_NOT_FOUND);
} else {
// 找到多个同名部门,不抛异常,统一设置到预定义的未分配部门,转人工处理
entity.setOrganization(SystemConstant.UNSIGNED_ORGANIZATION_ID);
}
} else {
// 上级名称和编码都为空,数据不合法
throw new CustomException(OrganizationExceptionEnum.PARENT_NAME_AND_CODE_CANOT_NULL);
}
return entity;
}
}
EasyExcel提供了两种模式来映射数据,一是列下标,二是列名。列下标会因为列的增、删、移动而变化,不可靠,采用列名更合适,需要在vo对象的属性上加上注解@ExcelProperty(“名称”)。
采用专用的vo,只需要把excel中出现的有限的几个列建立对应的属性的就行了,如果采用与表单输入公用VO的方案,还得用@ExcelIgnore注解把不需要的字段给忽略掉,要不然导入环节会出错。
这里有个问题需要注意,不能加@Accessors(chain = true)。这个注解将使属性的set方法返回值是对象本身,而EasyExcel组件解析处理映射使用了BeanMap从Map拷贝到Bean, 需要Map 的Key与Bean的变量名一致, 并有对应的 set方法, 且set方法为 void, 才能拷贝成功。
package tech.abc.platform.system.vo;
import com.alibaba.excel.annotation.ExcelProperty;
import lombok.Data;
import javax.validation.constraints.NotBlank;
/**
* 组织机构导入 视图对象类
*
* @author wqliu
* @date 2023-05-18
*/
@Data
public class OrganizationForImportVO {
/**
* 名称
*/
@NotBlank(message = "【名称】不能为空")
@ExcelProperty("名称")
private String name;
/**
* 编码
*/
@ExcelProperty("编码")
private String code;
/**
* 类型
*/
@NotBlank(message = "【类型】不能为空")
@ExcelProperty("类型")
private String typeName;
/**
* 上级名称
*/
@ExcelProperty("上级名称")
private String parentName;
/**
* 上级编码
*/
@ExcelProperty("上级编码")
private String parentCode;
/**
* 排序
*/
@ExcelProperty("排序")
private String orderNo;
/**
* 备注
*/
@ExcelProperty("备注")
private String remark;
}
预先配置导入模板有诸多好处,除了美观外,主要是简单易用,避免出错。此外,还可以在预置模板里做一些简单配置,如单元格的格式(如文本、金额、日期),以及简单数据源的下拉选择(如是否),放一些示例数据等。
模板我们放在了对应模块的resources目录下/template/system/organization/import.xlsx,注意这里的模块编码实际不能少,否则容易产生不同模块下实体名相同的问题,造成打包时模板覆盖问题。
pom打包,记得附加**/*.xlsx,把excel模板作为资源打包进去。
<build>
<finalName>abc-platform-systemfinalName>
<plugins>
<plugin>
<groupId>org.apache.maven.pluginsgroupId>
<artifactId>maven-compiler-pluginartifactId>
<version>3.8.1version>
<configuration>
<source>1.8source>
<target>1.8target>
<encoding>UTF-8encoding>
configuration>
plugin>
<plugin>
<groupId>org.apache.maven.pluginsgroupId>
<artifactId>maven-surefire-pluginartifactId>
<version>2.22.2version>
<configuration>
<skipTests>trueskipTests>
configuration>
plugin>
plugins>
<resources>
<resource>
<directory>src/main/javadirectory>
<includes>
<include>**/*.xmlinclude>
includes>
resource>
<resource>
<directory>src/main/resourcesdirectory>
<includes>
<include>*.ymlinclude>
<include>**/*.xlsxinclude>
<include>*.xmlinclude>
includes>
resource>
resources>
build>
这块没啥好说的,使用平台系统管理模块下的权限项配置功能,按照规范和约定增加配置项即可。
<el-button
v-permission="pageCode + 'downloadImportTemplate'"
type="primary"
icon="Download"
@click="downloadImportTemplate"
>下载模板el-button
>
<el-upload
ref="uploader"
:limit="1"
:http-request="importData"
action="/"
:show-file-list="false"
:before-upload="onBeforeUpload"
class="uploader"
>
<el-button v-permission="pageCode + 'import'" type="primary" icon="List"
>批量导入
el-button>
el-upload>
div>
这里有个坑点,el-upload控件默认会另起一行
加了display: inline;也不够,会成下面这个样子
跟按钮水平对齐,最终加的样式如下:
vue页面调用,对应按钮事件,如下
// 下载导入模板
downloadImportTemplate() {
this.api.downloadImportTemplate()
},
// 导入
importData(file) {
const formData = new FormData()
formData.append('file', file.file)
this.api.import(formData).finally(() => {
this.clearFile()
})
}
system模块统一封装的api,接收vue调用,然后调用axios服务实例,如下
// 下载导入模板
downloadImportTemplate() {
return request.download({ url: this.serveUrl + 'downloadImportTemplate' })
},
// 导入
import(formData) {
return request.upload({ url: this.serveUrl + 'importExcel', data: formData })
}
下载比较简单,没什么好说的。
这地方有个需要特别注意的点,:http-request="importData"绑定的事件,传入的参数把file又封装了一层,如果写成formData.append(‘file’, file),后端用SpringMVC收到的file参数一直为null,需要使用的是参数file的属性file,即lformData.append(‘file’, file.file)。
实现时遇到的问题,使用element plus的uploader控件,上传文件只能执行一次,再次上传(数据验证失败,修改后重新导入或多次导入),因为缓存问题会无操作响应(不是浏览器假死,而是不触发操作),需要在上传成功和失败的情况下,均执行清理文件操作。
clearFile() {
// 上传成功之后清除历史记录,否则再次上传浏览器无响应
this.$refs.uploader.clearFiles()
}
路径为src\config\axios\index.ts,之前实现了get、put、post和delete,虽然下载用的是get,上传用的是post,但因为文件处理比较特殊,有额外的处理,因此单独封装。
下载,必须指定responseType属性为’blob’。
上传,必须指定headersType为’multipart/form-data’
download: (option: any) => {
request({ method: 'get', responseType: 'blob', ...option })
.then((res) => {
const { data, headers } = res
const fileName = headers['content-disposition'].replace(/\w+;filename=(.*)/, '$1')
// 此处当返回json文件时需要先对data进行JSON.stringify处理,其他类型文件不用做处理
const blob = new Blob([data], { type: headers['content-type'] })
const dom = document.createElement('a')
const url = window.URL.createObjectURL(blob)
dom.href = url
dom.download = decodeURI(fileName)
dom.style.display = 'none'
document.body.appendChild(dom)
dom.click()
dom.parentNode.removeChild(dom)
window.URL.revokeObjectURL(url)
})
.catch((err) => {
reject(err)
})
},
upload: (option: any) => {
return new Promise((resolve, reject) => {
option.headersType = 'multipart/form-data'
request({ method: 'post', ...option })
.then((res) => {
// 明确设置为true时,显示提示
if (option.showInfo === true) {
ElMessage.info(res.data.message)
}
resolve(res.data)
})
.catch((err) => {
reject(err)
})
})
}
虽然EasyExcel提供了直接的导出Excel的api,但是关键问题有两个,一是Excel的格式,在程序中设置相当地繁琐,并且不够美观,如涉及到复合表头、合并单元格,那么不是一般的复杂,另一方面,需要在视图模型类上加诸多的注解,来控制是否显示以及必要的格式转换,这两点的初始化工作,以及调整的工作量都偏大,灵活性也太差。
从设计角度考虑,数据和展现应该是分离的,不应该耦合在一块,因此使用EasyExcel的填充api,即先人工编辑好excel模板,设置好样式,以及数据的占位,具体的数据,由应用程序后端动态生成来填充,各司其职,提供灵活性和扩展性。
如何处理数据导出
不同的场景下,导出的数据量是不同的,如系统的主数据组织机构、人员等,数据量有限,可以一次性读取到内存,一次性写入到Excel中,但是不可避免存在导出大量业务单据的情况,例如几万甚至几十万的数据,这时候,应该分批读取和分批写入,避免大量占用应用服务器内存,也减少全量垃圾回收次数,使应用运行更稳定。
有两种方案,一是平台封装两个方法,分别是一次性处理和分批处理,由业务功能开发时根据估算数据量,来自行决定调用哪一个;二是平台只提供一个方法,内部根据数据量大小及配置来决定是一次性处理还是分批处理。
经考虑后,采取以方案2,这样使用方无需过多关注细节,只是去调用导出即可,而且某些业务单据可能上百万,但是用户选择了时间段等过滤条件后,数据量可能只有几百几千条,同样更适合一次性处理。
实现阶段进行方案优化,在控制器层,父类获取获取数据量比较困难(技术上能拿到,但需要每个子类去实现获取总量的方法),因此采用变通的处理方式,即统一使用分批处理的模式,每次处理数据量设置一个较大值,比如10000,这样小数据量的导出,处理一次就结束了,而大数据量,仍会分批多次处理。
需要导出excel的功能的业务实体对应的控制器,只需要继承控制器父类,并覆写一个获取分页数据的方法即可。
并且可以自行控制单次处理数据量(如列特别多,逻辑处理复杂等)
数据导出的具体设计与实现思路与导入高度类似,这里只放出关键代码,具体参见开源代码库。
excel导出功能公共基类
package tech.abc.platform.common.extension;
import com.alibaba.excel.EasyExcel;
import com.alibaba.excel.ExcelWriter;
import com.alibaba.excel.write.metadata.WriteSheet;
import com.baomidou.mybatisplus.core.metadata.IPage;
import org.springframework.core.io.ClassPathResource;
import tech.abc.platform.common.base.BaseController;
import tech.abc.platform.common.component.easyexcel.ExcelExceptionEnum;
import tech.abc.platform.common.exception.CustomException;
import tech.abc.platform.common.exception.FileException;
import javax.servlet.http.HttpServletResponse;
import java.io.InputStream;
import java.io.OutputStream;
import java.lang.reflect.ParameterizedType;
import java.net.URLEncoder;
/**
* excel导出功能扩展
*
* @author wqliu
* @date 2023-05-19
*/
public class ExcelExportExtension<V, E> extends BaseController {
/**
* 导出数据分页大小
* TODO:可配置化
*/
private static final long EXPORT_DATA_PAGE_SIZE = 200;
/**
* 导出模板
*/
private String exportTemplate;
/**
* 设置导出模板
*/
public void setExportTemplate(String exportTemplate) {
this.exportTemplate = exportTemplate;
}
/**
* 导出到excel
*
* @param queryParam
* @param response
* @throws Exception
*/
public void exportExcel(V queryParam, HttpServletResponse response) {
ClassPathResource classPathResource = new ClassPathResource(this.exportTemplate);
try (InputStream inputStream = classPathResource.getInputStream();
OutputStream outputStream = response.getOutputStream()) {
// 设置响应信息
response.setContentType("application/vnd.ms-excel");
response.setCharacterEncoding("utf-8");
// 这里URLEncoder.encode可以防止中文乱码
String fileName = URLEncoder.encode("导出数据.xlsx", "UTF-8");
response.setHeader("Content-disposition", "attachment;filename=" + fileName);
// excel导出处理
ExcelWriter excelWriter = EasyExcel.write(outputStream, this.getClazz())
.withTemplate(inputStream).build();
WriteSheet writeSheet = EasyExcel.writerSheet().build();
// 此处并没有获取数据总量后自行计算分页,而是利用了已有的分页查询功能
long pageSize = EXPORT_DATA_PAGE_SIZE;
// 开始第一次查询,并获取分页总数
IPage<V> pagedResult = getExportData(queryParam, pageSize, 1);
excelWriter.fill(pagedResult.getRecords(), writeSheet);
// 读取后续数据
for (int i = 2; i <= pagedResult.getPages(); i++) {
// 分页去数据库查询数据 这里可以去数据库查询每一页的数据
pagedResult = getExportData(queryParam, pageSize, i);
excelWriter.fill(pagedResult.getRecords(), writeSheet);
}
// 关闭流
excelWriter.finish();
} catch (Exception exception) {
throw new CustomException(FileException.EXCEL_EXPORT_FAILURE, exception.getMessage());
}
}
/**
* 获取导出数据
*
* @return
* @throws Exception
*/
public IPage<V> getExportData(V queryParam, long pageSize, long pageNum) {
throw new CustomException(ExcelExceptionEnum.EXPORT_METHOD_UNIMPLEMENTED);
}
/**
* 通过父类获取运行时泛型类型
*
* @return
*/
private Class<V> getClazz() {
// 获得当前类型的带有泛型类型的父类
Class subclass;
ParameterizedType pd = (ParameterizedType) this.getClass().getGenericSuperclass();
Class<V> clazz = (Class) pd.getActualTypeArguments()[0];
return clazz;
}
}
具体的导出控制器类
package tech.abc.platform.system.controller.extension;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import tech.abc.platform.common.annotation.SystemLog;
import tech.abc.platform.common.extension.ExcelExportExtension;
import tech.abc.platform.common.query.QueryGenerator;
import tech.abc.platform.system.controller.OrganizationController;
import tech.abc.platform.system.entity.Organization;
import tech.abc.platform.system.service.OrganizationService;
import tech.abc.platform.system.vo.OrganizationVO;
import javax.servlet.http.HttpServletResponse;
import java.util.List;
/**
* 实现Excel导出功能的组织机构控制器
*
* @author wqliu
* @date 2023-05-19
*/
@RestController
@RequestMapping("/system/organization")
@Slf4j
public class OrganizationExcelExportExtensionController extends ExcelExportExtension<OrganizationVO, Organization> {
@Autowired
private OrganizationService organizationService;
@Autowired
protected OrganizationController organizationController;
@GetMapping("/exportExcel")
@Override
@PreAuthorize("hasPermission(null,'system:organization:export')")
@SystemLog(value = "导出excel", logResponseData = false)
public void exportExcel(OrganizationVO vo, HttpServletResponse response) {
// 设置模板名称
super.setExportTemplate("/template/system/organization/export.xlsx");
// 当勾选查询所有复选框时,查询所有数据
if (vo.getIgnoreParent() != null && vo.getIgnoreParent()) {
vo.setOrganization(null);
}
// 导出到excel
super.exportExcel(vo, response);
}
@Override
public IPage<OrganizationVO> getExportData(OrganizationVO queryParam, long pageSize, long pageNum) {
// 构造分页对象
IPage<Organization> page = new Page<Organization>(pageNum, pageSize);
// 构造查询条件
QueryWrapper<Organization> queryWrapper = QueryGenerator.generateQueryWrapper(Organization.class, queryParam);
// 排序
queryWrapper.lambda().orderByAsc(Organization::getOrganization).orderByAsc(Organization::getOrderNo);
organizationService.page(page, queryWrapper);
// 转换vo
IPage<OrganizationVO> pageVO = mapperFacade.map(page, IPage.class);
List<OrganizationVO> organizationVOList = organizationController.convert2VO(page.getRecords());
pageVO.setRecords(organizationVOList);
return pageVO;
}
}
平台名称:一二三开发平台
简介: 企业级通用开发平台
设计资料:csdn专栏
开源地址:Gitee
开源协议:MIT
欢迎收藏、点赞、评论,你的支持是我前行的动力。