前言
我所在团队是做供应链业务的,涉及到很多报表数据,因此Excel的操作总是少不了,比如用户会想着通过Excel去批量操作一些东西,因为他们之前就是老玩Excel。之前我有篇文章介绍过如何抽取业务组件,其中提到一个我个人总结的观点就是,业务组件其实就是场景的抽象封装。文章比较概况地讲解了业务组件,本来我是以为不会更新了,结果上周公司讲PPT的时候,别组的一个技术leader对我这个Excel模块的设计很感兴趣,让我突然意识到原来这模块我设计的还挺不错。自恋的话不多说,自信人起飞,代码自然神。
背景介绍
我所在的团队是开发了一个门户网站,说是门户网站其实也算比较简陋的那种,做了几个核心的功能,比如消息通知、日志收集、单点登录、权限管理、待办和文件汇总等。门户网站做什么呢?打个比方,门户网站首页就像是银行业务大厅,首先来访用户需要登记拿票选择要办理的业务,这个过程就是单点登录和权限管理模块处理的,负责给用户分配角色(这里提一嘴,我设计的权限是RBAC模型,是基于角色访问控制)。然后用户可以通过大屏看到自己要处理的事情,我是在首页做了一个待办列表,展示用户各个业务子系统需要处理的待办,点击处理就会跳转到业务子系统中处理。因为用户操作Excel属于高频动作,所以在待办列表旁边增加了一个文件列表,用于记录用户导入或导出的文件,同时也提供下载功能。总的来说,门户网站就是一个统一处理业务子系统待办以及查看文件的大厅,是我们组各个业务子系统的统一入口。说远了,门户网站这个其实我自己也没弄得太清楚,就不在这深挖了。
回头讲讲Excel,基于我组的业务需求,Excel是需要发送到门户网站展示的,这个定死了。接下来搞定存储的问题,存储的话还是文件服务器,不要搞什么本地存储的骚操作。公司有提供一个基于FastDfs架构的文件服务器,不管好坏,提供了API进行操作,这里直接用即可。Excel的存储和展示都完成了,最重要的解析和操作选用阿里的Easy Excel就好了,三年前我刚实习的时候,领导让我做导入导出的时候,我上Github找到的就是这个,当时对比什么原生POI,EasyPoi之类的简直薄纱。这里贴一下官网https://easyexcel.opensource.alibaba.com/,阿里的中间件一般进入成熟期后,文档写的都不错。好期待我也能有机会去参与这样一个大型中间件的开发。
最后用一张图总结一下用户在我现在负责的系统中的一个数据操作过程。
![](https://img-blog.csdnimg.cn/img_convert/fe4377cd28f75c88da171bccdb48a1a5.png)
业务场景
我根据我面对的用户群体,将他们的操作分为四种类型,通用导入、通用导出、动态导入以及动态导出,对我的开发同事来说他们面临的开发情况也是这样的。
其实一开始根据我提供的业务组件只有两种,后来发现不支持同事进行动态表头、合并单元格等复杂Excel操作的时候,又给加了两种。
异步和同步在我看来可以归为一种,因为我是以异步的基础去设计整个模块的,考虑情况会更多更复杂,是包含同步的情况的。
操作流设计
如背景所述,业务子系统在操作时需要发送数据到文件服务器和门户网站,这个地方是可以封装出来的,因为是通用的操作。封装起来有多个好处。
规范了代码减少了BUG,即使是新同事也能直接上手,调用API就完事了,看看文档有手就行,不用关注和别的系统的交互。
方便我进行总体设计,修改时也能统一修改。
因为用户可能会上传大文件,并且我们系统操作的业务时间也是不一定的,页面上不能一直转圈圈吧,所以得考虑异步的情况,返回提示用户去门户网站看结果。既然是异步肯定得考虑交互友好,接下来按照步骤来一步步讲解整个Excel操作流程的设计。
导入流
首先时与页面请求同步的基础三步,这里的考虑是对Excel做一个最基本的检查,如果Excel本身有问题那么就要马上反馈给用户。同样的为了用户的体验,发送到门户网站让用户及时看到文件正在被处理,这一步就不能放到异步里去操作了。毕竟大家都想看到自己的诉求有反馈,你说一个Excel上传了,你页面就返回一个正在处理,然后就没了,想找个地方看进度都没,那不是上火嘛。
说点题外话,这一块我和前端同事有考虑过优化,给一个处理进度条。初期设计是通过WebSocket或者MQTT去搞,为此我还看了会Netty,可惜后来项目太紧就没弄,其实我还蛮想做的,感觉挺有意思。
Excel文件的基础解析--文件、名称、格式的基础检查
检查通过之后上传文件服务器
组装文件数据发送给门户网站,门户文件列表显示该文件正在导入中
/**
* @param mFile 上传文件
* @param identifier PTM2.0文件列表查询参数:唯一标识
* @Author WangZY
* @Date 2021/12/14 14:25
* @Description 导入文件--文件下载链接10天有效期
* @return 文件列表ID
**/
public ExcelUploadResDTO commonImportExcel(MultipartFile mFile, String identifier) {
if (mFile.isEmpty()) {
throw new PtmException("上传excel文件不能为空");
} else {
String fileName = mFile.getOriginalFilename();
if (StringUtils.isEmpty(fileName)) {
throw new PtmException("excel名称不能为空");
} else {
if (!fileName.endsWith(".xls") && !fileName.endsWith(".xlsx")) {
throw new PtmException("excel格式不正确");
} else {
//组装Token和文件服务器权限信息发送请求
String token = getToken(true, 10);
if (mFile.getSize() <= 0) {
throw new PtmException("上传文件为空");
} else {
// 先判断文件夹是否存在,避免不存在时报错
String fileDir = commonProperties.getFileDir();
File filePathExist = new File(fileDir);
if (!filePathExist.exists()) {
boolean mkdir = filePathExist.mkdirs();
if (!mkdir) {
return null;
}
}
//将multipartFile转换为临时文件file,避免异步时子线程找不到文件实例
File file = new File(fileDir + mFile.getOriginalFilename());
try (BufferedInputStream bis = new BufferedInputStream(mFile.getInputStream());
BufferedOutputStream bos =
new BufferedOutputStream(Files.newOutputStream(file.toPath()))) {
int bytesRead = 0;
byte[] buffer = new byte[8192];
while ((bytesRead = bis.read(buffer, 0, 8192)) != -1) {
bos.write(buffer, 0, bytesRead);
}
} catch (Exception e) {
log.error("multipartFileToFile失败", e);
}
String fileId = ExternalApi.uploadFileServer(token, file, splicingFileServerUrl("upload"));
//组装文件信息,在PTM文件列表创建一条处理中的记录
long ptmFileId = createPtmFile(fileId, fileName, identifier, true);
return new ExcelUploadResDTO(ptmFileId, file);
}
}
}
}
}
然后从这里开始异步,页面直接返回提示用户去门户网站看处理情况就好了。
2022.12.18更新,这里为了避免异步开启后主线程处理完毕关闭MultipartFile的IO流,使得上传的临时文件删除的问题,选择读取上传IO流
使用Easy Excel读取上传的Excel文件。
获取一行行的文件数据以及解析的错误信息,如果有错误信息的话生成错误提示文件,并结束导入变更门户网站文件状态为导入失败。
解析完成后就开始做业务操作,比如根据需求校验文件中的数据、批量新增数据。
如果文件处理成功,修改门户网站文件列表对应行状态为导入成功,处理失败改状态为失败。
这里说一个我个人觉得比较好的亮点设计,就是对错误的统一处理这块。因为Easy Excel解析是一行行读,监听器里每一行的错误都能拿到,所以读取文件的实体类我冗余了一个错误信息的字段,把每一行的错误信息都存在这个字段,然后再导出一个错误提示文件,用户在文件中就能清晰的看到错误在哪。
![](https://img-blog.csdnimg.cn/img_convert/2f8bda78d1944e7f6aebd4bdfec0d984.png)
错误如何展示给用户,这一部分分成四种情况。
Excel文件基本解析或者请求文件服务器和门户网站失败是及时响应页面请求返回错误信息,不能耽误,这种最基本的错误就要马上返回给用户。
Excel使用Easy Excel解析时出现的解析错误,比如日期、数字无法解析之类的解析错误。这一块我给开发同事两个选择,一个是一行行数据塞入错误信息然后生成一个新的错误提示文件,另一个是给一句话提示展示在文件列表。
文件数据在业务处理时提示错误,同第二条。
异步过程中出现的没被主动catch的错误,在截取后(门户网站数据库表设计限制字段长度)展示在文件列表。
![](https://img-blog.csdnimg.cn/img_convert/33c8f0b07ebd82e263ad8b075e669d10.png)
![](https://img-blog.csdnimg.cn/img_convert/6210e23914bea9762e2c6e9e10779eb7.png)
流程图
导入流整个设计的流程图我放在下面,方便大家理解
![](https://img-blog.csdnimg.cn/img_convert/ff99d1a89983f9f67e630b3e49439636.png)
伪代码
这一块涉及的代码太多了,不可能全贴出来,我就写一段伪代码吧
通用Controller层
@LimitMethod
@PostMapping("/import")
public RemoteResult<String> importAdd(@RequestParam("file") MultipartFile multipartFile)
常见引入依赖
@Autowired
private LuaTool luaTool;
@Autowired
private ExcelTool excelTool;
1.第一步生成单号或者标识,userId之类的最好也取出来,做好传递的准备
identifier标识可使用LuaTool生成
String generateOrder = luaTool.generateOrder("SMB-PRODUCT-");
生成局部变量方便线程间数据传递
RequestContext.getCurrentContext()或者使用CurrentUserUtil工具类(sso-zero提供)
String userId = CurrentUserUtil.getUserId();
String userName = CurrentUserUtil.getUserName();
2.第二步调用commonImportExcel方法读取并传递文件到PTM2.0(这一步必须放在外面,是对excel的基本校验,有错误及时推送前端,不能异步)
//该方法包含对excel的基本校验,并且自带上传文件服务器以及传递PTM
//这里为了避免异步开启后主线程处理完毕关闭MultipartFile的IO流,使得上传的临时文件删除的问题
ExcelUploadResDTO excel = excelTool.commonImportExcel(multipartFile, generateOrder);
long ptmFileId = excel.getPtmFileId();
3.开启异步,使用readFile或者readMultipartFile解析文件,并进行业务处理,如果此部分需要事务,请另起一个事务类,使用@Transactional(rollbackFor = Exception.class)或者在当前代码区域手动开启事务或者自注入再调用方法。
CompletableFuture.runAsync(() -> {
//读取文件,readFile方法会调用Easy Excel解析读取excel,如果有读取错误会抛出错误,方法入参中有表头校验,选择true会校验表头是否正确,不正确会抛异常
List<ItemAddExcelDTO> dataList = (List<ItemAddExcelDTO>) excelTool.readFile(excel.getTempFile(),
ItemAddExcelDTO.class, false);
//业务处理,这里ItemAddExcelDTO导入类需要冗余一个异常信息字段errMsg,业务处理的时候把错误信息塞进去
judgeImport(dataList);
//判断errMsg字段是否有值,有值说明这一行有业务逻辑错误
ItemAddExcelDTO orElse = dataList.stream()
.filter(ma -> StringUtils.isNotBlank(ma.getErrMsg())).findAny().orElse(null);
//可选,异常文件导出
if (orElse != null) {
String fileName = "错误提示文件-" + generateOrder;
excelTool.synchronizeExportExcel(dataList, ItemAddExcelDTO.class, fileName, generateOrder,
userId, userName);
throw new PtmException("文件校验有错误项,请下载错误提示文件");
} else {
//继续业务操作
}
})
4.handle处理部分,调用finishFileStatus方法回传PTM2.0状态,注意这里的ptmFileId是指PTM文件列表的id
.handle((res, e) -> {
if (e != null) {
log.error("物料风险地图芯片导入异步处理数据失败,流程单号:{},异常信息:", generateOrder, e);
excelTool.finishFileStatus(ptmFileId, null, null, ExcelFieldConstant.TYPE_IMPORT,
ExcelFieldConstant.IMPORT_FAILED, e.getMessage());
} else {
excelTool.finishFileStatus(ptmFileId, null, null, ExcelFieldConstant.TYPE_IMPORT,
ExcelFieldConstant.IMPORT_SUCCESS, null);
}
return null;
});
为啥用completablefutrue不用async?--如有性能问题,请自行创建线程池配置,默认的线程池采用无限队列
推荐completablefutrue的原因是使用比async方便,async在spring中通过切面实现,使用时需要注意不能内部调用等使切面失效的问题,注意方法pubic修饰,类被spring管理。
动态导入
动态导入和通用的其实区别不大,其实就是把通用的封装好的拆开,比如错误处理这就不能通用了,需要开发同事自行处理。和通用的相比其实只有第三步,在异步代码块中的部分发送变化,需要自行使用EasyExcel做相关的操作
这里自定义的部分其实只替换了通用导入第三步的readFile相关封装好的方法,其他照常用就行了
........
CompletableFuture.runAsync(() -> {
与前面通用的使用readFile系列方法不一样的地方来了,动态表头或者其他自定义的操作需要自行完成readFile中的方法
EasyExcel.read(inputStream, clazz, excelListener).sheet().doRead();
.handle((res, e) -> {
......
});
导出流
导出因为是我们自己去创建文件,所以会少一步文件校验,在异步之前同样要做的是把文件相关信息发送到门户网站,不过此时是传一个壳子信息,因为真正的文件还没有被创建。
生成一个唯一标识,例如单号
发送文件壳子信息给门户网站,生成一条导出中的文件记录
异步模块处理就很简单,组装数据生成导出文件,修改门户网站文件列表状态即可
业务处理组装要导出的数据
使用Easy Excel导出到前面创建的文件File中
上传文件服务器
将文件服务器返回的fileId发送给门户网站,同时修改文件状态
流程图
照例给流程图
![](https://img-blog.csdnimg.cn/img_convert/b5bcd6d36be2e2a25daaa4689ddec144.png)
伪代码
照例给个伪代码
1.自定义文件名称,并调用createPtmFile(此刻fileId和fileName可以乱写或不写,后续被覆盖)
String generateOrder = luaTool.generateOrder("SMB-PRODUCT-OUT-");
String fileName = excelTool.suffixFileName("产品基础数据导出-", "yyyy年MM月dd日HHmmss");
long ptmFileId = excelTool.createPtmFile("", fileName, generateOrder, false);
生成局部变量方便线程间数据传递
RequestContext.getCurrentContext()或者使用CurrentUserUtil工具类(sso-zero提供)
2.开启异步,进行业务处理,调用asyncExportExcel方法异步导出,注意return返回的fileid,fileId是文件服务器给的
CompletableFuture.supplyAsync(() -> {
业务处理...convert是要导出的数据
return excelTool.asyncExportExcel(convert, ProductExportDTO.class, fileName);
})
3.handle处理部分,调用finishFileStatus方法回传PTM2.0状态
.handle((res, e) -> {
if (e != null) {
log.error("SMB海外预测报备异步导出处理数据失败,流程单号:{},异常信息:", generateOrder, e);
excelTool.finishFileStatus(ptmFileId, res, fileName, ExcelFieldConstant.TYPE_EXPORT,
ExcelFieldConstant.EXPORT_FAILED, e.getMessage());
} else {
excelTool.finishFileStatus(ptmFileId, res, fileName, ExcelFieldConstant.TYPE_EXPORT,
ExcelFieldConstant.EXPORT_SUCCESS, null);
}
return null;
});
动态导出
其实区别也不大,也是只有异步代码块有区别。这块对组件提供的通用方法进行了拆分,方便开发同事选用。
......与通用的保持一致
开启异步,进行业务处理,调用自己的导出方法,注意return返回的fileid
CompletableFuture.supplyAsync(() -> {
业务处理...convert是要导出的数据
创建文件可以用此方法,后面两参数决定文件名是否追加后缀
File file=assemblyFile(String filePath, String fileName, boolean isAppend, String pattern)
自定义动态导出
.......
将填入动态导出数据的文件上传到文件服务器获取文件ID,并且推送PTM
String flieId=uploadFileServer(File file, boolean isLongValid, long day)
return fileId;
})
3.handle处理部分,调用finishFileStatus方法回传PTM2.0状态
.handle((res, e) -> {
......
});
组件设计
组件的设计主要包含两部分,一是对于Easy Excel的优化与封装,二是对门户网站和文件服务器交互的封装。
Easy Excel在Excel的操作和性能上自然是没得说,这一块不用优化。我的优化主要是从本地化和易用性上面进行封装,结合我遇到的场景来做的。
监听器改造
因为我们是通用的组件,所以肯定得提供一个通用的监听器。通用的监听器根据场景分成两种,一种是固定头的,一种是动态头读取,动态头的采用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();
if (StringUtils.isEmpty(message)) {
return "";
} else {
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);
}
}
import lombok.AllArgsConstructor;
import lombok.Data;
import java.util.List;
/**
* @Classname ExcelAnalyzeResDTO
* @Date 2022/1/17 9:44
* @Author WangZY
* @Description excel解析结果类
*/
@Data
@AllArgsConstructor
public class ExcelAnalyzeResDTO {
/**
* excel数据
*/
private List<?> excelDataList;
/**
* 校验异常
*/
private String dateError;
/**
* 头信息
*/
private List<String> headList;
}
通用读取方法
读取文件类型的话一般常见的开发场景处理MultipartFile和File即可,我这里也只提供了这两种,其他的自行查看Easy Excel官方处理方法。读取这里因为监听器额外输出的信息得以去校验表头和类型转换异常.
表头校验我的思路是去找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);
}
}
分页导出文件
解决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);
}
}
写在最后
算是前文后端思想-如何抽取业务组件的实践篇,也是对整个Excel模块的一个总结和思考。其实我一开始的时候只有一个整体的框架设计,只封装了和外部系统的交互。后来是慢慢的去优化,根据用户和开发同事的需求去不断地迭代,慢慢的才有了一个详细的设计。业务组件就是这样的,不可能一开始就尽善尽美,所以要持续跟进,尽力去完善,然后不知不觉间就成为了优秀的设计。
以上Excel模块其实优化的差不多了,整个架子应该就不会有太大的变动了。回看整个模块,引入了Easy Excel和文件服务器,关联了门户网站,还算依赖的比较少,核心是Easy Excel不可替换,其他两个文件服务器和门户网站你可以任意替换。最近一直在想着写的啥,一周一更属实有点爆种了,原创这速度我感觉有点扛不住了,渐渐的会放缓速度,暂定一周半一更,原创不易求大佬们给个赞,谢谢啦。