导入导出?CV完了就摸鱼

今天的主角是EasyExcel,github地址:https://github.com/alibaba/easyexcel
号称64M内存1分钟内读取75M(46W行25列)的Excel,还有急速模式能更快,但是内存占用会在100M多一点

Maven依赖

<dependency>
    <groupId>com.alibaba</groupId>
    <artifactId>easyexcel</artifactId>
    <version>3.0.5</version>
</dependency>

<!-- 对于文件操作可能用到了 hutool的工具包 -->
<dependency>
   <groupId>cn.hutool</groupId>
   <artifactId>hutool-all</artifactId>
   <version>5.7.16</version>
</dependency>

读Excel

  • excel示例
    在这里插入图片描述

  • 对象

/**
 * 用户表
 *
 * @author DuanYu <a>z-houjie@qq.com</a>
 * @date 2022-02-09 11:09
 */
@Data
public class UserExcelDTO {

    private Integer id;
    
    /** 用户类型: 0-未知,1-员工,2-客户 */
    private Integer type;
    
    /** 用户头像 */
    private String avatar;
    
    /** 姓名 */
    private String name;

    /** 手机号 */
    private String mobile;

    /** 删除标记 */
    private Integer deleted;

    /** 创建时间 */
    private Date createdTime;

    /** 更新时间 */
    private Date updatedTime;
}

  • 一个用于处理文件上传的controller
/**
 * 上传
 *
 * @param file 文件
 */
@PostMapping("/upload")
public void upload(MultipartFile file) {
    Assert.isTrue(Objects.requireNonNull(file.getOriginalFilename()).endsWith(".xlsx"), "请以.xlsx格式导入文件");
    exportService.upload(file);
}

  • 处理读取数据
// 这里 需要指定读用哪个class去读,然后读取第一个sheet,从第二行开始;文件流会自动关闭
EasyExcelFactory.read(file.getInputStream(), User.class, new AnalysisEventListener<User>() {
    @Resource
    private UserMapper userMapper;

    /**
     * 每隔500条存储数据库,然后清理list ,方便内存回收
     */
    private static final int BATCH_COUNT = 500;

    private List<User> cachedDataList = new ArrayList<>(BATCH_COUNT);

    /**
     * 每一条数据解析都调用一次这个方法
     *
     * @param user            用户数据
     * @param analysisContext 读取excel信息
     */
    @Override
    public void invoke(User user, AnalysisContext analysisContext) {
        log.info("解析到一条数据:{}", JSON.toJSONString(user));
        cachedDataList.add(user);
        // 达到BATCH_COUNT了,需要去存储一次数据库,防止数据几万条数据在内存,容易OOM
        if (cachedDataList.size() >= BATCH_COUNT) {
            saveData();
            // 存储完成清理 list
            cachedDataList = new ArrayList<>(BATCH_COUNT);
        }
    }

    /**
     * 所有数据解析完成了调用这个方法
     *
     * @param analysisContext 读取excel信息
     */
    @Override
    public void doAfterAllAnalysed(AnalysisContext analysisContext) {
        // 这里也要保存数据,将最后一批数据也存储到数据库
        saveData();
        log.info("所有数据解析完成");
    }

    private void saveData() {
        log.info("{}条数据,开始存储数据库", cachedDataList.size());
        userMapper.batchInsert(cachedDataList);
        log.info("存储数据库成功");
    }
}).sheet(0).headRowNumber(1).doRead();

在开发中,这个Listener监听肯定是要拿出来单独写的
有个很重要的点 XXXXAnalysisEventListener不能被spring管理,要每次读取excel都要new,然后里面用到spring可以构造方法传进去


转换器Converter

我们可以发现excel中的用户类型、是否离职是String,但在Java类中是Integer类型。所以这里介绍一下转换器的使用

/**
* 抽象方法介绍
*/
public interface Converter<T> {
    // 在java中的类型是什么
    Class supportJavaTypeKey();
    
    // 在excel中的类型是什么
    CellDataTypeEnum supportExcelTypeKey();
    
    // 将excel的数据类型转为java数据类型
    T convertToJavaData(CellData var1, ExcelContentProperty var2, GlobalConfiguration var3) throws Exception;
    
    // 将java的数据类型转为excel数据类型
    CellData convertToExcelData(T var1, ExcelContentProperty var2, GlobalConfiguration var3) throws Exception;
}

/**
 * 枚举
 */
@Getter
@RequiredArgsConstructor
public enum UserType {

    /**
     * 员工
     */
    STAFF(1, "员工"),

    /**
     * 客户
     */
    CUSTOMER(2, "客户");

    private final Integer value;

    private final String description;

    private static final Map<Integer, String> DESCRIPTION_DICT = new HashMap<>();

    private static final Map<String, Integer> VALUE_DICT = new HashMap<>();

    static {
        for (UserType value : UserType.values()) {
            DESCRIPTION_DICT.put(value.getValue(), value.getDescription());
            VALUE_DICT.put(value.getDescription(), value.getValue());
        }
    }

    public static String getTypeName(Integer key) {
        return DESCRIPTION_DICT.getOrDefault(key, null);
    }

    public static Integer getTypeValue(String key) {
        return VALUE_DICT.getOrDefault(key, null);
    }
}

/**
 * 具体实现
 * @author DuanYu Email:<a>z-houjie@qq.com</a>
 * @date 2022-02-21 17:20
 */
public class UserTypeExcelConverter implements Converter<Integer> {

    @Override
    public Class supportJavaTypeKey() {
        return Integer.class;
    }

    @Override
    public CellDataTypeEnum supportExcelTypeKey() {
        return CellDataTypeEnum.STRING;
    }

    @Override
    public Integer convertToJavaData(CellData cellData, ExcelContentProperty excelContentProperty, GlobalConfiguration globalConfiguration) throws Exception {
        return UserType.getTypeValue(cellData.getStringValue());

    }

    @Override
    public CellData convertToExcelData(Integer value, ExcelContentProperty excelContentProperty, GlobalConfiguration globalConfiguration) throws Exception {
        return new CellData(Optional.ofNullable(UserType.getTypeName(value)).orElse(""));
    }
}

  • 使用
@ExcelProperty(value = "用户类型", converter = UserTypeExcelConverter.class)
private Integer type;

@ExcelProperty(value = "是否删除", converter = DeleteExcelConverter.class)
private Integer deleted;

// 介绍一下其他转换器

/** 日期 */
@DateTimeFormat("yyyy年MM月dd日HH时mm分ss秒")
private String date;

/** 百分比 */
@NumberFormat("#.##%")
private String doubleData;


踩坑知识点

使用异常线程读Excel文件可能存在 java.io.FileNotFoundExceprion
1.spring boot的服务在启动时,会在操作系统的/tmp目录下生成一个Tomcat.*的文件目录,用于"java.io.tmpdir"文件流操作
2.当程序对文件进行操作(上传)时,会在此目录下生成临时文件,暂存在临时文件中
当我们使用异步线程时主线程结束了,异步线程还未拿到文件,主线程会存在将临时文件删掉的操作导致上述异常
复现方式:异步线程进来等待几秒在用文件,直接java.io.FileNotFoundExceprion
解决方法:1.规避多线程操作文件、2.将文件转到本地使用完后手动删除

File localFile = null;
try{
    localFile = FileUtils.multipartFileToFile(file);
} catch(Exception e){
    
} finally {
    FileUtils.del(localFile);
}

  • FileUtils
@Slf4j
public class FileUtils extends FileUtil {

    private static final String TMP_PATH = File.separator + "tmp" + File.separator;

    /**
     * MultipartFile 转 File
     *
     * @param file MultipartFile
     * @throws IOException io异常
     */
    public static File multipartFileToFile(MultipartFile file) throws IOException {
        InputStream ins = file.getInputStream();
        File toFile = new File(TMP_PATH + URLEncoder.encode(Objects.requireNonNull(file.getOriginalFilename()), "UTF-8"));
        toFile.getParentFile().mkdirs();
        FileUtil.writeFromStream(ins, toFile);
        return toFile;
    }

    /**
     * base64转文件
     *
     * @param base64   base64
     * @param fileName 文件名称
     * @return 文件
     * @throws UnsupportedEncodingException 编码异常
     */
    public static File base64ToFile(String base64, String fileName) throws UnsupportedEncodingException {
        return Base64.decodeToFile(base64, new File(TMP_PATH + URLEncoder.encode(fileName, "UTF-8")));
    }

}

导出Excel

往往导出会涉及大数据量的问题,那么无论对用户的体验还是接口的可行性都是不友好的(超时异常等)。这样我们会将导出的功能做成异步的,用户先通过筛选条件查询,服务器就会在后台异步的进行数据的制作,等制作完成再让用户去下载。

  • 表设计
    有文件服务器的情况下,只需要把文件上传到服务器,将下载地址存到表里面即可。
    如果没有单独的文件服务器,那么只能将数据存储到数据库中,但是一条数据量非常大,导致仅仅一个修改状态的操作都要把这条记录的数据全部下载下来。
    为解决这一问题采用的是每个主题的数据分一个表,将数据分片存储一对多的建立关系,相当于数据桶的概念,将不同的数据放在不同的桶中,当然桶是需要自己创建的。
-- 文件导出表
CREATE TABLE `export` (
    `id` int(11) NOT NULL AUTO_INCREMENT,
    `status` tinyint(1) DEFAULT NULL COMMENT '导出状态:0: 导出中 1:成功,2:失败',
    `export_topic` int(4) DEFAULT NULL COMMENT '导出对象,1-某个主题、2-某个主题...',
    `export_parameters` text COMMENT '导出参数(JSON格式)',
    `export_type` tinyint(1) DEFAULT '0' COMMENT '导出类型,1:csv  2:excel',
    `info_slicing_path` varchar(255) DEFAULT NULL COMMENT '内容分片路径',
    `operator` int(11) DEFAULT NULL COMMENT '操作人ID',
    `created_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
    PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=0 DEFAULT CHARSET=utf8mb4;

-- 主题数据表,每一个主题都需要新建一张数据分片表
CREATE TABLE `exprot_user_slicing`(
    `id` int(11) NOT NULL AUTO_INCREMENT,
    `export_info` longtext COMMENT '导出信息',
    `created_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
    PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=0 DEFAULT CHARSET=utf8mb4;

  • Controller
/**
 * 查询数据入库
 *
 * @param param 请求参数
 */
@PostMapping("/data")
public void export(@RequestBody ExportParam param) {
    exportService.exportExcel(param);
    log.info("数据制作中...");
}

// ExportParam 为查询参数(哪一个主题,查询数据筛选条件以json字符串传过来)
@Data
@Accessors(chain = true)
public class ExportParam implements Serializable {

    private static final long serialVersionUID = 0L;

    /**
     * 导出的主题
     */
    private Integer topic;

    /**
     * 查询参数
     */
    private String jsonParam;
}
  • 初始化主题枚举
    枚举类中会定义某个主题,这个主题的查询参数、导出类型、文件名称、查询方法等,为后面的通用Service层做准备,这样每个导出只需要初始化主题,并提供一个查询的方法。其他的交给天吧!!!!

@Getter
@RequiredArgsConstructor
public enum ExportTopic {

    /**
     * 用户列表页
     */
    USER_LIST(1, UserParam.class, UserExportDTO.class, "用户数据", "export_user_slicing", p -> SpringContextUtil.getBean(UserService.class).findExportData((UserParam) p));


    private final Integer value;

    /**
     * 查询参数类型
     */
    private final Class<?> param;

    /**
     * 导出类型
     */
    private final Class<?> target;

    /**
     * 文件名称
     */
    private final String fileName;

    /**
     * 分片表地址
     */
    private final String location;

    /**
     * 数据查询方法
     */
    private final Function<Object, List<?>> function;


    public static final Map<Integer, ExportTopic> INSTANCE;

    static {
        Map<Integer, ExportTopic> dict = new HashMap<>(ExportTopic.values().length);
        for (ExportTopic export : ExportTopic.values()) {
            dict.put(export.value, export);
        }
        INSTANCE = dict;
    }

}

  • Service层
    从给入场中找到这个到导出是什么主题、这里对数据的类型并不敏感,只需要将数据按照大小分片存储即可
    其中,因为是异步的,我们有充足的时间对数据进行优化操作,下面的分片是将数据先进行压缩,得到的byte数组,然后根据字节大小进行分片。
/**
 * 查询数据导出数据
 *
 * @param exportParam 查询参数
 */
@Override
public void exportExcel(ExportParam exportParam) {
    ExportTopic exportTopic = getTopic(exportParam.getTopic());
    // 生成导出记录
    Export export = new Export();
    export.setStatus(ExportStatus.EXPORTING.getValue());
    export.setExportTopic(exportTopic.getValue());
    export.setExportParameters(exportParam.getJsonParam());
    export.setExportType(ExportType.EXCEL.getValue());
    export.setCreatedTime(new Date());
    exportMapper.insertSelective(export);

    // 异步制作导出内容
    asyncOutput.execute(() -> {
        try {
            Object param = JSON.parseObject(exportParam.getJsonParam(), exportTopic.getParam());

            // 执行数据查询方法拿到数据集合
            List<?> data = exportTopic.getFunction().apply(param);
            // 分片存储
            String locations = dataInfoSlicing(exportTopic.getLocation(), data);
            // 更新导出情况
            exportMapper.updateByPrimaryKeySelective(new Export().setId(export.getId()).setInfoSlicingPath(locations).setStatus(ExportStatus.SUCCESS.getValue()));

            log.info("制作成功!地址库:{},分片ID:{}", exportTopic.getLocation(), locations);
        } catch (Exception e) {
            exportMapper.updateByPrimaryKeySelective(new Export().setId(export.getId()).setStatus(ExportStatus.FAIL.getValue()));
            log.error("制作异常:", e);
        }
    });
}

/**
 * 数据分片存储
 *
 * @param location 地址库
 * @param dataInfo 数据信息
 */
private String dataInfoSlicing(String location, List<?> dataInfo) {
    // 内容压缩
    byte[] serialize = ExportUtil.serialize(dataInfo);
    // 内容分片
    List<String> slicingList = ExportUtil.slicing(serialize);
    // 存储 
    if (!CollectionUtils.isEmpty(slicingList)) {
        List<Map<String, Object>> exportInfo = slicingList.stream().map(v -> {
            Map<String, Object> infoMap = new HashMap<>(slicingList.size());
            infoMap.put("exportInfo", v);
            return infoMap;
        }).collect(Collectors.toList());

        // 在没有IO限制的条件下可以优化成并发写入
        exportMapper.insertSlice(exportInfo, location);
        return exportInfo.stream().map(v -> String.valueOf(v.get("id"))).collect(Collectors.joining("-"));
    }
    throw new IllegalStateException("数据分片存储异常");
}

/**
 * 获取导出主题
 *
 * @param topic 主题value
 * @return 主题
 */
private ExportTopic getTopic(Integer topic) {
    return Objects.requireNonNull(ExportTopic.INSTANCE.get(topic), "不支持的导出主题");
}

  • 下载
    数据制作完成后,通过主题ID可以查询到这个主题所有导出列表
/**
 * 获取导出列表
 *
 * @param param 请求参数
 * @return
 */
@PostMapping("/get")
public ResultData getExportList(@RequestBody @Validated ExportGetListParam param) {
    return ResultData.successful(exportService.findExportList(param));
}


/**
 * 获取下载列表
 *
 * @param param 查询条件
 * @return 分页列表
 */
@Override
public PageInfo<Export> findExportList(ExportGetListParam param) {
    PageMethod.startPage(param.getPageIndex(), param.getPageSize());
    return new PageInfo<>(exportMapper.selectByTopic(param.getTopic()));
    // 可以完善 操作人ID字段,再查询映射出操作人名称(举一反三)
}

  • 下载
    在上面的列表中选着某一条制作的数据进行导出,后台做的操作为通过ID查询这条记录,获取这条记录的储存分片的ID路径(1-2-3-4),通过这些ID去数据桶里把数据捞出来按主键顺序拼接起来即可(数据桶主键自增)
@GetMapping("/download/{id}")
public void download(@PathVariable Integer id, HttpServletResponse response) throws IOException {
    exportService.download(id, response);
}


/**
 * 下载导出文件
 *
 * @param id       导出记录ID
 * @param response 响应体
 */
@Override
public void download(Integer id, HttpServletResponse response) throws IOException {
    Export export = exportMapper.selectByPrimaryKey(id);
    if (Objects.nonNull(export) && Objects.equals(ExportStatus.SUCCESS.getValue(), export.getStatus())) {
        // 获取片段内容
        List<Integer> sliceIds = Arrays.stream(export.getInfoSlicingPath().split("-")).map(Integer::valueOf).collect(Collectors.toList());
        ExportTopic topic = getTopic(export.getExportTopic());
        List<String> slices = exportMapper.selectSliceInfoByLocation(topic.getLocation(), sliceIds);
        String join = String.join("", slices);
        List targetList;
        if (!StringUtils.hasText(join)) {
            log.info("数据为空");
            targetList = new ArrayList();
        } else {
            targetList = ExportUtil.deserialize(join, topic.getTarget());
        }
        ExportUtil.exportSingleSheetToHttpResponse(topic.getTarget(), () -> targetList, ResultData.failed("导出失败"), topic.getFileName(), "1", response);
    }
    throw new IllegalStateException("无可下载数据");
}

  • ExportUtil
/**
 * @author DuanYu <a>Email:z-houjie@qq.com</a>
 * @date 2022-02-09 14:47
 */
public class ExportUtil {

    private static final Logger log = LoggerFactory.getLogger(ExportUtil.class);

    private ExportUtil() {
        throw new IllegalStateException("Utility class");
    }

    /**
     * 导出单Sheet的excel数据到HttpResponse
     *
     * @param type      类型
     * @param query     获取数据的方法
     * @param errorData 执行失败的返回数据
     * @param fileName  文件名
     * @param sheetName sheet
     * @param response  response
     * @param <T>       类型
     */
    public static <T> void exportSingleSheetToHttpResponse(Class<T> type, Supplier<List<T>> query, Object errorData, String fileName, String sheetName, HttpServletResponse response) throws IOException {
        try {
            long start = System.currentTimeMillis();
            List<T> list = query.get();
            long end = System.currentTimeMillis();
            log.info("Excel 获取数据耗时:{}ms", end - start);

            setExcelHttpResponse(fileName, response);

            ServletOutputStream outputStream = response.getOutputStream();
            EasyExcelFactory.write(outputStream, type).excelType(ExcelTypeEnum.XLSX).sheet(sheetName).doWrite(list);
        } catch (Exception e) {
            log.error("Excel 导出失败:", e);
            response.reset();
            // 如果生成失败,则转为Json返回
            setJsonHttpResponse(response);

            PrintWriter writer = response.getWriter();
            writer.println(JSON.toJSONString(errorData));
            writer.close();
        }
    }

    /**
     * 设置响应类型为excel
     *
     * @param fileName 文件名称
     * @param response response
     * @throws UnsupportedEncodingException 编码异常
     */
    public static void setExcelHttpResponse(String fileName, HttpServletResponse response) throws UnsupportedEncodingException {
        response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
        response.setCharacterEncoding("utf-8");
        fileName = URLEncoder.encode(fileName, "UTF-8");
        response.setHeader("Content-disposition", "attachment;filename=" + fileName + ".xlsx");
    }

    /**
     * 设置响应类型为excel
     *
     * @param response response
     */
    public static void setJsonHttpResponse(HttpServletResponse response) {
        response.setContentType("application/json");
        response.setCharacterEncoding("utf-8");
    }

    /**
     * 分片
     *
     * @param serialize 压缩后的内容
     * @return 内容片段
     */
    public static List<String> slicing(byte[] serialize) {
        List<String> list = new ArrayList<>();
        // 每片大小(100KB)可以看情况定义
        final int slicingSize = 1024 * 100;
        // 初始化偏移量和长度
        int offset = 0, length = slicingSize;
        do {
            if ((serialize.length - offset) <= slicingSize) {
                length = serialize.length - offset;
            }
            list.add(new String(serialize, offset, length, StandardCharsets.ISO_8859_1));
            offset += length;
        } while (offset < serialize.length);
        return list;
    }


    /**
     * 序列化 压缩json
     *
     * @param elements 内容
     * @param <E>      内容类型
     * @return 压缩后内容
     */
    public static <E> byte[] serialize(List<E> elements) {
        String json = JSON.toJSONString(elements);
        byte[] bytes = json.getBytes(StandardCharsets.UTF_8);
        return ZipUtil.gzip(bytes);
    }


    /**
     * 解压
     *
     * @param serialized 压缩内容
     * @param type       转换的类型
     * @param <E>        类型
     * @return 解压的内容
     */
    public static <E> List<E> deserialize(String serialized, Class<E> type) {
        byte[] bytes = serialized.getBytes(StandardCharsets.ISO_8859_1);
        byte[] unzip = ZipUtil.unGzip(bytes);
        return JSON.parseArray(new String(unzip), type);
    }
}

结束语

书山有路勤为径,学海无涯苦作舟!!!

这篇文章部分来源于EasyExcel官方文档,更多更细的API使用方法请移步官方文档EasyExcel · 语雀 (yuque.com),我只是一名引路者。

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
expdp和impdp是Oracle数据库提供的工具,用于进行数据的导出导入操作。下面是使用expdp和impdp工具进行数据导入导出的步骤: 1. 导出数据(使用expdp): - 打开命令行窗口或终端,并登录到Oracle数据库。 - 运行以下命令来导出数据: ``` expdp username/password@database_name DIRECTORY=directory_name DUMPFILE=dumpfile_name LOGFILE=logfile_name ``` 其中,`username`是要导出数据的用户,`password`是用户的密码,`database_name`是数据库的名称,`directory_name`是导出文件的目录,`dumpfile_name`是导出文件的名称,`logfile_name`是日志文件的名称。 - 根据需要,可以添加其他选项来指定要导出的表、模式、数据过滤条件等。 2. 导入数据(使用impdp): - 打开命令行窗口或终端,并登录到Oracle数据库。 - 运行以下命令来导入数据: ``` impdp username/password@database_name DIRECTORY=directory_name DUMPFILE=dumpfile_name LOGFILE=logfile_name ``` 其中,`username`是要导入数据的用户,`password`是用户的密码,`database_name`是数据库的名称,`directory_name`是导入文件的目录,`dumpfile_name`是导入文件的名称,`logfile_name`是日志文件的名称。 - 根据需要,可以添加其他选项来指定要导入的表、模式、数据过滤条件等。 需要注意的是,导出导入的用户需要具有相应的权限才能执行这些操作。另外,导出导入的文件可以是二进制文件(.dmp)或压缩文件(.dpb)。

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值