知识 - EasyExcel
# 前言
EasyExcel 是阿里巴巴开源的一款专注于解决大数据量 Excel 导入导出场景的 Java 类库。相较于传统的 Apache POI 等库,EasyExcel 在设计上注重性能优化和降低内存开销,特别是在处理包含大量数据的 Excel 文件时表现突出。
# EasyExcel
核心特点与原理:
高性能与低内存占用
EasyExcel 采用逐行读写的方式处理 Excel 文件,而不是一次性加载整个文件到内存中,这样能够有效避免处理大型文件时出现内存溢出问题。
底层运用 Java NIO(非阻塞 IO)技术提高读写效率,尤其是在导入时,通过事件驱动模型(Observer Pattern),每解析完一行数据就会触发一次事件通知,进而处理这一行数据,然后释放内存,以此循环直至文件结束,大大降低了内存消耗。
异步处理
EasyExcel支持异步导入导出,通过多线程异步处理机制,可以显著提升处理速度,非常适合批处理任务。
简单易用的 API
提供了简洁的 API 接口,开发者无需了解过多的 Excel 底层细节,就可以方便地进行数据的读取和写入。
功能丰富
不仅支持基本的数据读写,还支持复杂功能,例如合并单元格、数据验证、自定义样式等。
支持自定义数据转换器(Converter),用于处理自定义类型的转换。
提供监听器(AnalysisEventListener)机制,可以在解析过程中执行自定义逻辑,比如数据库操作、数据校验等。
扩展性强
用户可以根据需求自定义监听器来处理特定业务逻辑,框架具有很好的灵活性和可扩展性。
EasyExcel 通过精心设计的内存管理和事件驱动模型,实现了对大规模 Excel 数据处理场景的良好支持,是企业级应用和大数据分析中进行 Excel 数据处理的理想选择。
# Apache POI
Apache POI 是由 Apache 软件基金会开发和维护的一个开源项目,其全称为 「Poor Obfuscation Implementation」,但实际上这个名字更多是一种幽默的说法,并不是项目初衷的正式描述。Apache POI 是一套 Java API,专门用来处理 Microsoft Office 格式的文件,特别是对于 Excel (.xls, .xlsx), Word (.doc, .docx), PowerPoint (.ppt, .pptx) 等格式的支持非常全面。
主要功能与原理:
读写功能:
Apache POI 提供了丰富的 API 来读取和创建这些文件格式,允许 Java 开发者直接在代码中打开、修改和保存 Office 文档,而不需启动实际的 Office 应用程序。
组件结构
对于 Excel 文件,POI 包含两个主要组件:HSSF(Horrible Spreadsheet Format,处理老版 .xls 文件)和 XSSF(XML SpreadSheet Format,处理新版 .xlsx 文件,基于 Office Open XML 标准)。
类似地,对于 Word 和 PowerPoint 也有相应的组件,如 HWPF 和 XWPF。
内部工作原理
POI 对 Office 文件格式进行了详细的逆向工程,理解并实现了它们复杂的二进制或 XML 内部结构。
当读取 Excel 文件时,POI 解析文件的内容,将其转化为一系列 Java 对象(如 HSSFRow、HSSFSheet、HSSFWorkbook 等),这些对象封装了表格数据和样式信息。
在写入时,POI 则根据 Java 对象构建出符合 Office 文件格式规范的数据流,从而生成有效的 Office 文档。
内存管理
尽管 POI 努力优化内存使用,但处理大文件时仍可能面临内存压力。尤其是处理大数据量的 Excel 文件时,POI 通常建议采用流式处理(Streaming User Model)以降低内存消耗,即逐行读写数据而不是一次性加载所有数据到内存中。
Apache POI 是一个强大的工具集,使得 Java 开发者能够在不需要安装 Microsoft Office 的环境下进行 Office 文件的编程操作,广泛应用于数据迁移、报表生成、数据分析等各种业务场景中。
# 两者对比
EasyExcel 和 Apache POI 都是 Java 中用于处理 Excel 文件的流行库,但它们在设计目标、性能和易用性上存在一定的差异:
设计理念与性能优化
Apache POI 是一个全面的 Office 文件处理库,对于 Excel 文件有非常细致和完整的操作支持,适用于各种复杂场景。然而,由于其原始设计并未专门针对大数据量和低内存消耗进行优化,在处理大规模数据时可能会遇到内存溢出(OOM)的问题。
EasyExcel 是阿里巴巴开源的一个轻量级框架,建立在 Apache POI 的基础之上,重点在于解决大数据量下的内存效率问题。它采用流式处理机制,仅逐行读写数据,极大地减少了内存消耗。这意味着在处理超大 Excel 文件时,EasyExcel 性能表现更好,更适合资源受限的环境。
API 易用性
Apache POI 的 API 较为底层和繁琐,需要开发者手动管理行、列、单元格等对象,对于简单操作可能显得不够简洁。
EasyExcel提供了更为简洁和友好的 API,通过事件驱动模型简化了读写逻辑,同时也支持自定义注解进行数据映射,使开发者能够快速编写出简洁高效的代码。
灵活性与扩展性
Apache POI 提供的功能全面,能够应对各种定制化需求,适合对 Excel 文件有深度定制和精细控制的场景。
EasyExcel 主要针对常规的读写场景进行了优化,虽然牺牲了一定的灵活性,但对于大多数常见业务需求,它提供的功能已经足够强大,并且因其高效和易用性受到很多开发者的青睐。
# 实际应用选择
如果项目中需要处理的 Excel 文件较大,或者对内存消耗敏感,EasyExcel 是更好的选择,因为它能有效避免 OOM 并提高处理速度。
如果需要处理多种 Office 文件格式,或者进行复杂的单元格样式、公式等高级操作,Apache POI 更具优势,因为它的功能覆盖范围更广。
若注重开发效率和易用性,且业务场景相对简单,EasyExcel 的简洁API和良好的文档可以显著减少开发时间和成本。
# SpringBoot 集成
虽然 EasyExcel 已经提供了非常简易的 API 使用,但是实际场景还是需要对 EasyExcel 的 API 进行简易封装,比如字典导出,如数据库存的 0、1,导出的值为启用、禁用等。
<dependencies>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.18.32</version>
</dependency>
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>easyexcel</artifactId>
<version>3.3.4</version>
</dependency>
</dependencies>
2
3
4
5
6
7
8
9
10
11
12
# 注解
首先自定义一些注解:
枚举格式化注解
标注在实体类的属性上,指定属性使用的枚举类。
@Target({ElementType.FIELD})
@Retention(RetentionPolicy.RUNTIME)
@Inherited
public @interface ExcelEnumFormat {
/**
* 字典枚举类型
*/
Class<? extends Enum<?>> enumClass();
/**
* 字典枚举类 Value
*/
String valueField() default "value";
/**
* 字典枚举类 label
*/
String labelField() default "label";
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
这样就可以从枚举类获取 value、label,实现数据库存储的 value 和导出 Excel 的 label 进行快速转换。
简单列举使用实例:
@Data
public class ExportDemoVO {
/**
* 用户类型
*/
@ExcelProperty(value = "用户类型", converter = ExcelEnumConvert.class)
@ExcelEnumFormat(enumClass = UserStatus.class, labelField = "info")
private String userStatus;
@Getter
@AllArgsConstructor
public enum UserStatus {
/**
* 正常
*/
OK("0", "正常"),
/**
* 停用
*/
DISABLE("1", "停用"),
/**
* 删除
*/
DELETED("2", "删除");
private final String value;
private final String info;
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
当 userStatus 为 0,导出是正常,1 是停用,2 是删除,
ExcelEnumConvert
是什么,请看 转换去 Converter。
字典格式化注解
除此之外,我们不想为简单的映射写一个枚举类,如 0 为禁用,1 为启用,仅仅两个映射关系,写一个枚举类不太合适,那么就有另一个注解:
@Target({ElementType.FIELD})
@Retention(RetentionPolicy.RUNTIME)
@Inherited
public @interface ExcelDictFormat {
/**
* 自定义实现字典数据处理类
*/
Class<? extends ExcelDictHandler> handler() default DefaultExcelDictHandler.class;
/**
* 是否缓存 自定义实现字典数据处理类 返回的数据(如果处理类涉及数据库操作,建议开启 true)
*/
boolean cacheHandlerReturn() default true;
/**
* 读取内容转表达式 (如: 0:男, 1:女, 2:未知)
*/
String readExp() default "";
/**
* key 和 value 的映射符
*/
String mappingKey() default ":";
/**
* 分隔符,读取字符串组内容,可以加空格,如 Y:是, N:否
*/
String separator() default ",";
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
简单列举使用实例:
@Data
public class SysDictDataVO {
@ExcelProperty(value = "是否默认", converter = ExcelDictConvert.class)
@ExcelDictFormat(readExp = "Y:是, N:否")
private String isDefault;
}
2
3
4
5
6
7
这样 isDefault
是 Y,那么导出 Excel 的就是是,N 为 否。
当然可以使用 handler
来自定义类返回数据,如:
@Data
public class SysDictDataVO {
@ExcelProperty(value = "性别", converter = ExcelDictConvert.class)
@ExcelDictFormat(handler = UserSexHandler.class)
private Integer sex;
}
2
3
4
5
6
7
UserSexHandler
public class UserSexHandler implements ExcelDictHandler {
@Override
public String getLabel(String value) {
if (value.equals("1")) {
return "男";
} else if (value.equals("2")) {
return "女";
}
return "保密";
}
@Override
public String getValue(String label) {
if (label.equals("男")) {
return "1";
} else if (label.equals("女")) {
return "2";
}
return "3";
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
UserSexHandler
实现的 ExcelDictHandler
是必须的,内置 getLabel
、getValue
两个方法,分别返回 Excel 的 label 和 Java 存储的 value。
Excel 列单元格合并(合并列相同项)
@Target(ElementType.FIELD)
@Retention(RetentionPolicy.RUNTIME)
@Inherited
public @interface CellMerge {
/**
* col index
*/
int index() default -1;
/**
* 合并需要依赖的其他字段名称
*/
String[] mergeBy() default {};
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# 转换器 Converter
上面 ExcelDictFormat
、ExcelEnumFormat
注解使用实例中,ExcelProperty
注解的 converter 属性是必传的,且传入的类(转换器)是固定的,下面给出两个的代码。
首先在 Java 类的属性上使用了注解,那么就必须要有处理注解的类,也就是转换器 Converter
类,它会扫描出使用了注解的属性,然后在导出或者导入的时候进行拦截处理。
这需要实现 Converter 接口,它是 EasyExcel 提供的,在导入导出每个属性的时候,会调用实现了 Converter
接口的类里的方法。
ExcelEnumConvert
枚举格式化转换处理类
处理 ExcelEnumFormat
注解
public class ExcelEnumConvert implements Converter<Object> {
@Override
public Class<Object> supportJavaTypeKey() {
return Object.class;
}
@Override
public CellDataTypeEnum supportExcelTypeKey() {
return null;
}
@Override
public Object convertToJavaData(ReadCellData<?> cellData, ExcelContentProperty contentProperty, GlobalConfiguration globalConfiguration) throws Exception {
cellData.checkEmpty();
// Excel 中填入的是枚举中指定的描述
Object textValue = switch (cellData.getType()) {
case STRING, DIRECT_STRING, RICH_TEXT_STRING -> cellData.getStringValue();
case NUMBER -> cellData.getNumberValue();
case BOOLEAN -> cellData.getBooleanValue();
default -> throw new IllegalArgumentException("单元格类型异常!");
};
// 如果是空值
if (ObjectUtil.isNull(textValue)) {
return null;
}
Map<Object, String> enumCodeToTextMap = beforeConvert(contentProperty);
// 输出至 Excel 是 value 转 label
Map<Object, Object> enumTextToCodeMap = new HashMap<>();
enumCodeToTextMap.forEach((key, value) -> enumTextToCodeMap.put(value, key));
// 从 label -> value 中查找
Object value = enumTextToCodeMap.get(textValue);
return Convert.convert(contentProperty.getField().getType(), value);
}
@Override
public WriteCellData<String> convertToExcelData(Object object, ExcelContentProperty contentProperty, GlobalConfiguration globalConfiguration) {
if (ObjectUtil.isNull(object)) {
return new WriteCellData<>("");
}
Map<Object, String> enumValueMap = beforeConvert(contentProperty);
String value = String.valueOf(enumValueMap.get(object));
return new WriteCellData<>(Optional.ofNullable(value).orElse(""));
}
/**
* 获取 enum 类对应的 value 和 label
*/
private Map<Object, String> beforeConvert(ExcelContentProperty contentProperty) {
ExcelEnumFormat excelEnumFormat = getAnnotation(contentProperty.getField());
Map<Object, String> enumValueMap = new HashMap<>();
Enum<?>[] enumConstants = excelEnumFormat.enumClass().getEnumConstants();
for (Enum<?> enumConstant : enumConstants) {
Object value = ReflectUtil.invokeGetter(enumConstant, excelEnumFormat.valueField());
String label = ReflectUtil.invokeGetter(enumConstant, excelEnumFormat.labelField());
enumValueMap.put(value, label);
}
return enumValueMap;
}
private ExcelEnumFormat getAnnotation(Field field) {
return AnnotationUtil.getAnnotation(field, ExcelEnumFormat.class);
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
ExcelDictConvert
字典格式化转换处理
处理 ExcelDictFormat
注解
public class ExcelDictConvert implements Converter<Object> {
@Override
public Class<Object> supportJavaTypeKey() {
return Object.class;
}
@Override
public CellDataTypeEnum supportExcelTypeKey() {
return null;
}
@Override
public Object convertToJavaData(ReadCellData<?> cellData, ExcelContentProperty contentProperty, GlobalConfiguration globalConfiguration) {
cellData.checkEmpty();
Field field = contentProperty.getField();
ExcelDictFormat excelDictFormat = getAnnotation(field);
String originValue = cellData.getStringValue();
// 先从 Spring 容器获取
ExcelDictHandler excelDictHandler = SpringHelper.getBean(excelDictFormat.handler());
if(Objects.isNull(excelDictHandler)) {
// Spring 容器获取失败,则手动调用无参构造器创建
excelDictHandler = ReflectUtil.newInstance(excelDictFormat.handler());
}
if (Objects.isNull(excelDictHandler)) {
String value = ExcelHelper.reverseValueByExp(originValue, excelDictFormat.readExp(), excelDictFormat.mappingKey(), excelDictFormat.separator());
return Convert.convert(field.getType(), value);
}
if (excelDictFormat.cacheHandlerReturn()) {
// 从缓存取出 value
ExcelDictManager.Dict dict = ExcelDictManager.get(field);
if (Objects.nonNull(dict) && Objects.nonNull(dict.getValue())) {
return Convert.convert(field.getType(), dict.getValue());
}
// 如果没有,则读取 excelDictHandler#getValue 返回的值作为 value 缓存
String value = excelDictHandler.getValue(originValue);
if (Objects.isNull(dict)) {
dict = new ExcelDictManager.Dict();
dict.setValue(value);
ExcelDictManager.set(field, dict);
}
if (Objects.isNull(dict.getValue())) {
dict.setValue(value);
ExcelDictManager.set(field, dict);
}
return Convert.convert(field.getType(), value);
}
return Convert.convert(field.getType(), excelDictHandler.getValue(originValue));
}
@Override
public WriteCellData<String> convertToExcelData(Object value, ExcelContentProperty contentProperty, GlobalConfiguration globalConfiguration) throws Exception {
if (ObjectUtil.isNull(value)) {
return new WriteCellData<>("");
}
Field field = contentProperty.getField();
ExcelDictFormat excelDictFormat = getAnnotation(field);
String originValue = Convert.toStr(value);
// 先从 Spring 容器获取
ExcelDictHandler excelDictHandler = SpringHelper.getBeanIfPresent(excelDictFormat.handler());
if(Objects.isNull(excelDictHandler)) {
// Spring 容器获取失败,则手动调用无参构造器创建
excelDictHandler = ReflectUtil.newInstance(excelDictFormat.handler());
}
if (Objects.isNull(excelDictHandler) || excelDictHandler instanceof DefaultExcelDictHandler) {
String label = ExcelHelper.parseValueByExp(originValue, excelDictFormat.readExp(), excelDictFormat.mappingKey(), excelDictFormat.separator());
return new WriteCellData<>(label);
}
if (excelDictFormat.cacheHandlerReturn()) {
// 从缓存取出 label
ExcelDictManager.Dict dict = ExcelDictManager.get(field);
if (Objects.nonNull(dict) && Objects.nonNull(dict.getLabel())) {
return new WriteCellData<>(dict.getLabel());
}
// 如果没有,则读取 excelDictHandler#getLabel 返回的值作为 label 缓存
String label = excelDictHandler.getLabel(originValue);
if (Objects.isNull(dict)) {
dict = new ExcelDictManager.Dict();
dict.setLabel(label);
ExcelDictManager.set(field, dict);
}
if (Objects.isNull(dict.getLabel())) {
dict.setLabel(label);
ExcelDictManager.set(field, dict);
}
return new WriteCellData<>(label);
}
return new WriteCellData<>(excelDictHandler.getLabel(originValue));
}
private ExcelDictFormat getAnnotation(Field field) {
return AnnotationUtil.getAnnotation(field, ExcelDictFormat.class);
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
ExcelBigNumberConvert
处理大数值类型的属性
将大数值转换,Excel 数值长度位 15 位,大于 15 位的数值转换位字符串。
public class ExcelBigNumberConvert implements Converter<Long> {
@Override
public Class<Long> supportJavaTypeKey() {
return Long.class;
}
@Override
public CellDataTypeEnum supportExcelTypeKey() {
return CellDataTypeEnum.STRING;
}
@Override
public Long convertToJavaData(ReadCellData<?> cellData, ExcelContentProperty contentProperty, GlobalConfiguration globalConfiguration) {
return Convert.toLong(cellData.getData());
}
@Override
public WriteCellData<Object> convertToExcelData(Long object, ExcelContentProperty contentProperty, GlobalConfiguration globalConfiguration) {
if (ObjectUtil.isNotNull(object)) {
String str = Convert.toStr(object);
if (str.length() > 15) {
return new WriteCellData<>(str);
}
}
WriteCellData<Object> cellData = new WriteCellData<>(new BigDecimal(object));
cellData.setType(CellDataTypeEnum.NUMBER);
return cellData;
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
ExcelClassConvert
处理属性为引用类型的属性
Java 自定义类转换。使用 Jackson 转为 string 字符串。只支持 Java 转 Excel,不支持 Excel 转 Java
public class ExcelClassConvert implements Converter<Object> {
@Override
public Class<Object> supportJavaTypeKey() {
return Object.class;
}
@Override
public CellDataTypeEnum supportExcelTypeKey() {
return null;
}
@Override
public Object convertToJavaData(ReadCellData<?> cellData, ExcelContentProperty contentProperty, GlobalConfiguration globalConfiguration) {
cellData.checkEmpty();
// 转为 Map,因此实体类使用该 converter 时,属性必须为 Map
return JacksonUtil.toJson(cellData.getStringValue(), Map.class);
}
@Override
public WriteCellData<String> convertToExcelData(Object value, ExcelContentProperty contentProperty, GlobalConfiguration globalConfiguration) {
if (ObjectUtil.isNull(value)) {
return new WriteCellData<>("");
}
return new WriteCellData<>(JacksonUtil.toJsonStr(value));
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
这里给出了四个类,前两个是处理加自定义注解在属性的类,后两个是处理指定类型属性的类。
这些 Converter 类必须在 ExcelProperty
注解的 converter 属性上使用,这样实体类的属性在导入导出才会调用自定义的 Converter 类,否则使用 EasyExcel 内置的 Converter 类。
# 字典缓存
针对上面 ExcelDictFormat
注解里的 handler 属性,我们需要自定义类实现 ExcelDictHandler
接口来实现 getLabel
、getValue
方法返回字典。
但是注意的是,一行数据就会触发一次这两个方法,假设方法里是从数据库读取,那么 1000 行数据导出,就会有 1000 次与数据库交互,这明显不好,因此需要缓存,这样下一次执行方法前,先判断是否可以从缓存读取,可以的就不需要触发方法。
首先给出 ExcelDictHandler
接口
public interface ExcelDictHandler {
/**
* Java 导出为 Excel 时候调用,将返回的值作为 Excel 展示值
*
* @param value Java 属性值
* @return 展示的值
*/
String getLabel(String value);
/**
* Excel导入为 Java 时候调用,将返回的值作为 Java 属性值
*
* @param label Excel 值
* @return 展示的值
*/
String getValue(String label);
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
默认实现 ExcelDictHandler
接口的基础类 DefaultExcelDictHandler
public class DefaultExcelDictHandler implements ExcelDictHandler{
@Override
public String getLabel(String value) {
return "";
}
@Override
public String getValue(String label) {
return "";
}
}
2
3
4
5
6
7
8
9
10
11
12
13
缓存管理类 ExcelDictManager
@NoArgsConstructor(access = AccessLevel.PRIVATE)
public class ExcelDictManager {
private static final ThreadLocal<Map<Field, Dict>> FIELD_DICT_CACHE = ThreadLocal.withInitial(ConcurrentHashMap::new);
public static void set(Field field, Dict dict) {
FIELD_DICT_CACHE.get().put(field, dict);
}
public static Dict get(Field field) {
return FIELD_DICT_CACHE.get().get(field);
}
public static Map<Field, Dict> getAll() {
return FIELD_DICT_CACHE.get();
}
public static void remove() {
FIELD_DICT_CACHE.remove();
}
@Data
public static class Dict {
private String value;
private String label;
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
ExcelDictManager
的使用就在 ExcelDictConvert
类里。
# 监听器
使用 EasyExcel 的 API 都知道,导入的时候需要实现监听器,来监听每一笔导入到 Java 成功的数据。
这里实现默认的监听器,然后存储导入的所有的数据,后续可以通过提供的 API 获取到。
ExcelResult
接口:调用 Excel 导入后数据的返回方法
public interface ExcelResult<T> {
/**
* 对象列表
*/
List<T> getList();
/**
* 错误列表
*/
List<String> getErrorList();
/**
* 导入结果分析
*/
String getAnalysisResult();
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
默认实现 ExcelResult
接口的 BaseExcelResult
类
@Setter
public class BaseExcelResult<T> implements ExcelResult<T> {
/**
* 数据对象 list
*/
private List<T> list;
/**
* 错误信息列表
*/
private List<String> errorList;
public BaseExcelResult() {
this.list = new ArrayList<>();
this.errorList = new ArrayList<>();
}
public BaseExcelResult(List<T> list, List<String> errorList) {
this.list = list;
this.errorList = errorList;
}
public BaseExcelResult(ExcelResult<T> excelResult) {
this.list = excelResult.getList();
this.errorList = excelResult.getErrorList();
}
@Override
public List<T> getList() {
return list;
}
@Override
public List<String> getErrorList() {
return errorList;
}
@Override
public String getAnalysisResult() {
int successCount = list.size();
int errorCount = errorList.size();
if (successCount == 0) {
return "读取失败,未解析到数据";
} else {
if (errorCount == 0) {
return StrUtil.format("恭喜您,全部读取成功!共{}条", successCount);
} else {
return "";
}
}
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
实现一个监听接口 ExcelListener
,该接口继承了 EasyExcel 提供的监听接口,目的是在 EasyExcel 监听上进行扩展
public interface ExcelListener<T> extends ReadListener<T> {
/**
* 获取 Excel 解析结果
*/
ExcelResult<T> getExcelResult();
/**
* 删除 Dict 缓存
*/
default void removeExcelDictCache() {
ExcelDictManager.remove();
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
最后实现默认的监听处理类 BaseExcelListener
,该类将存储导入的所有数据以及周边数据:
@NoArgsConstructor
@Slf4j
public class BaseExcelListener<T> extends AnalysisEventListener<T> implements ExcelListener<T> {
/**
* 是否 Validator 检验,默认为是
*/
private boolean isValidate = Boolean.TRUE;
/**
* excel 表头数据
*/
private Map<Integer, String> headMap;
/**
* 导入回执
*/
private ExcelResult<T> excelResult;
public BaseExcelListener(boolean isValidate) {
this.excelResult = new BaseExcelResult<>();
this.isValidate = isValidate;
}
/**
* 处理异常
*
* @param exception ExcelDataConvertException
* @param context Excel 上下文
*/
@Override
public void onException(Exception exception, AnalysisContext context) throws Exception {
String errMsg = null;
if (exception instanceof ExcelDataConvertException excelDataConvertException) {
// 如果是某一个单元格的转换异常 能获取到具体行号
Integer rowIndex = excelDataConvertException.getRowIndex();
Integer columnIndex = excelDataConvertException.getColumnIndex();
errMsg = StrUtil.format("第 {} 行 - 第 {} 列 - 表头 {}: 解析异常<br/>",
rowIndex + 1, columnIndex + 1, headMap.get(columnIndex));
if (log.isDebugEnabled()) {
log.error(errMsg);
}
}
// 校验异常
if (exception instanceof ConstraintViolationException constraintViolationException) {
Set<ConstraintViolation<?>> constraintViolations = constraintViolationException.getConstraintViolations();
String constraintViolationsMsg = constraintViolations.stream().map(ConstraintViolation::getMessage).filter(Objects::nonNull).collect(Collectors.joining(", "));
errMsg = StrUtil.format("第 {} 行数据校验异常: {}", context.readRowHolder().getRowIndex() + 1, constraintViolationsMsg);
if (log.isDebugEnabled()) {
log.error(errMsg);
}
}
excelResult.getErrorList().add(errMsg);
throw new ExcelAnalysisException(errMsg);
}
@Override
public void invokeHeadMap(Map<Integer, String> headMap, AnalysisContext context) {
this.headMap = headMap;
log.debug("解析到一条表头数据: {}", headMap.toString());
}
@Override
public void invoke(T data, AnalysisContext context) {
if (isValidate) {
ValidatorUtil.validate(data);
}
excelResult.getList().add(data);
}
@Override
public void doAfterAllAnalysed(AnalysisContext context) {
removeExcelDictCache();
log.debug("所有数据解析完成!");
}
@Override
public ExcelResult<T> getExcelResult() {
return excelResult;
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
# Excel 下拉
如果导出的是枚举类,那么希望 Excel 里自动实现下拉功能,这样我们就可以选择字典数据,而不是输入数据。
下拉可选项类 DropDownOptions,注意:为确保下拉框解析正确,传值务必使用 createOptionValue()
做为值的拼接。
@Data
@AllArgsConstructor
@NoArgsConstructor
public class DropDownOptions {
/**
* 一级下拉所在列index,从0开始算
*/
private int index = 0;
/**
* 二级下拉所在的index,从0开始算,不能与一级相同
*/
private int nextIndex = 0;
/**
* 一级下拉所包含的数据
*/
private List<String> options = new ArrayList<>();
/**
* 二级下拉所包含的数据Map
* <p>以每一个一级选项值为Key,每个一级选项对应的二级数据为Value</p>
*/
private Map<String, List<String>> nextOptions = new HashMap<>();
/**
* 分隔符
*/
private static final String DELIMITER = "_";
/**
* 创建只有一级的下拉选
*/
public DropDownOptions(int index, List<String> options) {
this.index = index;
this.options = options;
}
/**
* <h2>创建每个选项可选值</h2>
* <p>注意:不能以数字,特殊符号开头,选项中不可以包含任何运算符号</p>
*
* @param vars 可选值内包含的参数
* @return 合规的可选值
*/
public static String createOptionValue(Object... vars) {
StringBuilder stringBuffer = new StringBuilder();
String regex = "^[\\S\\d\\u4e00-\\u9fa5]+$";
for (int i = 0; i < vars.length; i++) {
String var = StrUtil.trimToEmpty(String.valueOf(vars[i]));
if (!var.matches(regex)) {
throw new ServiceException("选项数据不符合规则,仅允许使用中英文字符以及数字");
}
stringBuffer.append(var);
if (i < vars.length - 1) {
// 直至最后一个前,都以_作为切割线
stringBuffer.append(DELIMITER);
}
}
if (stringBuffer.toString().matches("^\\d_*$")) {
throw new ServiceException("禁止以数字开头");
}
return stringBuffer.toString();
}
/**
* 将处理后合理的可选值解析为原始的参数
*
* @param option 经过处理后的合理的可选项
* @return 原始的参数
*/
public static List<String> analyzeOptionValue(String option) {
return StrUtil.split(option, DELIMITER, true, true);
}
/**
* 创建级联下拉选项
*
* @param parentList 父实体可选项原始数据
* @param parentIndex 父下拉选位置
* @param sonList 子实体可选项原始数据
* @param sonIndex 子下拉选位置
* @param parentHowToGetIdFunction 父类如何获取唯一标识
* @param sonHowToGetParentIdFunction 子类如何获取父类的唯一标识
* @param howToBuildEveryOption 如何生成下拉选内容
* @return 级联下拉选项
*/
public static <T> DropDownOptions buildLinkedOptions(List<T> parentList,
int parentIndex,
List<T> sonList,
int sonIndex,
Function<T, Number> parentHowToGetIdFunction,
Function<T, Number> sonHowToGetParentIdFunction,
Function<T, String> howToBuildEveryOption) {
DropDownOptions parentLinkSonOptions = new DropDownOptions();
// 先创建父类的下拉
parentLinkSonOptions.setIndex(parentIndex);
parentLinkSonOptions.setOptions(
parentList.stream()
.map(howToBuildEveryOption)
.collect(Collectors.toList())
);
// 提取父-子级联下拉
Map<String, List<String>> sonOptions = new HashMap<>();
// 父级依据自己的ID分组
Map<Number, List<T>> parentGroupByIdMap =
parentList.stream().collect(Collectors.groupingBy(parentHowToGetIdFunction));
// 遍历每个子集,提取到Map中
sonList.forEach(everySon -> {
if (parentGroupByIdMap.containsKey(sonHowToGetParentIdFunction.apply(everySon))) {
// 找到对应的上级
T parentObj = parentGroupByIdMap.get(sonHowToGetParentIdFunction.apply(everySon)).get(0);
// 提取名称和ID作为Key
String key = howToBuildEveryOption.apply(parentObj);
// Key对应的Value
List<String> thisParentSonOptionList;
if (sonOptions.containsKey(key)) {
thisParentSonOptionList = sonOptions.get(key);
} else {
thisParentSonOptionList = new ArrayList<>();
sonOptions.put(key, thisParentSonOptionList);
}
// 往Value中添加当前子集选项
thisParentSonOptionList.add(howToBuildEveryOption.apply(everySon));
}
});
parentLinkSonOptions.setNextIndex(sonIndex);
parentLinkSonOptions.setNextOptions(sonOptions);
return parentLinkSonOptions;
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
下拉选项处理类 ExcelDownHandler
,该类需要实现 EasyExcel 提供的 SheetWriteHandler 接口,这样在导出的时候,就会调用该类的方法来处理。
考虑到下拉选过多可能导致 Excel 打开缓慢的问题,只校验前 1000 行,即只有前1000行的数据可以用下拉框,超出的自行通过限制数据量的形式,第二次输出。
public class ExcelDownHandler implements SheetWriteHandler {
/**
* Excel表格中的列名英文
* 仅为了解析列英文,禁止修改
*/
private static final String EXCEL_COLUMN_NAME = "ABCDEFGHIJKLMNOPQRSTUVWXYZ";
/**
* 单选数据Sheet名
*/
private static final String OPTIONS_SHEET_NAME = "options";
/**
* 联动选择数据Sheet名的头
*/
private static final String LINKED_OPTIONS_SHEET_NAME = "linkedOptions";
/**
* 下拉可选项
*/
private final List<DropDownOptions> dropDownOptions;
/**
* 当前单选进度
*/
private int currentOptionsColumnIndex;
/**
* 当前联动选择进度
*/
private int currentLinkedOptionsSheetIndex;
public ExcelDownHandler(List<DropDownOptions> options) {
this.dropDownOptions = options;
this.currentOptionsColumnIndex = 0;
this.currentLinkedOptionsSheetIndex = 0;
}
/**
* <h2>开始创建下拉数据</h2>
* 1.通过解析传入的@ExcelProperty同级是否标注有@DropDown选项
* 如果有且设置了value值,则将其直接置为下拉可选项
* <p>
* 2.或者在调用ExcelUtil时指定了可选项,将依据传入的可选项做下拉
* <p>
* 3.二者并存,注意调用方式
*/
@Override
public void afterSheetCreate(WriteWorkbookHolder writeWorkbookHolder, WriteSheetHolder writeSheetHolder) {
Sheet sheet = writeSheetHolder.getSheet();
// 开始设置下拉框 HSSFWorkbook
DataValidationHelper helper = sheet.getDataValidationHelper();
Workbook workbook = writeWorkbookHolder.getWorkbook();
FieldCache fieldCache = ClassUtils.declaredFields(writeWorkbookHolder.getClazz(), writeWorkbookHolder);
// 循环实体中的每个属性
for (Map.Entry<Integer, FieldWrapper> entry : fieldCache.getSortedFieldMap().entrySet()) {
Integer index = entry.getKey();
FieldWrapper wrapper = entry.getValue();
Field field = wrapper.getField();
// 可选的下拉值
List<String> options = new ArrayList<>();
if (field.isAnnotationPresent(ExcelDictFormat.class)) {
// 如果指定了 @ExcelDictFormat,则使用字典的逻辑
ExcelDictFormat format = field.getDeclaredAnnotation(ExcelDictFormat.class);
String converterExp = format.readExp();
if (StringUtil.hasText(converterExp)) {
// 如果指定了确切的值,则直接解析确切的值
List<String> strList = StringUtil.splitList(converterExp, format.separator());
options = strList.stream().map(s -> Optional.ofNullable(StringUtil.split(s, format.mappingKey())).orElse(new String[1])[1]).map(String::trim).filter(Objects::nonNull).toList();
}
} else if (field.isAnnotationPresent(ExcelEnumFormat.class)) {
// 否则如果指定了 @ExcelEnumFormat,则使用枚举的逻辑
ExcelEnumFormat format = field.getDeclaredAnnotation(ExcelEnumFormat.class);
List<Object> labelList = EnumUtil.getFieldValues(format.enumClass(), format.labelField());
options = labelList.stream().map(String::valueOf).toList();
}
if (ObjectUtil.isNotEmpty(options)) {
// 仅当下拉可选项不为空时执行
if (options.size() > 20) {
// 这里限制如果可选项大于20,则使用额外表形式
dropDownWithSheet(helper, workbook, sheet, index, options);
} else {
// 否则使用固定值形式
dropDownWithSimple(helper, sheet, index, options);
}
}
}
if (CollUtil.isEmpty(dropDownOptions)) {
return;
}
dropDownOptions.forEach(everyOptions -> {
// 如果传递了下拉框选择器参数
if (!everyOptions.getNextOptions().isEmpty()) {
// 当二级选项不为空时,使用额外关联表的形式
dropDownLinkedOptions(helper, workbook, sheet, everyOptions);
} else if (everyOptions.getOptions().size() > 10) {
// 当一级选项参数个数大于10,使用额外表的形式
dropDownWithSheet(helper, workbook, sheet, everyOptions.getIndex(), everyOptions.getOptions());
} else if (everyOptions.getOptions().size() != 0) {
// 当一级选项个数不为空,使用默认形式
dropDownWithSimple(helper, sheet, everyOptions.getIndex(), everyOptions.getOptions());
}
});
}
/**
* <h2>简单下拉框</h2>
* 直接将可选项拼接为指定列的数据校验值
*
* @param celIndex 列index
* @param value 下拉选可选值
*/
private void dropDownWithSimple(DataValidationHelper helper, Sheet sheet, Integer celIndex, List<String> value) {
if (ObjectUtil.isEmpty(value)) {
return;
}
this.markOptionsToSheet(helper, sheet, celIndex, helper.createExplicitListConstraint(ArrayUtil.toArray(value, String.class)));
}
/**
* <h2>额外表格形式的级联下拉框</h2>
*
* @param options 额外表格形式存储的下拉可选项
*/
private void dropDownLinkedOptions(DataValidationHelper helper, Workbook workbook, Sheet sheet, DropDownOptions options) {
String linkedOptionsSheetName = String.format("%s_%d", LINKED_OPTIONS_SHEET_NAME, currentLinkedOptionsSheetIndex);
// 创建联动下拉数据表
Sheet linkedOptionsDataSheet = workbook.createSheet(WorkbookUtil.createSafeSheetName(linkedOptionsSheetName));
// 将下拉表隐藏
workbook.setSheetHidden(workbook.getSheetIndex(linkedOptionsDataSheet), true);
// 完善横向的一级选项数据表
List<String> firstOptions = options.getOptions();
Map<String, List<String>> secoundOptionsMap = options.getNextOptions();
// 创建名称管理器
Name name = workbook.createName();
// 设置名称管理器的别名
name.setNameName(linkedOptionsSheetName);
// 以横向第一行创建一级下拉拼接引用位置
String firstOptionsFunction = String.format("%s!$%s$1:$%s$1",
linkedOptionsSheetName,
getExcelColumnName(0),
getExcelColumnName(firstOptions.size())
);
// 设置名称管理器的引用位置
name.setRefersToFormula(firstOptionsFunction);
// 设置数据校验为序列模式,引用的是名称管理器中的别名
this.markOptionsToSheet(helper, sheet, options.getIndex(), helper.createFormulaListConstraint(linkedOptionsSheetName));
for (int columIndex = 0; columIndex < firstOptions.size(); columIndex++) {
// 先提取主表中一级下拉的列名
String firstOptionsColumnName = getExcelColumnName(columIndex);
// 一次循环是每一个一级选项
int finalI = columIndex;
// 本次循环的一级选项值
String thisFirstOptionsValue = firstOptions.get(columIndex);
// 创建第一行的数据
Optional.ofNullable(linkedOptionsDataSheet.getRow(0))
// 如果不存在则创建第一行
.orElseGet(() -> linkedOptionsDataSheet.createRow(finalI))
// 第一行当前列
.createCell(columIndex)
// 设置值为当前一级选项值
.setCellValue(thisFirstOptionsValue);
// 第二行开始,设置第二级别选项参数
List<String> secondOptions = secoundOptionsMap.get(thisFirstOptionsValue);
if (CollUtil.isEmpty(secondOptions)) {
// 必须保证至少有一个关联选项,否则将导致Excel解析错误
secondOptions = Collections.singletonList("暂无_0");
}
// 以该一级选项值创建子名称管理器
Name sonName = workbook.createName();
// 设置名称管理器的别名
sonName.setNameName(thisFirstOptionsValue);
// 以第二行该列数据拼接引用位置
String sonFunction = String.format("%s!$%s$2:$%s$%d",
linkedOptionsSheetName,
firstOptionsColumnName,
firstOptionsColumnName,
secondOptions.size() + 1
);
// 设置名称管理器的引用位置
sonName.setRefersToFormula(sonFunction);
// 数据验证为序列模式,引用到每一个主表中的二级选项位置
// 创建子项的名称管理器,只是为了使得Excel可以识别到数据
String mainSheetFirstOptionsColumnName = getExcelColumnName(options.getIndex());
for (int i = 0; i < 100; i++) {
// 以一级选项对应的主体所在位置创建二级下拉
String secondOptionsFunction = String.format("=INDIRECT(%s%d)", mainSheetFirstOptionsColumnName, i + 1);
// 二级只能主表每一行的每一列添加二级校验
markLinkedOptionsToSheet(helper, sheet, i, options.getNextIndex(), helper.createFormulaListConstraint(secondOptionsFunction));
}
for (int rowIndex = 0; rowIndex < secondOptions.size(); rowIndex++) {
// 从第二行开始填充二级选项
int finalRowIndex = rowIndex + 1;
int finalColumIndex = columIndex;
Row row = Optional.ofNullable(linkedOptionsDataSheet.getRow(finalRowIndex))
// 没有则创建
.orElseGet(() -> linkedOptionsDataSheet.createRow(finalRowIndex));
Optional
// 在本级一级选项所在的列
.ofNullable(row.getCell(finalColumIndex))
// 不存在则创建
.orElseGet(() -> row.createCell(finalColumIndex))
// 设置二级选项值
.setCellValue(secondOptions.get(rowIndex));
}
}
currentLinkedOptionsSheetIndex++;
}
/**
* <h2>额外表格形式的普通下拉框</h2>
* 由于下拉框可选值数量过多,为提升Excel打开效率,使用额外表格形式做下拉
*
* @param celIndex 下拉选
* @param value 下拉选可选值
*/
private void dropDownWithSheet(DataValidationHelper helper, Workbook workbook, Sheet sheet, Integer celIndex, List<String> value) {
// 创建下拉数据表
Sheet simpleDataSheet = Optional.ofNullable(workbook.getSheet(WorkbookUtil.createSafeSheetName(OPTIONS_SHEET_NAME)))
.orElseGet(() -> workbook.createSheet(WorkbookUtil.createSafeSheetName(OPTIONS_SHEET_NAME)));
// 将下拉表隐藏
workbook.setSheetHidden(workbook.getSheetIndex(simpleDataSheet), true);
// 完善纵向的一级选项数据表
for (int i = 0; i < value.size(); i++) {
int finalI = i;
// 获取每一选项行,如果没有则创建
Row row = Optional.ofNullable(simpleDataSheet.getRow(i))
.orElseGet(() -> simpleDataSheet.createRow(finalI));
// 获取本级选项对应的选项列,如果没有则创建
Cell cell = Optional.ofNullable(row.getCell(currentOptionsColumnIndex))
.orElseGet(() -> row.createCell(currentOptionsColumnIndex));
// 设置值
cell.setCellValue(value.get(i));
}
// 创建名称管理器
Name name = workbook.createName();
// 设置名称管理器的别名
String nameName = String.format("%s_%d", OPTIONS_SHEET_NAME, celIndex);
name.setNameName(nameName);
// 以纵向第一列创建一级下拉拼接引用位置
String function = String.format("%s!$%s$1:$%s$%d",
OPTIONS_SHEET_NAME,
getExcelColumnName(currentOptionsColumnIndex),
getExcelColumnName(currentOptionsColumnIndex),
value.size());
// 设置名称管理器的引用位置
name.setRefersToFormula(function);
// 设置数据校验为序列模式,引用的是名称管理器中的别名
this.markOptionsToSheet(helper, sheet, celIndex, helper.createFormulaListConstraint(nameName));
currentOptionsColumnIndex++;
}
/**
* 挂载下拉的列,仅限一级选项
*/
private void markOptionsToSheet(DataValidationHelper helper, Sheet sheet, Integer celIndex,
DataValidationConstraint constraint) {
// 设置数据有效性加载在哪个单元格上,四个参数分别是:起始行、终止行、起始列、终止列
CellRangeAddressList addressList = new CellRangeAddressList(1, 1000, celIndex, celIndex);
markDataValidationToSheet(helper, sheet, constraint, addressList);
}
/**
* 挂载下拉的列,仅限二级选项
*/
private void markLinkedOptionsToSheet(DataValidationHelper helper, Sheet sheet, Integer rowIndex,
Integer celIndex, DataValidationConstraint constraint) {
// 设置数据有效性加载在哪个单元格上,四个参数分别是:起始行、终止行、起始列、终止列
CellRangeAddressList addressList = new CellRangeAddressList(rowIndex, rowIndex, celIndex, celIndex);
markDataValidationToSheet(helper, sheet, constraint, addressList);
}
/**
* 应用数据校验
*/
private void markDataValidationToSheet(DataValidationHelper helper, Sheet sheet,
DataValidationConstraint constraint, CellRangeAddressList addressList) {
// 数据有效性对象
DataValidation dataValidation = helper.createValidation(constraint, addressList);
// 处理Excel兼容性问题
if (dataValidation instanceof XSSFDataValidation) {
//数据校验
dataValidation.setSuppressDropDownArrow(true);
//错误提示
dataValidation.setErrorStyle(DataValidation.ErrorStyle.STOP);
dataValidation.createErrorBox("提示", "此值与单元格定义数据不一致");
dataValidation.setShowErrorBox(true);
//选定提示
dataValidation.createPromptBox("填写说明:", "填写内容只能为下拉中数据,其他数据将导致导入失败");
dataValidation.setShowPromptBox(true);
sheet.addValidationData(dataValidation);
} else {
dataValidation.setSuppressDropDownArrow(false);
}
sheet.addValidationData(dataValidation);
}
/**
* <h2>依据列index获取列名英文</h2>
* 依据列index转换为Excel中的列名英文
* <p>例如第1列,index为0,解析出来为A列</p>
* 第27列,index为26,解析为AA列
* <p>第28列,index为27,解析为AB列</p>
*
* @param columnIndex 列index
* @return 列index所在得英文名
*/
private String getExcelColumnName(int columnIndex) {
// 26一循环的次数
int columnCircleCount = columnIndex / 26;
// 26一循环内的位置
int thisCircleColumnIndex = columnIndex % 26;
// 26一循环的次数大于0,则视为栏名至少两位
String columnPrefix = columnCircleCount == 0
? StrUtil.EMPTY
: StrUtil.subWithLength(EXCEL_COLUMN_NAME, columnCircleCount - 1, 1);
// 从26一循环内取对应的栏位名
String columnNext = StrUtil.subWithLength(EXCEL_COLUMN_NAME, thisCircleColumnIndex, 1);
// 将二者拼接即为最终的栏位名
return columnPrefix + columnNext;
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
# 合并单元格
在 注解 里,提供了 CellMerge 注解,该注解就是实现 Excel 列单元格合并功能。
实现 CellMergeStrategy 类来处理该注解来实现列单元格合并,需要继承 EasyExcel 提供的 AbstractMergeStrategy 类和实现 WorkbookWriteHandler 接口。这样 EasyExcel 才知道有这个类并调用。
@Slf4j
public class CellMergeStrategy extends AbstractMergeStrategy implements WorkbookWriteHandler {
private final List<CellRangeAddress> cellList;
private final boolean hasTitle;
private int rowIndex;
public CellMergeStrategy(List<?> list, boolean hasTitle) {
this.hasTitle = hasTitle;
// 行合并开始下标
this.rowIndex = hasTitle ? 1 : 0;
this.cellList = handle(list, hasTitle);
}
@Override
protected void merge(Sheet sheet, Cell cell, Head head, Integer relativeRowIndex) {
//单元格写入了,遍历合并区域,如果该Cell在区域内,但非首行,则清空
final int rowIndex = cell.getRowIndex();
if (CollUtil.isNotEmpty(cellList)){
for (CellRangeAddress cellAddresses : cellList) {
final int firstRow = cellAddresses.getFirstRow();
if (cellAddresses.isInRange(cell) && rowIndex != firstRow){
cell.setBlank();
}
}
}
}
@Override
public void afterWorkbookDispose(final WorkbookWriteHandlerContext context) {
//当前表格写完后,统一写入
if (CollUtil.isNotEmpty(cellList)){
for (CellRangeAddress item : cellList) {
context.getWriteContext().writeSheetHolder().getSheet().addMergedRegion(item);
}
}
}
@SneakyThrows
private List<CellRangeAddress> handle(List<?> list, boolean hasTitle) {
List<CellRangeAddress> cellList = new ArrayList<>();
if (CollUtil.isEmpty(list)) {
return cellList;
}
Field[] fields = ReflectUtil.getFields(list.get(0).getClass(), field -> !"serialVersionUID".equals(field.getName()));
// 有注解的字段
List<Field> mergeFields = new ArrayList<>();
List<Integer> mergeFieldsIndex = new ArrayList<>();
for (int i = 0; i < fields.length; i++) {
Field field = fields[i];
if (field.isAnnotationPresent(CellMerge.class)) {
CellMerge cm = field.getAnnotation(CellMerge.class);
mergeFields.add(field);
mergeFieldsIndex.add(cm.index() == -1 ? i : cm.index());
if (hasTitle) {
ExcelProperty property = field.getAnnotation(ExcelProperty.class);
rowIndex = Math.max(rowIndex, property.value().length);
}
}
}
Map<Field, RepeatCell> map = new HashMap<>();
// 生成两两合并单元格
for (int i = 0; i < list.size(); i++) {
for (int j = 0; j < mergeFields.size(); j++) {
Field field = mergeFields.get(j);
Object val = ReflectUtil.invokeGetter(list.get(i), field.getName());
int colNum = mergeFieldsIndex.get(j);
if (!map.containsKey(field)) {
map.put(field, new RepeatCell(val, i));
} else {
RepeatCell repeatCell = map.get(field);
Object cellValue = repeatCell.getValue();
if (cellValue == null || "".equals(cellValue)) {
// 空值跳过不合并
continue;
}
if (!cellValue.equals(val)) {
if ((i - repeatCell.getCurrent() > 1) && isMerge(list, i, field)) {
cellList.add(new CellRangeAddress(repeatCell.getCurrent() + rowIndex, i + rowIndex - 1, colNum, colNum));
}
map.put(field, new RepeatCell(val, i));
} else if (i == list.size() - 1) {
if (i > repeatCell.getCurrent() && isMerge(list, i, field)) {
cellList.add(new CellRangeAddress(repeatCell.getCurrent() + rowIndex, i + rowIndex, colNum, colNum));
}
}
}
}
}
return cellList;
}
private boolean isMerge(List<?> list, int i, Field field) {
boolean isMerge = true;
CellMerge cm = field.getAnnotation(CellMerge.class);
final String[] mergeBy = cm.mergeBy();
if (StrUtil.isAllNotBlank(mergeBy)) {
//比对当前list(i)和list(i - 1)的各个属性值一一比对 如果全为真 则为真
for (String fieldName : mergeBy) {
final Object valCurrent = ReflectUtil.getFieldValue(list.get(i), fieldName);
final Object valPre = ReflectUtil.getFieldValue(list.get(i - 1), fieldName);
if (!Objects.equals(valPre, valCurrent)) {
//依赖字段如有任一不等值,则标记为不可合并
isMerge = false;
}
}
}
return isMerge;
}
@Data
@AllArgsConstructor
static class RepeatCell {
private Object value;
private int current;
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
# 工具类
最后提供封装了 EasyExcel API 的工具类
@NoArgsConstructor(access = AccessLevel.PRIVATE)
public class ExcelHelper {
/**
* 同步导入(适用于小数据量)
*
* @param is 输入流
* @return 转换后集合
*/
public static <T> List<T> importExcel(InputStream is, Class<T> clazz) {
return EasyExcel.read(is).head(clazz).autoCloseStream(false).sheet().doReadSync();
}
/**
* 使用校验监听器,异步导入,同步返回
*
* @param is 输入流
* @param clazz 对象类型
* @param isValidate 是否 Validator 检验 默认为是
* @return 转换后集合
*/
public static <T> ExcelResult<T> importExcel(InputStream is, Class<T> clazz, boolean isValidate) {
BaseExcelListener<T> listener = new BaseExcelListener<>(isValidate);
return importExcel(is, clazz, listener);
}
/**
* 使用自定义监听器,异步导入,自定义返回
*
* @param is 输入流
* @param clazz 对象类型
* @param listener 自定义监听器
* @return 转换后集合
*/
public static <T> ExcelResult<T> importExcel(InputStream is, Class<T> clazz, ExcelListener<T> listener) {
EasyExcel.read(is, clazz, listener).sheet().doRead();
return listener.getExcelResult();
}
/**
* 分页异步导入
*
* @param is 输入流
* @param clazz 对象类型
* @param batchCount 分页大小
* @param consumer 数据每次到达分页阈值后,返回数据
*/
public static <T> void importExcel(InputStream is, Class<T> clazz, Integer batchCount, Consumer<T> consumer) {
importExcel(is, clazz, new PageReadListener(consumer, batchCount));
}
/**
* 自定义原生监听器导入
*
* @param is 输入流
* @param clazz 对象类型
* @param listener 自定义原生监听器
*/
public static <T> void importExcel(InputStream is, Class<T> clazz, ReadListener<T> listener) {
EasyExcel.read(is, clazz, listener).sheet().doRead();
ExcelDictManager.remove();
}
/**
* 使用校验监听器,异步导入,同步返回
*
* @param filename 文件绝对路径
* @param clazz 对象类型
* @param isValidate 是否 Validator 检验 默认为是
* @return 转换后集合
*/
public static <T> ExcelResult<T> importExcel(String filename, Class<T> clazz, boolean isValidate) {
BaseExcelListener<T> listener = new BaseExcelListener<>(isValidate);
return importExcel(filename, clazz, listener);
}
/**
* 使用自定义监听器,异步导入,自定义返回
*
* @param filename 文件绝对路径
* @param clazz 对象类型
* @param listener 自定义监听器
* @return 转换后集合
*/
public static <T> ExcelResult<T> importExcel(String filename, Class<T> clazz, ExcelListener<T> listener) {
EasyExcel.read(filename, clazz, listener).sheet().doRead();
return listener.getExcelResult();
}
/**
* 分页异步导入
*
* @param filename 文件绝对路径
* @param clazz 对象类型
* @param batchCount 分页大小
* @param consumer 数据每次到达分页阈值后,返回数据
*/
public static <T> void importExcel(String filename, Class<T> clazz, Integer batchCount, Consumer<T> consumer) {
importExcel(filename, clazz, new PageReadListener(consumer, batchCount));
}
/**
* 自定义原生监听器导入
*
* @param filename 文件绝对路径
* @param clazz 对象类型
* @param listener 自定义原生监听器
*/
public static <T> void importExcel(String filename, Class<T> clazz, ReadListener<T> listener) {
EasyExcel.read(filename, clazz, listener).sheet().doRead();
ExcelDictManager.remove();
}
/**
* 导出 Excel
*
* @param list 导出数据集合
* @param sheetName 工作表的名称
* @param clazz 实体类
* @param response 响应体
*/
public static <T> void exportExcel(List<T> list, String sheetName, Class<T> clazz, HttpServletResponse response) {
try {
resetResponse(sheetName, response);
ServletOutputStream os = response.getOutputStream();
exportExcel(list, sheetName, clazz, false, os, null);
} catch (IOException e) {
throw new RuntimeException("导出 Excel 异常");
}
}
/**
* 导出 Excel
*
* @param list 导出数据集合
* @param sheetName 工作表的名称
* @param clazz 实体类
* @param response 响应体
* @param options 级联下拉选
*/
public static <T> void exportExcel(List<T> list, String sheetName, Class<T> clazz, HttpServletResponse response, List<DropDownOptions> options) {
try {
resetResponse(sheetName, response);
ServletOutputStream os = response.getOutputStream();
exportExcel(list, sheetName, clazz, false, os, options);
} catch (IOException e) {
throw new RuntimeException("导出 Excel 异常");
} finally {
ExcelDictManager.remove();
}
}
/**
* 导出 Excel
*
* @param list 导出数据集合
* @param sheetName 工作表的名称
* @param clazz 实体类
* @param merge 是否合并单元格
* @param response 响应体
*/
public static <T> void exportExcel(List<T> list, String sheetName, Class<T> clazz, boolean merge, HttpServletResponse response) {
try {
resetResponse(sheetName, response);
ServletOutputStream os = response.getOutputStream();
exportExcel(list, sheetName, clazz, merge, os, null);
} catch (IOException e) {
throw new RuntimeException("导出 Excel 异常");
} finally {
ExcelDictManager.remove();
}
}
/**
* 导出 Excel
*
* @param list 导出数据集合
* @param sheetName 工作表的名称
* @param clazz 实体类
* @param merge 是否合并单元格
* @param response 响应体
* @param options 级联下拉选
*/
public static <T> void exportExcel(List<T> list, String sheetName, Class<T> clazz, boolean merge, HttpServletResponse response, List<DropDownOptions> options) {
try {
resetResponse(sheetName, response);
ServletOutputStream os = response.getOutputStream();
exportExcel(list, sheetName, clazz, merge, os, options);
} catch (IOException e) {
throw new RuntimeException("导出 Excel 异常");
} finally {
ExcelDictManager.remove();
}
}
/**
* 导出 Excel
*
* @param list 导出数据集合
* @param sheetName 工作表的名称
* @param clazz 实体类
* @param os 输出流
*/
public static <T> void exportExcel(List<T> list, String sheetName, Class<T> clazz, OutputStream os) {
exportExcel(list, sheetName, clazz, false, os, null);
}
/**
* 导出 Excel
*
* @param list 导出数据集合
* @param sheetName 工作表的名称
* @param clazz 实体类
* @param os 输出流
* @param options 级联下拉选内容
*/
public static <T> void exportExcel(List<T> list, String sheetName, Class<T> clazz, OutputStream os, List<DropDownOptions> options) {
exportExcel(list, sheetName, clazz, false, os, options);
}
/**
* 导出 Excel
*
* @param list 导出数据集合
* @param sheetName 工作表的名称
* @param clazz 实体类
* @param merge 是否合并单元格
* @param os 输出流
*/
public static <T> void exportExcel(List<T> list, String sheetName, Class<T> clazz, boolean merge,
OutputStream os, List<DropDownOptions> options) {
ExcelWriterSheetBuilder builder = EasyExcel.write(os, clazz)
.autoCloseStream(false)
// 自动适配
.registerWriteHandler(new LongestMatchColumnWidthStyleStrategy())
// 大数值自动转换,防止失真
.registerConverter(new ExcelBigNumberConvert())
.sheet(sheetName);
if (merge) {
// 合并处理器
builder.registerWriteHandler(new CellMergeStrategy(list, true));
}
// 添加下拉框操作
builder.registerWriteHandler(new ExcelDownHandler(options));
builder.doWrite(list);
ExcelDictManager.remove();
}
/**
* 纯 Excel 模板导出
*
* @param filename 文件路径,如果没有指定磁盘符,则默认读取项目 resource 目录下的文件
* @param templatePath 模板路径 resource 目录下的路径包括模板文件名
* 例如: excel/temp.xlsx
* 重点: 模板文件必须放置到启动类对应的 resource 目录下
* @param response 响应体
*/
public static void exportTemplate(String filename, String templatePath, HttpServletResponse response) {
try {
resetResponse(filename, response);
ServletOutputStream os = response.getOutputStream();
ClassPathResource templateResource = new ClassPathResource(templatePath);
EasyExcel.write(os)
.withTemplate(templateResource.getStream())
.autoCloseStream(false)
// 大数值自动转换 防止失真
.registerConverter(new ExcelBigNumberConvert())
.sheet().doWrite(new ArrayList<>());
} catch (IOException e) {
throw new RuntimeException("导出 Excel 异常");
} finally {
ExcelDictManager.remove();
}
}
/**
* 单表多数据模板导出 模板格式为 {.属性}
*
* @param filename 文件名
* @param templatePath 模板路径 resource 目录下的路径包括模板文件名
* 例如: excel/temp.xlsx
* 重点: 模板文件必须放置到启动类对应的 resource 目录下
* @param data 模板需要的数据
* @param response 响应体
*/
public static void exportTemplate(List<Object> data, String filename, String templatePath, HttpServletResponse response) {
try {
resetResponse(filename, response);
ServletOutputStream os = response.getOutputStream();
exportTemplate(data, templatePath, os);
} catch (IOException e) {
throw new RuntimeException("导出 Excel 异常");
} finally {
ExcelDictManager.remove();
}
}
/**
* 单表多数据模板导出 模板格式为 {.属性}
*
* @param templatePath 模板路径 resource 目录下的路径包括模板文件名
* 例如: excel/temp.xlsx
* 重点: 模板文件必须放置到启动类对应的 resource 目录下
* @param data 模板需要的数据
* @param os 输出流
*/
public static void exportTemplate(List<Object> data, String templatePath, OutputStream os) {
ClassPathResource templateResource = new ClassPathResource(templatePath);
ExcelWriter excelWriter = EasyExcel.write(os)
.withTemplate(templateResource.getStream())
.autoCloseStream(false)
// 大数值自动转换 防止失真
.registerConverter(new ExcelBigNumberConvert())
.build();
WriteSheet writeSheet = EasyExcel.writerSheet().build();
if (CollUtil.isEmpty(data)) {
throw new IllegalArgumentException("数据为空");
}
// 单表多数据导出,模板格式为 {.属性}
for (Object d : data) {
excelWriter.fill(d, writeSheet);
}
excelWriter.finish();
}
/**
* 多表多数据模板导出 模板格式为 {key.属性}
*
* @param filename 文件名
* @param templatePath 模板路径 resource 目录下的路径包括模板文件名
* 例如: excel/temp.xlsx
* 重点: 模板文件必须放置到启动类对应的 resource 目录下
* @param data 模板需要的数据
* @param response 响应体
*/
public static void exportTemplateMultiList(Map<String, Object> data, String filename, String templatePath, HttpServletResponse response) {
try {
resetResponse(filename, response);
ServletOutputStream os = response.getOutputStream();
exportTemplateMultiList(data, templatePath, os);
} catch (IOException e) {
throw new RuntimeException("导出 Excel 异常");
} finally {
ExcelDictManager.remove();
}
}
/**
* 多sheet模板导出 模板格式为 {key.属性}
*
* @param filename 文件名
* @param templatePath 模板路径 resource 目录下的路径包括模板文件名
* 例如: excel/temp.xlsx
* 重点: 模板文件必须放置到启动类对应的 resource 目录下
* @param data 模板需要的数据
* @param response 响应体
*/
public static void exportTemplateMultiSheet(List<Map<String, Object>> data, String filename, String templatePath, HttpServletResponse response) {
try {
resetResponse(filename, response);
ServletOutputStream os = response.getOutputStream();
exportTemplateMultiSheet(data, templatePath, os);
} catch (IOException e) {
throw new RuntimeException("导出 Excel 异常");
} finally {
ExcelDictManager.remove();
}
}
/**
* 多表多数据模板导出 模板格式为 {key.属性}
*
* @param templatePath 模板路径 resource 目录下的路径包括模板文件名
* 例如: excel/temp.xlsx
* 重点: 模板文件必须放置到启动类对应的 resource 目录下
* @param data 模板需要的数据
* @param os 输出流
*/
public static void exportTemplateMultiList(Map<String, Object> data, String templatePath, OutputStream os) {
ClassPathResource templateResource = new ClassPathResource(templatePath);
ExcelWriter excelWriter = EasyExcel.write(os)
.withTemplate(templateResource.getStream())
.autoCloseStream(false)
// 大数值自动转换 防止失真
.registerConverter(new ExcelBigNumberConvert())
.build();
WriteSheet writeSheet = EasyExcel.writerSheet().build();
if (CollUtil.isEmpty(data)) {
throw new IllegalArgumentException("数据为空");
}
for (Map.Entry<String, Object> map : data.entrySet()) {
// 设置列表后续还有数据
FillConfig fillConfig = FillConfig.builder().forceNewRow(Boolean.TRUE).build();
if (map.getValue() instanceof Collection) {
// 多表导出必须使用 FillWrapper
excelWriter.fill(new FillWrapper(map.getKey(), (Collection<?>) map.getValue()), fillConfig, writeSheet);
} else {
excelWriter.fill(map.getValue(), writeSheet);
}
}
excelWriter.finish();
}
/**
* 多 sheet 模板导出 模板格式为 {key.属性}
*
* @param templatePath 模板路径 resource 目录下的路径包括模板文件名
* 例如: excel/temp.xlsx
* 重点: 模板文件必须放置到启动类对应的 resource 目录下
* @param data 模板需要的数据
* @param os 输出流
*/
public static void exportTemplateMultiSheet(List<Map<String, Object>> data, String templatePath, OutputStream os) {
ClassPathResource templateResource = new ClassPathResource(templatePath);
ExcelWriter excelWriter = EasyExcel.write(os)
.withTemplate(templateResource.getStream())
.autoCloseStream(false)
// 大数值自动转换 防止失真
.registerConverter(new ExcelBigNumberConvert())
.build();
if (CollUtil.isEmpty(data)) {
throw new IllegalArgumentException("数据为空");
}
for (int i = 0; i < data.size(); i++) {
WriteSheet writeSheet = EasyExcel.writerSheet(i).build();
for (Map.Entry<String, Object> map : data.get(i).entrySet()) {
// 设置列表后续还有数据
FillConfig fillConfig = FillConfig.builder().forceNewRow(Boolean.TRUE).build();
if (map.getValue() instanceof Collection) {
// 多表导出必须使用 FillWrapper
excelWriter.fill(new FillWrapper(map.getKey(), (Collection<?>) map.getValue()), fillConfig, writeSheet);
} else {
excelWriter.fill(map.getValue(), writeSheet);
}
}
}
excelWriter.finish();
}
/**
* 重置响应体
*/
private static void resetResponse(String sheetName, HttpServletResponse response) throws UnsupportedEncodingException {
String filename = encodingFilename(sheetName);
String encode = URLEncoder.encode(filename, StandardCharsets.UTF_8);
String percentEncodedFileName = encode.replaceAll("\\+", "%20");
String contentDispositionValue = "attachment; filename=%s;filename*=utf-8''%s".formatted(percentEncodedFileName, percentEncodedFileName);
response.addHeader("Access-Control-Expose-Headers", "Content-Disposition,download-filename");
response.setHeader("Content-disposition", contentDispositionValue);
response.setHeader("download-filename", percentEncodedFileName);
response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet;charset=UTF-8");
}
/**
* 编码文件名
*/
public static String encodingFilename(String filename) {
return filename + "_" + IdUtil.fastSimpleUUID() + ".xlsx";
}
/**
* 解析导出值
*
* @param originValue 参数值,如 0
* @param exp 表达式,如 0:男, 1:女, 2:未知
* @param separator 分隔符
* @return 解析后值
*/
public static String parseValueByExp(String originValue, String exp, String mappingKey, String separator) {
StringBuilder property = new StringBuilder();
// 切割每个字典
String[] convertSource = exp.split(separator);
for (String convertItem : convertSource) {
// 切割 key 和 value
String[] convertItemArr = convertItem.trim().split(mappingKey);
// 如果 originValue 存在多个值
if (originValue.contains(separator)) {
for (String value : originValue.split(separator)) {
if (convertItemArr[0].trim().equals(value)) {
property.append(convertItemArr[1].trim()).append(separator);
break;
}
}
} else {
// 如果 originValue 只有一个值
if (convertItemArr[0].trim().equals(originValue)) {
return convertItemArr[1].trim();
}
}
}
return StringUtils.stripEnd(property.toString(), separator);
}
/**
* 解析导入值
*
* @param originValue 参数值,如 男
* @param exp 表达式,如 0:男, 1:女, 2:未知
* @param separator 分隔符
* @return 解析后值
*/
public static String reverseValueByExp(String originValue, String exp, String mappingKey, String separator) {
StringBuilder property = new StringBuilder();
// 切割每个字典
String[] convertSource = exp.split(separator);
for (String convertItem : convertSource) {
// 切割 key 和 value
String[] convertItemArr = convertItem.trim().split(mappingKey);
// 如果 originValue 存在多个值
if (originValue.contains(separator)) {
for (String value : originValue.split(separator)) {
if (convertItemArr[1].trim().equals(value)) {
property.append(convertItemArr[0].trim()).append(separator);
break;
}
}
} else {
// 如果 originValue 只有一个值
if (convertItemArr[1].trim().equals(originValue)) {
return convertItemArr[0].trim();
}
}
}
return StringUtils.stripEnd(property.toString(), separator);
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531