赞
踩
数据导入导出是应用系统常见需求,而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文件中添加如下依赖。
<!--阿里EasyExcel组件-->
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>easyexcel</artifactId>
<version>3.3.1</version>
</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-system</finalName> <plugins> <!--编译插件 --> <plugin> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-compiler-plugin</artifactId> <version>3.8.1</version> <configuration> <!--指定JDK编译版本 --> <source>1.8</source> <target>1.8</target> <encoding>UTF-8</encoding> </configuration> </plugin> <!-- 测试插件 --> <plugin> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-surefire-plugin</artifactId> <version>2.22.2</version> <configuration> <!-- 跳过测试 --> <skipTests>true</skipTests> </configuration> </plugin> </plugins> <resources> <!--处理mybatis的mapper.xml文件--> <resource> <directory>src/main/java</directory> <includes> <include>**/*.xml</include> </includes> </resource> <!--处理其他资源文件--> <resource> <directory>src/main/resources</directory> <includes> <!--系统配置文件--> <include>*.yml</include> <!--excel模板--> <include>**/*.xlsx</include> <!--其他配置文件--> <include>*.xml</include> </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;也不够,会成下面这个样子
跟按钮水平对齐,最终加的样式如下:
<style scoped>
:deep(.el-upload) {
margin-left: 12px;
display: inline;
text-align: center;
cursor: pointer;
outline: 0;
}
:deep(.uploader) {
display: inline;
}
</style>
最后实现效果如下,完美对齐。
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
欢迎收藏、点赞、评论,你的支持是我前行的动力。
Copyright © 2003-2013 www.wpsshop.cn 版权所有,并保留所有权利。