前言

本章节将重点讲述橙单对于导入操作的实现逻辑和性能优化,以便于您在进行二次开发后,仍然可以保证该功能具备良好的运行时性能。下面我们先给出导入过程的处理步骤。

  • 解析,以高效的 SAX 模式读取 Excel 格式的导入文件。
  • 翻译,以通用反射的方式,实现字典反向翻译逻辑。比如文件中为「二年级」,而实际插入的则是对应的字典ID 2。
  • 验证,以批量的方式,优化导入数据的验证逻辑。比如 5 万条数据中包含 4 个字典字段,未经优化就会有 20 万次的数据库交互。
  • 插入,以小批的方式实现数据插入,不仅可以有效的提升插入性能,同时还能避免单次插入的数据量过大问题。

代码解析

这里我们以橙单教学版中的「课程对象」为例,在对应的数据表 zz_course 中,包含 10000 条课程数据,同时该表包含 4 个字典字段。比如,年级 (全局编码字典)、课程分类 (字典表字典)、难度 (常量字典) 和授课老师 (数据源字典) 等。为了演示方便,下面仅给出课程实体对象类的部分代码定义。

@Data
@TableName(value = "zz_course")
public class Course {
   // 在实际的代码中,课程表中还有更多的字段,这里 ... ...
 
   // 主键Id。
   @TableId(value = "course_id")
   private Long courseId;

   // 课程名称。
   @TableField(value = "course_name")
   private String courseName;

   // 主讲老师。
   @TableField(value = "teacher_id")
   private Long teacherId;

   // 课程难度。
   private Integer difficulty;

   // 是否上架。
   private Integer online;

   // 年级。
   @TableField(value = "grade_id")
   private Integer gradeId;

   // 课程分类。
   @TableField(value = "category_id")
   private Integer categoryId;

   // 课时数量。
   @TableField(value = "class_hour")
   private Integer classHour;
   // 数据源字典
   @RelationDict(
           masterIdField = "teacherId",
           slaveServiceName = "teacherService",
           slaveModelClass = Teacher.class,
           slaveIdField = "teacherId",
           slaveNameField = "teacherName")
   @TableField(exist = false)
   private Map<String, Object> teacherIdDictMap;
   // 全局编码字典
   @RelationGlobalDict(
           masterIdField = "gradeId",
           dictCode = "GradeDict")
   @TableField(exist = false)
   private Map<String, Object> gradeIdDictMap;
   // 字典表字典
   @RelationDict(
           masterIdField = "categoryId",
           slaveServiceName = "courseCategoryService",
           slaveModelClass = CourseCategory.class,
           slaveIdField = "categoryId",
           slaveNameField = "categoryName")
   @TableField(exist = false)
   private Map<String, Object> categoryIdDictMap;
   // 常量字典
   @RelationConstDict(
           masterIdField = "difficulty",
           constantDictClass = CourseDifficult.class)
   @TableField(exist = false)
   private Map<String, Object> difficultyDictMap;
}

格式化

在很多时候,Excel 导入文件的数据格式与数据表结构并非一一对应,这就需要我们在解析导入文件之后,先完成一些必要的数据格式化工作,具体如下。

  • Excel 文件中的字段数量和数据表不一致,比如,创建人、创建时间、更新人、更新时间、逻辑删除标记,主键,以及一些存在缺省值的字段,从实际应用场景来看,他们是不会出现在 Excel 导入文件中的。
  • Excel 文件中字段列的顺序和代码中字段声明的顺序不一致,这个很常见,不在详述了。
  • Excel 文件的第一行通常是中文名称,比如,课程名称、年级、课程图片、课程难度、所属科目等。而 Java 实体类中定义的字段名则分别是,courseName、gradeId、pictureUrl、difficulty、subjectId 等。因此需要给他们之间建立明确的映射关系。
  • 在实际开发中,我们还需要可以根据请求参数,动态判断是否跳过第一行的头信息。
  • Excel 文件中的某一列,保存的是年级信息,比如「二年级」,然而与其对应的数据表字段是 gradeId 这样的数值型字典 ID 数据,因此在批量插入数据之前,需要先将「二年级」动态翻译为字典 ID 值 2。

为了帮助大家更好的理解本小节的内容,我们先给出完整的批量导入接口代码。

@PostMapping("/import")
public ResponseResult<Void> importBatch(
       @RequestParam Boolean skipHeader,
       @RequestParam("importFile") MultipartFile importFile) throws Exception {
   String errorMessage;
   if (Objects.isNull(importFile) || importFile.isEmpty()) {
       errorMessage = "数据验证失败,导入文件不能为空!";
       return ResponseResult.error(ErrorCodeEnum.ARGUMENT_NULL_EXIST, errorMessage);
   }
   // 先把导入文件保存到本地指定目录。
   String filename = ImportUtil.saveImportFile(appConfig.getUploadFileBaseDir(), null, importFile);
   // 这里可以指定需要忽略导入的字段集合。如创建时间、创建人、更新时间、更新人、主键Id和逻辑删除,
   // 以及一些存在缺省值且无需导入的字段。其中主键字段和逻辑删除字段不需要在这里设置,批量插入逻辑会自动处理的。
   Set<String> ignoreFieldSet = new HashSet<>();
   ignoreFieldSet.add("createUserId");
   ignoreFieldSet.add("createTime");
   ignoreFieldSet.add("updateTime");
   // ImportUtil.makeHeaderInfoList工具方法,只是会根据实体类对象的字典,以及需要忽略的字段结合
   // 生成缺省的Excel文件中列数据和数据表字段顺序的映射关系。
   List<ImportUtil.ImportHeaderInfo> headerInfoList = 
           ImportUtil.makeHeaderInfoList(Course.class, ignoreFieldSet);
   // 开发者可根据实际情况,对headerInfoList进行修改。
   // 这里补充解释一句为啥没有使用@Excel这类的注解,放到实体类字段上,因为我们担心同一个实体类,可能会
   // 因为需要的原因,不能不支持多种Excel文件的导入,毕竟业务视角不同。如果在实体字段中使用@Excel这类注解,
   // 标记顺序和对应的头信息等,这样就有些不够灵活了,因此橙单选择了最为灵活且直观的代码方式。
   ImportUtil.ImportHeaderInfo[] headerInfos = headerInfoList.toArray(new ImportUtil.ImportHeaderInfo[]{});
   Set<String> translatedDictFieldSet = new HashSet<>();
   // 下面分别对应四个字典的反向翻译,年级、课程难度、课程分类、学科。translatedDictFieldSet集合包含的
   // 是,本次导入需要进行反向翻译的字段。即将excel中的字典显示名称,如一年级,反向翻译为gradeId = 1的id数据
   // 并最终插入到数据表中。
   translatedDictFieldSet.add("gradeId");
   translatedDictFieldSet.add("difficulty");
   translatedDictFieldSet.add("categoryId");
   translatedDictFieldSet.add("subjectId");
   // 导入文件的格式化信息,都准备好了。我们会在解析Excel文件的过程中,完成数据结构格式化的工作。
   // 这样不仅让代码更具通用性,同时性能也更高。
   List<Course> dataList =
           ImportUtil.doImport(headerInfos, skipHeader, filename, Course.class, translatedDictFieldSet);
   // translatedDictFieldSet,该集合的字典将在数据验证阶段被忽略。
   // 因为这些字典Id值是被翻译过来的,所以一定是合法的,否则就是空。所以在verifyImportList方法中,
   // 可以忽略这些反向翻译的字典字段数据验证,以提升批量验证的整体效率。
   CallResult result = courseService.verifyImportList(dataList, translatedDictFieldSet);
   if (!result.isSuccess()) {
       // result中返回了具体的验证失败对象,如果需要返回更加详细的错误,可根据实际情况手动修改。
       return ResponseResult.errorFrom(result);
   }
   courseService.saveNewBatch(dataList, -1);
   return ResponseResult.success();
}

解析导入

Excel 导入文件的解析,是整个批量导入代码实现中的第一步,我们的优化也从这里开始。

Excel 文件的解析有两种方式,一种是性能较低,但操作方便的 Model 模式,另一种是性能较高,处理相对复杂的 SAX 流处理模式。

  • Model 模式。Excel 工具类读取文件后,会在内存中构建一个完整的表格对象,开发者可以方便的遍历该数据结构,并取出需要的数据,重新构建适合自己业务的对象类集合。因此,在应用服务的内存中,导入的数据就会存在两份,如果导入数据量较大,就会给业务服务带来较为明显的内存压力,甚至加快了 GC 的频度。
  • SAX 模式。Excel 工具类读取文件的过程中,会采用边读边回调的方式调用自定义的业务代码。被回调的业务代码,通常会在此处构建自己的业务对象类集合。因此从结果看,内存中只是保留了一份导入数据,同时也规避了工具类构建内部表格对象的时间开销。

以下代码来自于 common-core 模块的 ImportUtil 工具类,这里我们给出了更多的代码注释,以帮助大家理解。

// 为了减少对第三方工具包的依赖,这里我们直接使用hutool中自带的excel解析工具类。
// 该方法会返回自定义回调构建的数据结构列表List<Map<String, Object>>对象。
// 导入相关的业务代码,会在调用doImport之后,处理他返回的导入数据结果集。
public static List<Map<String, Object>> doImport(
       ImportHeaderInfo[] headers, boolean skipHeader, String filename) {
   List<Map<String, Object>> resultList = new LinkedList<>();
   // readBySax方法,就会一边读取excel的原始文件流,一边回调createRowHandler接口。
   ExcelUtil.readBySax(new File(filename), 0, createRowHandler(headers, skipHeader, resultList));
   return resultList;
}
private static RowHandler createRowHandler(
       ImportHeaderInfo[] headers, boolean skipHeader, List<Map<String, Object>> resultList) {
   return new MyRowHandler(headers, skipHeader, resultList);
}
private static class MyRowHandler implements RowHandler {
   private ImportHeaderInfo[] headers;
   private boolean skipHeader;
   private List<Map<String, Object>> resultList;
   public MyRowHandler(
           ImportHeaderInfo[] headers, boolean skipHeader, List<Map<String, Object>> resultList) {
       this.headers = headers;
       this.skipHeader = skipHeader;
       this.resultList = resultList;
   }
   // handle方法,就是ExcelUtil.readBySax中回调的方法。
   // 每读取一行,就会回调该方法一次,直到全部读取完毕。
   @Override
   public void handle(int sheetIndex, long rowIndex, List<Object> rowList) {
       if (this.skipHeader && rowIndex == 0) {
           return;
       }
       int i = 0;
       Map<String, Object> data = new HashMap<>(headers.length);
       // 遍历excel每一行的数据,所有列的数据。
       // 这里会根据headerInfo中的数据表字段类型,将excel中的cell数据,还原为匹配对象类型的数据。
       for (Object rowData : rowList) {
           ImportHeaderInfo headerInfo = this.headers[i++];
           switch (headerInfo.fieldType) {
               case INT_TYPE:
                   data.put(headerInfo.fieldName, Convert.toInt(rowData));
                   break;
               case LONG_TYPE:
                   data.put(headerInfo.fieldName, Convert.toLong(rowData));
                   break;
               case STRING_TYPE:
                   data.put(headerInfo.fieldName, Convert.toStr(rowData));
                   break;
               // ... ... 为了减少篇幅,这里只给出几个示例类型的。
               default:
                   throw new MyRuntimeException(
                           "Invalid ImportHeaderInfo.fieldType [" + headerInfo.fieldType + "].");
           }
       }
       // 处理完这一行所有列的数据之后,存入结果列表中。
       resultList.add(data);
   }
}

字典翻译

这里是指字典的反向翻译。比如在 Excel 表格中,某一列显示的是 gradeName 字段,如「一年级、二年级」等。而实际插入到数据表的真实数据,则为与之对应的 ID 字段值,如 gradeId 是 1、2 等。在实现过程中,我们还巧妙的规避了以下两处开发缺陷。

  • 规避了 HardCode 的处理方式,字典反向翻译的处理代码,是基于反射和注解的方式,可作用于所有业务表的导入。
  • 规避了逐条访问数据库的性能问题。根据不同的字典类型,可从 Redis 或字典数据表中,一次性读取全部的字典数据,后续的翻译比对,均可在内存中快速完成。

以下代码来自于 common-core 模块的 ImportUtil 工具类,这里我们给出了更多的代码注释,以帮助大家理解。

public static <T> List<T> doImport(
       ImportHeaderInfo[] headers,
       boolean skipHeader,
       String filename,
       Class<T> clazz,
       Set<String> translateDictFieldSet) {
   // 下面一行就是之前介绍的,基于SAX模式解析Excel文件后构建的数据集合。    
   List<Map<String, Object>> resultList = doImport(headers, skipHeader, filename);
   // 这里会判断是否存在需要进行字典反向翻译字段集合。
   if (CollUtil.isNotEmpty(translateDictFieldSet)) {
       // doTranslateDict中的方法,会根据字典字段名,进行反向的字典翻译。
       translateDictFieldSet.forEach(c -> doTranslateDict(resultList, clazz, c));
   }
   return MyModelUtil.mapToBeanList(resultList, clazz);
}
public static void doTranslateDict(
       List<Map<String, Object>> dataList, Class<?> modelClass, String fieldName) {
   if (CollUtil.isEmpty(dataList)) {
       return;
   }
   // 这里有个约定,字典字段,如gradeId,在实体对象中会存在一个gradeIdDictMap的Map<String, Object>类型的字段,
   // 其中包含两个键值对,分别是id和name,其中id的值和gradeId相等,name对应于这个字典值的显示值。
   // 另外在本例的gradeIdDictMap对象中,还包含一个注解,比如:
   // @RelationConstDict 对应于常量字典的注解。
   // @RelationDict 对应于独立数据表字典的注解。
   // @RelationGlobalDict 对应于全局编码字典的注解。
   // 当前方法的参数值fieldName,是gradeId,这个是业务接口方法,如CourseController的importBatch传入的。
   // 从实现逻辑讲,传入gradeId字段名会更加直观。因此下面的代码会自动按照约定获取该字典字段所对应的数据绑定字段。
   Field field = ReflectUtil.getField(modelClass, fieldName + "DictMap");
   Assert.notNull(field);
   Map<String, Object> inversedDictMap;
   // 为了限制篇幅和突出重点,我们省略了常量字典和普通的字典表字典的反向翻译代码。
   // 仅仅给出应用最广泛的全局编码字典的处理逻辑和详细的代码注释。
   if (field.isAnnotationPresent(RelationConstDict.class)) {
       // 省略了 ... ...
   } else if (field.isAnnotationPresent(RelationDict.class)) {
       // 省略了 ... ...
   } else if (field.isAnnotationPresent(RelationGlobalDict.class)) {
       // 这里的field是字典数据绑定字典,如gradeIdDictMap的反射字段对象。
       // 我们需要先获取到他的注解参数。
       RelationGlobalDict r = field.getAnnotation(RelationGlobalDict.class);
       // globalDictService 是位于common-dict中的全局编码字典数据服务类。也是bean对象。
       BaseService<Object, Serializable> s = ApplicationContextHolder.getBean("globalDictService");
       // globalDictService.getGlobalDictItemDictMapFromCache
       // 从方法名可以看出,是从缓存中读取了指定编码的全部字典数据。
       Method m = ReflectUtil.getMethodByName(s.getClass(), "getGlobalDictItemDictMapFromCache");
       // 调用了上面的方法,从缓存中读取了所有指定编码的全局编码字典数据,如gradeDictCode。
       // 为了节省内存和提升读取效率,这里只是需要读取@RelationGlobalDict注解中,指定的编码即可。
       Map<Object, String> dictMap = ReflectUtil.invoke(s, m, r.dictCode(), null);
       // 反向处理字典接口,dictMap的key是gradeId,就是那些数字值,反向处理后,
       // inversedDictMap的key就成为了字典值对应的字典名称,如“二年级”等。
       inversedDictMap = MapUtil.inverse(dictMap);
   } else {
       throw new UnsupportedOperationException("Only Support RelationConstDict and RelationDict Field");
   }
   // 遍历待导入的数据列表dataList
   for (Map<String, Object> data : dataList) {
       // 遍历过程中,获取数据集中指定字典字段的数据,下面的value返回的值通常为字典的显示值,如“二年级”。
       Object value = data.get(fieldName);
       if (value != null) {
           // 从上面查询并计算后的反向字典Map中,查询字典显示名。
           Object newValue = inversedDictMap.get(value.toString());
           if (newValue != null) {
               // 在获取到反向关联的字典Id值后,通过反射的方式,重新赋值给字典字段。
               data.put(fieldName, newValue);
           }
       }
   }
}

数据验证

到了这一步,解析后的格式化数据已经 READY 了。不过还是不能立刻批量插入到业务数据表,因为导入的数据可能存在非法数据,比如 Excel 中导入的老师 Id,而在实际的老师表中没有该 teacherId 数据。所以我们还需要进一步对导入后的数据进行批量的数据验证。

// 下面之所以为课程对象配置了四种不同的字典类型,主要是为了测试代码生成的各种边界,
// 同时也是给我们的用户一个更为完整的教学版示例。
// 这些验证代码都是橙单代码生成器根据业务表的配置,动态生成的,如果有误或者需要调整,可以自己修改这里的代码。
@Override
public CallResult verifyImportList(List<Course> dataList, Set<String> ignoreFieldSet) {
   CallResult callResult;
   if (!CollUtil.contains(ignoreFieldSet, "difficulty")) {
       // 课程难度字典,需要验证全局编码字典。
       callResult = verifyImportForGlobalDict(dataList, "difficultyDictMap", Course::getDifficulty);
       if (!callResult.isSuccess()) {
           return callResult;
       }
   }
   if (!CollUtil.contains(ignoreFieldSet, "subjectId")) {
       // 课程所属科目字典,需要验证常量字典。
       callResult = verifyImportForConstDict(dataList, "subjectIdDictMap", Course::getSubjectId);
       if (!callResult.isSuccess()) {
           return callResult;
       }
   }
   if (!CollUtil.contains(ignoreFieldSet, "gradeId")) {
       // 年级字典,需要验证字典表字典。
       callResult = verifyImportForDict(dataList, "gradeIdDictMap", Course::getGradeId);
       if (!callResult.isSuccess()) {
           return callResult;
       }
   }
   if (!CollUtil.contains(ignoreFieldSet, "teacherId")) {
       // 老师Id字典,需要验证业务表字典。
       callResult = verifyImportForDatasourceDict(dataList, "teacherIdDictMap", Course::getTeacherId);
       if (!callResult.isSuccess()) {
           return callResult;
       }
   }
   return CallResult.ok();
}
// 这里我们仅仅以字典表字典为例。比如上面代码中的年级字典。
// 在我们的示例中,存在zz_grade的年级字典表,该表的数据被全部加载Redis缓存。
// 字典数据变化的时候,Redis缓存也会同步更新的。
@Override
public <R> CallResult verifyImportForDict(List<M> dataList, String fieldName, Function<M, R> idGetter) {
   if (CollUtil.isEmpty(dataList)) {
       return CallResult.ok();
   }
   // ... ... 为了突出重点逻辑,我们这里忽略了一下健壮性检测的代码,具体可参考橙单在码云的开源仓库。
   // 这里的fieldName,对应于上面代码的gradeIdDictMap。在课程(Course)实体对象中,该字段是标有
   // @RelationDict的注解的。
   Field field = ReflectUtil.getField(modelClass, fieldName);
   // 这里拿到了该字段的注解对象,以及注解中的参数。
   RelationDict relationDict = field.getAnnotation(RelationDict.class);
   // 通过注解参数,可以得到年级的服务对象(gradeService)。
   BaseService<Object, Serializable> service = ApplicationContextHolder.getBean(
           StringUtils.uncapitalize(relationDict.slaveServiceName()));
   // 调用该字典服务的全部数据。因为是字典表字典,getAllList方法,会从Redis缓存中读取。
   // dictIdSet中包含的就是zz_grade表中,所有合法的grade_id字段值了。
   Set<Object> dictIdSet = service.getAllList().stream()
           .map(c -> ReflectUtil.getFieldValue(c, relationDict.slaveIdField())).collect(toSet());
   // 迭代主表数据结合,即课程列表数据。
   for (M data : dataList) {
       // 函数表达式是调用方法传入的,所以可以直接从Course实体对象中,获取到当前对象的gradeId字段值。
       R id = idGetter.apply(data);
       // 如果没有在字典集合中找到该值,就会被视为非法的字典数据。退出验证方法,并将具体的错误信息,返回给调用者。
       if (id != null && !dictIdSet.contains(id)) {
           String errorMessage = String.format("数据验证失败,字段 [%s] 存在无效的字典表字典值 [%s]!",
                   relationDict.masterIdField(), id);
           return CallResult.error(errorMessage, data);
       }
   }
   return CallResult.ok();
}

批量插入

终于到了最后一步,我们更应该有始有终的保证我们每一步的代码实现,都是充分考虑过性能优化的。在阅读以下代码之前,先补充说明一句,我们并没有使用 Mybatis Plus 自带的 saveBatch 方法,而是自动生成了真批量插入的 insertList 方法。

@Transactional(rollbackFor = Exception.class)
@Override
public void saveNewBatch(List<Course> courseList, int batchSize) {
   if (CollUtil.isEmpty(courseList)) {
       return;
   }
   if (batchSize <= 0) {
       batchSize = 10000;
   }
   // 分批提交,避免单次提交时,SQL过长。比如总数据10000条,batchSize是1000,那么分10次提交完成。
   int start = 0;
   do {
       int end = Math.min(courseList.size(), start + batchSize);
       // 这里使用subList优化,每次去不同的分片,没有构建新的列表,避免了不必要内存浪费。
       List<Course> subList = courseList.subList(start, end);
       // 这里会设置缺省值,比如逻辑删除字段,主键id的计算等,还有创建人、创建时间那组字段,
       // 都会在buildDefaultValue方法中给出,这里就不在展开的。
       subList.forEach(this::buildDefaultValue);
       // 这里的insertList方法,橙单会在CourseMapper.xml文件中,生成真实的批量插入语句。
       // 所以插入效率是可以保证的。
       courseMapper.insertList(subList);
       if (end == courseList.size()) {
           break;
       }
       start += batchSize;
   } while (true);
}

结语

赠人玫瑰,手有余香,感谢您的支持和关注,选择橙单,效率乘三,收入翻番。