2023/09/13回顾点评--这一篇写的干货太少了,其实就是后文后端思想-如何设计一个操作和管理Excel的业务模块的一个概要版,但这也有接近三千字,令我震惊,原来我也这么会水
前言
继前文组件开发指北之后,今天想聊聊如何抽取业务组件。我相信工作时间长了之后,大家都会有自己独到的偷懒小工具,身边也有同事分享过自己的工具类啥的,但是少有抽成组件的。无他,大部分情况下组件的限制过大了,适用范围狭窄,反而不如工具类型。或者换一种说法,当你的工作环境中没有统一的开发规范,抽取业务组件就近乎登天之路。因此要抽取业务组件,第一件事就是统一代码规范,让大家尽量风格一致,这样才能在基础之上去抽取组件,提升总体的开发效率。
我现在提供了四种组件,分别是tool-box(详情见组件开发指北),business-common(业务组件),sso-zero(单点登录组件),log-transfer(日志收集组件),工具类和日志的前两篇文章已经讲解过了,单点登录的话后面有机会再说。
业务场景
统一controller层风格+全局异常处理
美化Controller层
美化后效果,需要统一返回值并且配置全局异常监听
统一返回值
有一个类似于RemoteResult的类,包含状态码,消息,返回值,如果你有更多的内容需要输出那就扩展这个类
全局异常监听
简易版的会用到三个类
- 异常类ToolException,标准异常,错误码,错误信息。
- 抽象异常类AbstractException,这个类的主要作用是提供一个异常的架子,方便扩展,如果没啥需求,可以不用这个,只提供普通异常类就行
- 全局异常监听类ToolExceptionHandler,在这个类里面去监听不同的错误,根据不同的错误来进行对应的处理
放置公用类或者常量等
比如我之前做了一个门户网站,门户网站里面需要统一展示所有子系统的待办,待办的信息是子系统通过接口来进行增删改查操作。为了避免各个地方的入参和返回值的类对不上,就需要单独抽出来放在组件里面,随着组件的更新而更新,当然这种情况下为了兼容最好都加上类似@JsonIgnoreProperties(ignoreUnknown = true)的注解。
导入导出
导入导出这块我是集成的阿里的EasyExcel,将读写和性能上的问题交给成熟的中间件去处理,在此基础上对自己的业务进行封装。封装的话,主要是围绕与门户网站以及文件服务器的交互,除此之外,还针对具体的场景进行了优化,提供更便捷以及人性化的操作。
通用监听器
通用的监听器分成两种,一种是固定头的,一种是动态头读取,动态头的采用EasyExcel官方提供的监听类,暂时没有做什么修改。固定头的我是取巧做了一个通用的,官方原版是需要写泛型的,相当于一个导入类一个监听器。我的思路是抹掉泛型,数据用Object接收,最后读取后再强转为固定的类。
通用监听器除了提供读取的功能还针对以下场景做了优化
- 将读取数据时的类型转换异常封装到字符串里,方便做异常信息提醒
- 读取头信息并封装
PS:这里其实读取超大容量的excel可能会报OOM之类的问题,为了避免出现问题,可以在监听类中分批处理消息
import com.alibaba.excel.context.AnalysisContext;
import com.alibaba.excel.event.AnalysisEventListener;
import com.alibaba.excel.exception.ExcelDataConvertException;
import com.alibaba.fastjson.JSON;
import com.ruijie.common.pojo.excel.ExcelAnalyzeResDTO;
import lombok.extern.slf4j.Slf4j;
import org.springframework.util.StringUtils;
import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
import java.util.Map;
/***
* @Author WangZY
* @Date 2020/3/27 15:56
* @Description 通用读取excel数据
*/
@Slf4j
public class ExcelListener extends AnalysisEventListener {
private List<Object> dataList = new ArrayList<>();
private List<String> headList = new ArrayList<>();
private StringBuilder dateError = new StringBuilder();
@Override
public void invoke(Object data, AnalysisContext context) {
Integer curRowNum = context.readRowHolder().getRowIndex();
String json = JSON.toJSONString(data);
if (!StringUtils.isEmpty(json) && !"{}".equals(json)) {
log.info("解析第{}行数据:{}", curRowNum + 1, json);
dataList.add(data);
}
}
@Override
public void onException(Exception exception, AnalysisContext context) {
log.error("解析失败,但是继续解析下一行:{}", exception.getMessage());
// 如果是某一个单元格的转换异常 能获取到具体行号
// 如果要获取头的信息 配合invokeHeadMap使用
Integer curRowNum = context.readRowHolder().getRowIndex();
if (curRowNum > 2) {
if (exception instanceof ExcelDataConvertException) {
ExcelDataConvertException convertEx = (ExcelDataConvertException) exception;
log.error("第{}行,第{}列解析异常,数据为:{}", convertEx.getRowIndex(),
convertEx.getColumnIndex(), convertEx.getCellData());
if (convertEx.getMessage().contains("Integer") || convertEx.getMessage().contains("BigDecimal")) {
String errStr = subErrStr(convertEx);
dateError.append("[第").append(convertEx.getRowIndex()).append("行,第")
.append(convertEx.getColumnIndex()).append("列,数据")
.append(StringUtils.isEmpty(errStr) ? "" : errStr)
.append("不能解析为数字]");
} else if (convertEx.getMessage().contains("Date")) {
String errStr = subErrStr(convertEx);
dateError.append("[第").append(convertEx.getRowIndex()).append("行,第")
.append(convertEx.getColumnIndex()).append("列,数据").append(errStr)
.append("不能解析为日期]");
} else {
dateError.append("[第").append(convertEx.getRowIndex()).append("行,第")
.append(convertEx.getColumnIndex()).append("列,数据").append("不能解析]");
}
}
}
}
private String subErrStr(ExcelDataConvertException convertEx) {
String errStr = "";
String message = convertEx.getCause().getMessage();
int i = message.indexOf(":");
int j = message.indexOf(":");
if (i > 0) {
errStr = message.substring(i);
}
if (j > 0) {
errStr = message.substring(j);
}
return errStr;
}
@Override
public void invokeHeadMap(Map headMap, AnalysisContext context) {
Collection values = headMap.values();
headList.addAll(values);
}
@Override
public void doAfterAllAnalysed(AnalysisContext context) {
log.info("所有数据解析完成!");
}
public ExcelAnalyzeResDTO getExcelData() {
return new ExcelAnalyzeResDTO(dataList, dateError.toString(), headList);
}
}
通用读取方法
读取的话,一般分为MultipartFile和普通文件,用以下两种即可。然后我们的业务场景呢,会对表头有校验,我这里思路就是去找easy excel的字段注解,去拿对应的实体类上的表头信息,用这个与读取的表头信息做对比。除此之外我还封装了类型转换异常,针对常见的日期和数字做了更友好的提示。
读取MultipartFile需转换成InputStream
public List<?> readMultipartFile(MultipartFile mFile, Class<?> clazz, boolean headCheck) {
ExcelListener excelListener = new ExcelListener();
InputStream inputStream = null;
try {
inputStream = mFile.getInputStream();
} catch (IOException e) {
log.error("获取文件流失败", e);
throw new PtmException("读取文件失败");
}
EasyExcel.read(inputStream, clazz, excelListener).sheet().doRead();
.....
}
public List<?> readFile(String fileName, Class<?> clazz, boolean headCheck) {
ExcelListener excelListener = new ExcelListener();
EasyExcel.read(fileName, clazz, excelListener).sheet().doRead();
ExcelAnalyzeResDTO excelData = excelListener.getExcelData();
//校验表头是否正确
if (headCheck) {
checkHeadRight(clazz, excelData);
}
//判断是否有类型转换异常
String dateError = excelData.getDateError();
if (!StringUtils.isEmpty(dateError)) {
throw new PtmException(dateError);
} else {
return excelData.getExcelDataList();
}
}
private void checkHeadRight(Class<?> clazz, ExcelAnalyzeResDTO excelData) {
Field[] fields = clazz.getDeclaredFields();
List<String> realHeadList = new ArrayList<>();
for (Field field : fields) {
field.setAccessible(true);
ExcelProperty excelProperty = field.getAnnotation(ExcelProperty.class);
if (excelProperty != null) {
String[] value = excelProperty.value();
realHeadList.addAll(Arrays.asList(value));
}
}
List<String> headList = excelData.getHeadList();
String collect = headList.stream().filter(e -> !realHeadList.contains(e))
.collect(Collectors.joining(","));
if (!StringUtils.isEmpty(collect)) {
throw new PtmException("模板异常,请确认以下表头是否填写错误=" + collect);
}
}
根据文件名创建文件
内置一个文件创建类,主要方便导出时候创建文件用
public File assemblyFile(String filePath, String fileName) {
if (StringUtils.isEmpty(filePath)) {
filePath = commonProperties.getFileDir();
}
// 先判断文件夹是否存在,避免不存在时报错
File filePathExist = new File(filePath);
if (!filePathExist.exists()) {
boolean mkdirs = filePathExist.mkdirs();
if (!mkdirs) {
return null;
}
}
DateTimeFormatter dtf = DateTimeFormatter.ofPattern("yyyy年MM月dd日HHmmss");
String format = LocalDateTime.now().format(dtf);
if (fileName.contains(".")) {
String[] split = fileName.split("\\.");
if (split.length > 2) {
throw new PtmException("请注意文件格式,不允许文件名中存在多个小数点");
}
return new File(filePath + split[0] + format + "." + split[1]);
} else {
// 默认导出xlsx格式
return new File(filePath + fileName + ".xlsx");
}
}
分页导出文件
解决xls,也就是老板excel单sheet不能超过65536行的问题,这里做了分页输出
private void creatExportFile(File file, Class<?> clazz, List<?> data) {
int sheetSize = 60000;
if (file.getName().endsWith("xls") && data.size() > sheetSize) {
ExcelWriter excelWriter = null;
try {
excelWriter = EasyExcel.write(file, clazz).build();
for (int i = 0; i < (data.size() / sheetSize) + 1; i++) {
int start = i * sheetSize;
int end = (i + 1) * sheetSize;
List<?> subList;
if (data.size() < end) {
subList = data.subList(start, data.size());
} else {
subList = data.subList(start, end);
}
WriteSheet writeSheet = EasyExcel.writerSheet(i, "数据" + i).build();
excelWriter.write(subList, writeSheet);
}
} finally {
// 千万别忘记finish 会帮忙关闭流
if (excelWriter != null) {
excelWriter.finish();
}
}
} else {
EasyExcel.write(file, clazz).sheet(file.getName()).doWrite(data);
}
}
代码实现
导入案例
导入步骤
1.调用commonImportExcel方法读取并传递文件到PTM2.0(这一步必须放在外面,是对excel的基本校验,有错误及时推送前端,不能异步)
2.开启异步,使用readFile或者readMultipartFile解析文件,并进行业务处理
3.handle处理部分,调用finishFileStatus方法回传PTM2.0状态
可选操作
1.identifier标识可使用LuaTool生成,例如String generateOrder = luaTool.generateOrder("SMB-PRODUCT-");
导出案例
导出步骤
1.自定义文件名称,并调用createPtmFile(此刻fileId和fileName可以乱写,后续被覆盖)
2.开启异步,进行业务处理,调用asyncExportExcel方法导出,注意return返回的fileid
3.handle处理部分,调用finishFileStatus方法回传PTM2.0状态
为啥用completablefutrue不用async?--如有性能问题,请自行创建线程池配置,默认的线程池采用无限队列
推荐completablefutrue的原因是使用比async方便,async在spring中通过切面实现,使用时需要注意不能内部调用,方法pubic修饰,类被spring管理等使切面失效的问题。
写在最后
业务组件这一篇,篇幅比较短。原因呢,自然是我接触的场景还是比较少,因为就如我前面所说,业务组件都是基于通用业务场景的代码封装,需要较高的代码规范。如何在工作中发现并抽取业务组件,这才是困难的第一步,我的工作场景能抽出组件来的基本是excel的导入导出,这一块封装了门户,文件服务器以及easy excel的操作,将原本繁杂的代码缩短到了几十行,极端情况下甚至可以十几行解决一个导入或者导出,这都是对工作效率的提升。当然推广起来也是比较麻烦,为了应对业务的需求和变动,组件需要不断的更新,同事用起来有问题就得找你,其实也侧面反映了一旦依赖上组件,就逃不开了,哈哈。
上一篇关于日志收集的反响不错,下一篇的思路我也想好了,既然这一篇是关于业务组件,那么下一篇就是关于具体开发场景的套路介绍。具体是并行批量插入数据库,并行处理数据,缓存应用等等实际场景的套路代码。