【EasyExcel实践】万能导出,一个接口导出多张表以及任意字段(可指定字段顺序)-简化升级版

前言

关于万能导出,前一阵子写过一个,功能是实现了的。
就是在使用时,感觉如果需要导出的页面比较多,那就会出现比较多的重复代码。就想着优化+简化一下,能够更方便的使用。

原版代码仓库和这一版的代码仓库相同:
https://gitee.com/fengsoshuai/excel-demo

原版中额外增加了转换器,枚举转换等功能,但是总觉得会让整体功能变复杂,所以在这一版中就去掉了。如果有兴趣看看的话,可以切换到master分支查看。

简化升级的这一版的代码分支是:simple-dynamic-column-export

正文

一、项目简介

本次简化升级,本着对使用者友好的目的去实现的。

另外,本文会粘贴全部代码!!

在这里插入图片描述

在使用上的简化体现在真正导出时,只需要几行代码,就能实现功能。可以让你更加专注于业务参数的组装。而且代码比较简洁。
举个例子:

@GetMapping("/exportDy")
public String exportDy(@RequestParam("table") List<String> table, HttpServletResponse response) throws IOException {

    // 构造导出模版
    ExportDynamicColumnTemplate<StudentDemo> dynamicColumnTemplate = new ExportDynamicColumnTemplateBuilder<>(StudentDemo.class)
            .columnNameList(table)
            .build();
    // 制造假数据
    dynamicColumnTemplate.appendExportDataList(studentDemos());

    // 转换为excel的字节数组
    byte[] bytes = dynamicColumnTemplate.toExcelByteArray();

    // 响应到web
    String fileName = System.currentTimeMillis() + ".xlsx";
    response.setHeader("Content-disposition", "attachment;filename=" + fileName);
    response.setContentType("application/x-msdownload");
    response.setCharacterEncoding("utf-8");
    response.getOutputStream().write(bytes);
    response.getOutputStream().flush();

    return "success";
}

简化后的代码,只需要3步操作:

  1. 根据导出bean,使用建造器生成模版实例;
  2. 给模版实例中填充业务数据;
  3. 填充完数据后,将数据转换为excel格式的字节数组;

当我们抡完这三板斧之后,剩下的就是将字节数组响应到web导出。

二、核心代码

2.1 pom.xml 依赖配置

<dependencies>
  <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
        <version>2.2.0.RELEASE</version>
    </dependency>

    <dependency>
        <groupId>org.projectlombok</groupId>
        <artifactId>lombok</artifactId>
        <version>1.18.2</version>
    </dependency>


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

        <exclusions>
            <exclusion>
                <groupId>org.slf4j</groupId>
                <artifactId>slf4j-api</artifactId>
            </exclusion>
        </exclusions>
    </dependency>

    <!-- https://mvnrepository.com/artifact/com.google.code.gson/gson -->
    <dependency>
        <groupId>com.google.code.gson</groupId>
        <artifactId>gson</artifactId>
        <version>2.10.1</version>
    </dependency>
</dependencies>

2.2 ExcelHeadMapFactory

表格头映射工厂,提供了注册表格头和获取表格头配置的静态方法。

package org.feng.export.factory;

import org.feng.export.system.ExcelColumnTitle;
import org.feng.export.system.ExcelExportBean;

import java.lang.reflect.Field;
import java.util.HashMap;
import java.util.Map;

/**
 * 表格头映射工厂
 *
 * @author feng
 */
public class ExcelHeadMapFactory {
    /**
     * 全局表头名映射,key内部变量的变量名和中文名映射为value
     */
    private static final Map<Class<?>, Map<String, String>> HEAD_NAME_MAP = new HashMap<>();


    public static void addHeadClass(Class<?> headClass) {
        if (!headClass.isAnnotationPresent(ExcelExportBean.class)) {
            throw new RuntimeException("HeadClass必须使用注解ExcelExportBean");
        }
        HEAD_NAME_MAP.put(headClass, mapToPrepareHead(headClass));
    }

    public static Map<String, String> getHeadMap(Class<?> headClass) {
        return HEAD_NAME_MAP.get(headClass);
    }

    public static boolean containsHeadClass(Class<?> headClass) {
        return HEAD_NAME_MAP.containsKey(headClass);
    }

    private static Map<String, String> mapToPrepareHead(Class<?> excelHeadClass) {
        Map<String, String> namedMap = new HashMap<>();
        Field[] declaredFields = excelHeadClass.getDeclaredFields();
        for (Field declaredField : declaredFields) {
            boolean annotationPresent = declaredField.isAnnotationPresent(ExcelColumnTitle.class);
            if (annotationPresent) {
                ExcelColumnTitle excelProperty = declaredField.getAnnotation(ExcelColumnTitle.class);
                String chineseFieldName = excelProperty.value();
                // 保存字段名和中文变量名
                namedMap.put(declaredField.getName(), chineseFieldName);
            }
        }
        return namedMap;
    }
}

2.3 ExcelDataLinkedHashMap

自定义LinkedHashMap,用于实现字段的顺序,以及动态字段展示。
只提供put 和 putAll方法的实现。想要别的put方法的话,读者请自行实现。

package org.feng.export.system;

import org.springframework.util.CollectionUtils;

import java.util.*;
import java.util.stream.Collectors;

/**
 * 表格数据专用的map,带顺序,而且初始化的时候,依据指定的表头变量字段名确定导出数据的顺序
 *
 * @author feng
 */
public class ExcelDataLinkedHashMap extends LinkedHashMap<String, Object> {

    private static final long serialVersionUID = -8554095999151235982L;

    /**
     * 头部字段名缓存
     */
    private final Set<String> headColumnNamesCache;

    /**
     * 指定被忽略的列名
     */
    private final Set<String> ignoreColumnSet;

    /**
     * ExcelDataLinkedHashMap构造器
     *
     * @param headColumnNames 表头字段变量名,例如:[name,studentNo,age,className]
     */
    public ExcelDataLinkedHashMap(List<Object> headColumnNames, String... ignoreColumns) {
        if (ignoreColumns != null && ignoreColumns.length > 0) {
            ignoreColumnSet = new HashSet<>(Arrays.asList(ignoreColumns));
        } else {
            ignoreColumnSet = Collections.emptySet();
        }
        // 字段名去重
        List<String> headColumnStringNames = headColumnNames.stream().distinct().map(Object::toString).collect(Collectors.toList());
        // 构建字段名缓存
        this.headColumnNamesCache = new HashSet<>(headColumnStringNames);
        // 指定列数据排列顺序
        for (String headColumnName : headColumnStringNames) {
            this.put(headColumnName, null);
        }
    }

    @Override
    public Object put(String key, Object value) {
        // 只保存字段名缓存中的key以及value
        if (headColumnNamesCache.contains(key)) {
            // 设置了被忽略的列,进行判断处理,匹配到了就不保存
            if (!CollectionUtils.isEmpty(ignoreColumnSet) && ignoreColumnSet.contains(key)) {
                return null;
            }
            return super.put(key, value);
        }
        return null;
    }

    @Override
    public void putAll(Map<? extends String, ?> map) {
        map.forEach(this::put);
    }
}

2.4 自定义注解 ExcelExportBean

package org.feng.export.system;

import java.lang.annotation.*;

/**
 * 指定表格导出的bean
 *
 * @author feng
 */
@Documented
@Target({ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
public @interface ExcelExportBean {
}

2.5 自定义注解 ExcelColumnTitle

package org.feng.export.system;

import java.lang.annotation.*;

/**
 * 列名标题注解,标注列的标题
 *
 * @author feng
 */
@Documented
@Target({ElementType.FIELD})
@Retention(RetentionPolicy.RUNTIME)
public @interface ExcelColumnTitle {
    String value();
}

2.6 建造器接口 Builder

package org.feng.export.util;

/**
 * 建造器接口
 *
 * @author feng
 */
public interface Builder<T> {
    T build();
}

2.7 表格工具类 ExcelUtils

提供将数据写入表格的静态方法。

package org.feng.export.util;

import com.alibaba.excel.EasyExcel;
import com.alibaba.excel.write.metadata.WriteSheet;

import java.io.ByteArrayOutputStream;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Map;

/**
 * excel工具类
 *
 * @author feng
 */
public class ExcelUtils {

    /**
     * 导出数据(单sheet)
     *
     * @param exportData key 是sheet名称,value是每个sheet里面的数据,支持自定义表头
     */
    public static byte[] easyOut(List<Map<String, Object>> exportData) {
        return easyOut("Sheet", exportData);
    }

    /**
     * 导出数据(单sheet)
     *
     * @param exportData key 是sheet名称,value是每个sheet里面的数据,支持自定义表头
     */
    public static byte[] easyOut(String sheetName, List<Map<String, Object>> exportData) {
        return easyOut(Collections.singletonMap(sheetName, exportData));
    }

    /**
     * 导出数据(多sheet)
     *
     * @param exportData key 是sheet名称,value是每个sheet里面的数据,可以自定义
     */
    public static byte[] easyOut(Map<String, List<Map<String, Object>>> exportData) {
        // 导出数据
        ByteArrayOutputStream out = new ByteArrayOutputStream();

        com.alibaba.excel.ExcelWriter excelWriter = EasyExcel.write(out).build();
        int i = 0;
        for (Map.Entry<String, List<Map<String, Object>>> entry : exportData.entrySet()) {
            WriteSheet writeSheet = EasyExcel.writerSheet(i, entry.getKey()).head(head(entry.getValue().get(0))).build();
            i++;
            excelWriter.write(data(entry.getValue(), true), writeSheet);
        }
        excelWriter.finish();

        return out.toByteArray();
    }

    private static List<List<String>> head(Map<String, Object> cellData) {
        List<List<String>> head = new ArrayList<>();
        for (String key : cellData.keySet()) {
            head.add(Collections.singletonList(key));
        }
        return head;
    }

    private static List<List<Object>> data(List<Map<String, Object>> sheetData, boolean skipHead) {
        List<List<Object>> data = new ArrayList<>();
        for (int i = 0; i < sheetData.size(); i++) {
            if (i == 0 && skipHead) {
                continue;
            }
            data.add(new ArrayList<>(sheetData.get(i).values()));
        }
        return data;
    }
}

2.8 GsonUtil

gson工具类,提供json处理、转换的静态方法。

package org.feng.export.util;

import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import com.google.gson.JsonElement;
import com.google.gson.JsonParser;
import com.google.gson.reflect.TypeToken;

import java.util.Map;

/**
 * gson工具类
 *
 * @version v1.0
 * @author: fengjinsong
 * @date: 2023年08月26日 15时00分
 */
public class GsonUtil {
    /**
     * 不会序列化空字段的Gson对象
     */
    private static final Gson GSON = new GsonBuilder()
            .create();
    /**
     * 可以序列化空字段的Gson对象
     */
    private static final Gson GSON_WITH_NULL = new GsonBuilder()
            .serializeNulls().create();

    private static final Gson GSON_WITH_DISABLE_HTML_ESCAPING = new GsonBuilder()
            .disableHtmlEscaping().create();

    public static Gson gson() {
        return GSON;
    }

    /**
     * 转换对象为json字符串(不会序列化空字段)
     *
     * @param object 目标对象
     * @return 一个json字符串
     */
    public static String toJson(Object object) {
        return GSON.toJson(object);
    }

    /**
     * 转换对象为json字符串(可以序列化空字段)
     *
     * @param object 目标对象
     * @return 一个json字符串
     */
    public static String toJsonWithNull(Object object) {
        return GSON_WITH_NULL.toJson(object);
    }

    /**
     * 转换对象为json字符串(禁止html转义)
     *
     * @param object 目标对象
     * @return 一个json字符串
     */
    public static String toJsonWithDisableTtmlEscaping(Object object) {
        return GSON_WITH_DISABLE_HTML_ESCAPING.toJson(object);
    }

    public static <T> T fromJson(String jsonStr, Class<T> clazz) {
        return GSON.fromJson(jsonStr, clazz);
    }

    public static Map<String, String> toStringMap(String jsonStr) {
        return GSON.fromJson(jsonStr, new TypeToken<Map<String, String>>() {
        }.getType());
    }

    /**
     * 校验字符串是否是一个json格式
     * <br> 注意:{@code "{}"} 也是符合条件的json
     *
     * @param jsonStr 目标字符串
     * @return true表示目标是一个正确的json格式
     */
    public static boolean validateJson(String jsonStr) {
        JsonElement jsonElement;
        try {
            jsonElement = JsonParser.parseString(jsonStr);
        } catch (Exception e) {
            return false;
        }
        if (jsonElement == null) {
            return false;
        }
        return jsonElement.isJsonObject();
    }
}

2.9 模版类 ExportDynamicColumnTemplate

package org.feng.export;

import lombok.Getter;
import lombok.Setter;
import lombok.extern.slf4j.Slf4j;
import org.feng.export.factory.ExcelHeadMapFactory;
import org.feng.export.system.ExcelDataLinkedHashMap;
import org.feng.export.util.ExcelUtils;
import org.feng.export.util.GsonUtil;
import org.springframework.util.Assert;
import org.springframework.util.CollectionUtils;
import org.springframework.util.StringUtils;

import java.util.*;

/**
 * 导出动态列模版
 *
 * @author feng
 */
@Slf4j
@Getter
public class ExportDynamicColumnTemplate<T> {

    /**
     * 导出的bean类型
     */
    private Class<T> exportBean;

    /**
     * 导出的数据
     */
    private List<Map<String, Object>> exportData;

    /**
     * 当前表头映射
     */
    private Map<String, Object> currentHeadMap;

    /**
     * 真正导出的列名
     */
    private List<Object> realExportColumnNameList;

    /**
     * 是否转换前打印导出数据(含表头)
     */
    @Setter
    private boolean logExportData;

    /**
     * 是否转换前打印实际的表头信息
     */
    @Setter
    private boolean logCurrentHead;

    ExportDynamicColumnTemplate(Class<T> exportBean, List<String> columnNameList) {
        this(exportBean, columnNameList, false, true);
    }

    ExportDynamicColumnTemplate(Class<T> exportBean, List<String> columnNameList, boolean logExportData, boolean logCurrentHead) {
        init(exportBean, columnNameList);
        this.logExportData = logExportData;
        this.logCurrentHead = logCurrentHead;
    }

    /**
     * 追加导出数据
     *
     * @param data          数据
     * @param ignoreColumns 设置忽略的列名
     */
    public void appendExportData(T data, String... ignoreColumns) {
        Objects.requireNonNull(data);
        ExcelDataLinkedHashMap dataMap = new ExcelDataLinkedHashMap(realExportColumnNameList, ignoreColumns);
        exportData.add(dataMap);
        String dataJson = GsonUtil.toJson(data);
        dataMap.putAll(GsonUtil.toStringMap(dataJson));
    }

    /**
     * 追加导出数据列表
     *
     * @param dataList      数据列表
     * @param ignoreColumns 设置忽略的列名
     */
    public void appendExportDataList(List<T> dataList, String... ignoreColumns) {
        if (CollectionUtils.isEmpty(dataList)) {
            return;
        }
        dataList.forEach(data -> appendExportData(data, ignoreColumns));
    }

    /**
     * 将当前需要导出的数据转换为表格的字节数组
     *
     * @return 字节数组
     */
    public byte[] toExcelByteArray() {
        return toExcelByteArray("Sheet");
    }

    /**
     * 将当前需要导出的数据转换为表格的字节数组
     *
     * @param sheetName 指定sheet名
     * @return 字节数组
     */
    public byte[] toExcelByteArray(String sheetName) {
        if (logExportData) {
            logExportData();
        }
        if (logCurrentHead) {
            log.info("导出数据表头信息:{}", currentHeadMap);
        }
        return ExcelUtils.easyOut(sheetName, exportData);
    }


    private void logExportData() {
        for (int i = 0; i < exportData.size(); i++) {
            Map<String, Object> data = exportData.get(i);
            log.info("导出数据[{}]:{}", i, data);
        }
    }

    /**
     * 初始化导出模版信息,主要是表头信息
     *
     * @param exportBean     导出bean的类型
     * @param columnNameList 需要导出的字段名列表
     */
    private void init(Class<T> exportBean, List<String> columnNameList) {
        this.exportBean = exportBean;
        exportData = new ArrayList<>();
        currentHeadMap = new LinkedHashMap<>();

        // 获取代码配置的表头信息
        if (!ExcelHeadMapFactory.containsHeadClass(exportBean)) {
            ExcelHeadMapFactory.addHeadClass(exportBean);
        }
        Map<String, String> headMap = ExcelHeadMapFactory.getHeadMap(exportBean);
        Assert.notEmpty(headMap, "表头不能为空,请检查exportBean的类型");

        // 初始化真正的表头信息,过滤无效配置或找不到的列名
        realExportColumnNameList = new ArrayList<>();
        for (String field : columnNameList) {
            String fieldChineseName = headMap.get(field);
            if (StringUtils.isEmpty(fieldChineseName)) {
                log.info("代码配置的导出表头不完整,不存在字段:{}", field);
            } else {
                currentHeadMap.put(fieldChineseName, field);
                realExportColumnNameList.add(field);
            }
        }

        // 记录当前的表头信息
        exportData.add(currentHeadMap);
    }
}

2.10 模版建造器 ExportDynamicColumnTemplateBuilder

用于生成模版实例。

package org.feng.export;

import lombok.AccessLevel;
import lombok.Setter;
import lombok.experimental.Accessors;
import org.feng.export.system.ExcelExportBean;
import org.feng.export.util.Builder;
import org.springframework.util.Assert;

import java.util.List;

/**
 * 导出动态列模版建造器
 *
 * @author feng
 */
@Accessors(chain = true, fluent = true)
@Setter
public class ExportDynamicColumnTemplateBuilder<T> implements Builder<ExportDynamicColumnTemplate<T>> {

    /**
     * 字段名列表
     */
    private List<String> columnNameList;

    /**
     * 导出的bean类型
     */
    @Setter(AccessLevel.NONE)
    private final Class<T> exportBean;

    /**
     * 是否转换前打印导出数据(含表头)
     */
    private Boolean logExportData;
    /**
     * 是否转换前打印实际的表头信息
     */
    private Boolean logCurrentHead;

    public ExportDynamicColumnTemplateBuilder(Class<T> exportBean) {
        if (!exportBean.isAnnotationPresent(ExcelExportBean.class)) {
            throw new RuntimeException("ExportBean必须使用注解ExcelExportBean");
        }
        this.exportBean = exportBean;
    }

    @Override
    public ExportDynamicColumnTemplate<T> build() {
        check();

        ExportDynamicColumnTemplate<T> template = new ExportDynamicColumnTemplate<>(exportBean, columnNameList);
        if(logCurrentHead != null) {
            template.setLogCurrentHead(logCurrentHead);
        }
        if(logExportData != null) {
            template.setLogExportData(logExportData);
        }
        return template;
    }

    private void check() {
        Assert.notNull(exportBean, "导出的实例类型不能为空");
        Assert.notEmpty(columnNameList, "字段名列表不能为空");
    }
}

三、控制器&调试Bean的定义

这一部分是非核心代码,属于对核心代码使用的一种演示。读者可以按照对应的写法来实现功能。

注意:导出的bean定义,需要使用注解 ExcelExportBean,其中的字段需要使用注解ExcelColumnTitle

3.1 StudentDemo

package org.feng.headbean;

import org.feng.export.system.ExcelColumnTitle;
import lombok.Data;
import org.feng.export.system.ExcelExportBean;

/**
 * 学生demo导出bean
 *
 * @author feng
 */
@Data
@ExcelExportBean
public class StudentDemo {
    @ExcelColumnTitle("姓名")
    private String name;
    @ExcelColumnTitle("年龄")
    private String age;
    @ExcelColumnTitle("性别")
    private String sex;
    @ExcelColumnTitle("学号")
    private String studentNo;
    @ExcelColumnTitle("班级")
    private String className;
}

3.2 NameAndFactoryDemo

该类对本次演示无实际意义,保留着是因为,需要演示加载多个配置表头的实例时的写法。
具体的可以查看启动类ExcelDemoApplication中的内容。

package org.feng.headbean;

import org.feng.export.system.ExcelColumnTitle;
import lombok.Data;
import org.feng.export.system.ExcelExportBean;

/**
 * TODO
 *
 * @author feng
 */
@Data
@ExcelExportBean
public class NameAndFactoryDemo {
    @ExcelColumnTitle("名字")
    private String name;
    @ExcelColumnTitle("工厂")
    private String factory;
}

3.3 控制器 ExcelDemoController

package org.feng.controller;

import org.feng.export.ExportDynamicColumnTemplate;
import org.feng.export.ExportDynamicColumnTemplateBuilder;
import org.feng.headbean.StudentDemo;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;

import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;

@Controller
@RequestMapping("/excel")
public class ExcelDemoController {

    @GetMapping("/exportDy")
    public String exportDy(@RequestParam("table") List<String> table, HttpServletResponse response) throws IOException {

        // 构造导出模版
        ExportDynamicColumnTemplate<StudentDemo> dynamicColumnTemplate = new ExportDynamicColumnTemplateBuilder<>(StudentDemo.class)
                .columnNameList(table)
                .build();
        // 制造假数据
        dynamicColumnTemplate.appendExportDataList(studentDemos());

        // 转换为excel的字节数组
        byte[] bytes = dynamicColumnTemplate.toExcelByteArray();

        // 响应到web
        String fileName = System.currentTimeMillis() + ".xlsx";
        response.setHeader("Content-disposition", "attachment;filename=" + fileName);
        response.setContentType("application/x-msdownload");
        response.setCharacterEncoding("utf-8");
        response.getOutputStream().write(bytes);
        response.getOutputStream().flush();

        return "success";
    }

    private List<StudentDemo> studentDemos() {
        List<StudentDemo> studentDemos = new ArrayList<>();
        for (int i = 0; i < 5; i++) {
            StudentDemo studentDemo = new StudentDemo();
            studentDemo.setStudentNo(100 + "-" + i);
            studentDemo.setAge(String.valueOf(20 + i));
            studentDemo.setSex(i > 2 ? "男" : "女");
            studentDemo.setClassName("一班");
            studentDemo.setName("小米" +(i+1));
            studentDemos.add(studentDemo);
        }

        return studentDemos;
    }
}

四、启动类

启动项目时,加载代码配置信息。

package org.feng;

import org.feng.export.factory.ExcelHeadMapFactory;
import org.feng.headbean.NameAndFactoryDemo;
import org.feng.headbean.StudentDemo;
import org.springframework.boot.CommandLineRunner;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

import java.util.ArrayList;
import java.util.List;

@SpringBootApplication
public class ExcelDemoApplication implements CommandLineRunner {

    public static void main(String[] args) {
        SpringApplication.run(ExcelDemoApplication.class, args);
    }

    @Override
    public void run(String... args) throws Exception {
        List<Class<?>> needRegisterExcelHeadClassList = new ArrayList<>();
        needRegisterExcelHeadClassList.add(NameAndFactoryDemo.class);
        needRegisterExcelHeadClassList.add(StudentDemo.class);
        needRegisterExcelHeadClassList.forEach(ExcelHeadMapFactory::addHeadClass);
    }
}

五、测试

在谷歌浏览器访问:
http://localhost:8080/excel/exportDy?table=name,className,studentNo

会下载得到这样的文件:
在这里插入图片描述