应用开发平台业务支撑功能——集成阿里EasyExcel组件实现Excel导入导出功能

背景

数据导入导出是应用系统常见需求,而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;


}

配置导入模板

预先配置导入模板有诸多好处,除了美观外,主要是简单易用,避免出错。此外,还可以在预置模板里做一些简单配置,如单元格的格式(如文本、金额、日期),以及简单数据源的下拉选择(如是否),放一些示例数据等。
image.png
模板我们放在了对应模块的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>

配置权限项

这块没啥好说的,使用平台系统管理模块下的权限项配置功能,按照规范和约定增加配置项即可。
image.png

前端列表视图增加按钮

<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控件默认会另起一行
image.png
加了display: inline;也不够,会成下面这个样子
image.png
跟按钮水平对齐,最终加的样式如下:

<style scoped>
:deep(.el-upload) {
    
    
  margin-left: 12px;
  display: inline;
  text-align: center;
  cursor: pointer;
  outline: 0;
}

:deep(.uploader) {
    
    
  display: inline;
}
</style>

最后实现效果如下,完美对齐。
image.png

前端调用

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()
    }

扩展Axios,实现上传与下载

路径为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;

    }
}

数据导出效果
image.png

开发平台资料

平台名称:一二三开发平台
简介: 企业级通用开发平台
设计资料:csdn专栏
开源地址:Gitee
开源协议:MIT

欢迎收藏、点赞、评论,你的支持是我前行的动力。

猜你喜欢

转载自blog.csdn.net/seawaving/article/details/131299250