设计模式实战01-模版模式
一.需求描述
我们在项目开发中经常碰到导入的需求,由于导入的逻辑通常比较复杂,涉及到一些业务上的校验等逻辑,每个人遇到这些需求可能会依据自己的习惯写很多代码, 导致项目冗长且没有规范, 不美观; 本文将基于一个导入需求,设计一个后端通用导入模版.提高代码的可读性与易维护性;
具体需求如下:
1.选择导入文件界面
2.导入校验进度条
3.导入数据校验失败结果
4.导入数据校验成功结果
5.最终导入成功结果
二.代码实现
根据以上需求,我们提取到以下几点重要信息
- 导入数据校验及返回结果
- 实时查询导入的进度
- 最终执行导入
通过分析,确定了异步导入+redis方案,提前返回一个任务ID, 在异步导入的线程中实时生成进度信息放入redis,然后前端根据任务ID实时查询进度;
1.设计导入结果返回参数
@ApiModel("通用导入结果")
@Data
public class GeneralImportResultResp {
/**
* 导入总数
*/
@ApiModelProperty("导入总数")
private Integer totalCount;
/**
* 成功数量
*/
@ApiModelProperty("成功数量")
private Integer successCount;
/**
* 失败数量
*/
@ApiModelProperty("失败数量")
private Integer failedCount;
/**
* 导入结果excel
*/
@ApiModelProperty("导入结果excel")
private String failedXlsUrl;
/**
* 已完成数量
*/
@ApiModelProperty("已完成数量")
private Integer completed;
/**
* 任务是否已执行完成
*/
@ApiModelProperty("任务是否已执行完成")
private Boolean done;
/**
* 任务完成进度(百分比)
*/
@ApiModelProperty("任务完成进度(百分比)")
private Integer progress;
@ApiModelProperty("任务执行中是否有异常")
private Boolean hasError = false;
/**
* 导入确认信息
*/
@ApiModelProperty("导入确认信息")
private String confirmMessage;
/**
* 计算任务完成度
*/
public void computeProgress(Integer completed) {
this.completed = completed;
if (this.totalCount == null || completed == null || completed <= 0) {
this.done = false;
this.progress = 0;
return;
}
if (completed >= this.totalCount) {
this.done = true;
this.progress = 100;
return;
}
this.done = false;
this.progress = BigDecimal.valueOf(completed)
.multiply(BigDecimal.valueOf(100))
.divide(BigDecimal.valueOf(totalCount), 0, RoundingMode.DOWN)
.intValue();
}
public boolean validateSuccess() {
return failedCount == null || failedCount == 0;
}
}
2.定义导入接口
public interface RedisImport {
GeneralImportResultResp getImportResult(String taskId);
/**
*
* @param 导入上下文
* @return 返回导入任务ID
*/
String importExcel(RedisImportContext context);
}
3.定义通用导入DTO
@Data
public class BaseImportDTO {
@ExcelIgnore
private Boolean success = Boolean.TRUE;
@ExcelIgnore
private String errorMsg;
@ExcelIgnore
private Boolean isConfirm = Boolean.FALSE;
}
4.定义导入上下文对象
@Data
@Builder
@AllArgsConstructor
@NoArgsConstructor
public class RedisImportContext {
/**
* excel文件
*/
private MultipartFile excelFile;
/**
* excel文件名称
*/
private String execiFileOriginalFileName;
/**
* 用户信息封装类
*/
private ImportBaseUserReq baseUserReq;
/**
* 是excel文件检查操作还是导入操作
*/
private Boolean isSave;
/**
* 如果excel过大,建议检查时进行分批检查,而不是整个excel文件检查
*/
private int splitExcelDataSize;
/**
* 进度条计数 save操作需要额外的操作时间,因此从-1开始
*/
public int getStartTaskCount() {
return this.isSave ? -2 : -1;
}
}
5.编写导入模版
@Slf4j
public abstract class RedisForEachImportTemplate<T extends BaseImportDTO, F> implements RedisImport {
@Override
public GeneralImportResultResp getImportResult(String taskId) {
String json = RedisUtils.initInstance().redisGet(buildTaskKey(taskId));
if (StringUtils.isEmpty(json)) {
throw new ServiceException(BizErrorCodeEnum.OPERATION_FAILED.getCode(), "任务ID不存在");
}
GeneralImportResultResp resp = JacksonUtils.string2Obj(json, GeneralImportResultResp.class);
if (resp.getHasError() != null && resp.getHasError()) {
throw new ServiceException(BizErrorCodeEnum.OPERATION_FAILED.getCode(), "导入失败,excel内容可能不正确,请检查后重新导入");
}
return resp;
}
@Override
public String importExcel(RedisImportContext context) {
/* 1. 导入加锁 */
String lockKey = addLock(context);
/* 2. 读取excel (该方法貌似无法放在异步中执行,会抛出FileNotFoundException)*/
List<T> excelDataList = readExcelDataWithLockKey(lockKey, context);
/* 读取excel文件之后,将其置为null(因为对象过大,不易长时间设置为强引用) */
context.setExcelFile(null);
/* 3. 异步导入 */
return asyncImportExcel(lockKey, excelDataList, context);
}
private String asyncImportExcel(String lockKey, List<T> excelDataList, RedisImportContext context) {
String taskId = RandomStringUtils.randomAlphanumeric(12);
CompletableFuture.runAsync(() -> {
try {
log.info("开始执行导入任务, 任务ID={}", taskId);
/**
* 初始化进度条
* 由于check和save都累计了进度条,因此进度条总数是*2
*/
GeneralImportResultResp resp = initTaskProgress(taskId, excelDataList.size(), context.getStartTaskCount());
/* excel整体数据检查 */
checkExcelSheetData(excelDataList);
// 批量获取校验和导入需要的数据
F importNeedData = getImportNeedData(context.getBaseUserReq(), excelDataList);
// 计数器
AtomicReference<Integer> count = new AtomicReference<>(context.getStartTaskCount());
/* excel行数据检查 */
for (List<T> dataList : Lists.partition(excelDataList, context.getSplitExcelDataSize())) {
checkExcelRowData(dataList, context, importNeedData);
}
saveTaskProgress(taskId, resp, count.get() + 1);
/* 初步返回导入校验结果 */
resp = buildResp(excelDataList, context.getExeciFileOriginalFileName());
/* 如果是保存操作,就执行save */
if (context.getIsSave() && resp.validateSuccess()) {
int successCount = 0;
int failCount = 0;
for (T t : excelDataList) {
try {
if (saveDO(t, context.getBaseUserReq(), importNeedData)) {
t.setSuccess(Boolean.TRUE);
successCount++;
} else {
t.setSuccess(Boolean.FALSE);
failCount++;
}
buildFailOrSuccessResp(t);
} catch (Exception ex) {
failCount++;
t.setSuccess(Boolean.FALSE);
t.setErrorMsg(ex.getMessage());
buildFailOrSuccessResp(t);
}
saveTaskProgress(taskId, resp, count.get() + 1);
}
resp = buildFinalResp(excelDataList, successCount, failCount, context.getExeciFileOriginalFileName());
}
saveTaskProgress(taskId, resp, resp.getTotalCount());
log.info("导入任务结束,任务ID:{},任务结果:{}", taskId, JacksonUtils.obj2String(resp));
} catch (Exception ex) {
log.error("导入任务报错:" + ex.getMessage(), ex);
GeneralImportResultResp resp = buildErrorResp();
saveTaskProgress(taskId, resp, 0);
} finally {
// 任务执行完毕后释放锁
RedisUtils.initInstance().redisDel(lockKey);
}
});
return taskId;
}
protected abstract void buildFailOrSuccessResp(T excelData);
// 批量获取校验和导入需要的数据
protected abstract F getImportNeedData(ImportBaseUserReq userReq, List<T> excelData);
/**
* 读取excel需要try catch, 因为读取文件的listener里也有校验,会抛出异常,此时必须释放锁
*/
private List<T> readExcelDataWithLockKey(String lockKey, RedisImportContext context) {
try {
return readExcelData(context.getExcelFile());
} catch (Exception ex) {
log.error("读取excel报错:" + ex.getMessage(), ex);
RedisUtils.initInstance().redisDel(lockKey);
throw ex;
}
}
private String addLock(RedisImportContext context) {
String lockKey = buildLockKey(context);
/* 调用setIfAbsent,保证原子性,不要先get再set */
Object uuid = RedisUtils.initInstance().getAtomLock(lockKey, 60L);
Assert.notNull(uuid, "已有其他用户在执行该操作,请稍后再试");
return lockKey;
}
private GeneralImportResultResp buildErrorResp() {
GeneralImportResultResp resp = new GeneralImportResultResp();
resp.setFailedCount(0);
resp.setSuccessCount(0);
resp.setTotalCount(0);
resp.setHasError(true);
return resp;
}
private GeneralImportResultResp initTaskProgress(String taskId, int totalCount, int startTaskCount) {
GeneralImportResultResp resp = new GeneralImportResultResp();
resp.setFailedCount(0);
resp.setSuccessCount(0);
resp.setTotalCount(totalCount);
/* 初始化redis进度条数据 */
saveTaskProgress(taskId, resp, startTaskCount);
return resp;
}
/**
* 保存任务进度
*
* @param taskId 任务ID
* @param resp 任务出参resp
* @param computeProgress 已处理的数量
*/
private void saveTaskProgress(String taskId, GeneralImportResultResp resp, Integer computeProgress) {
resp.computeProgress(computeProgress);
String json = JacksonUtils.obj2String(resp);
RedisUtils.initInstance().redisSet(buildTaskKey(taskId), json, 10L, TimeUnit.MINUTES);
log.info("导入保存任务进度,任务id:{},进度详情:{}", taskId, json);
}
/**
* 构造唯一key
*/
protected abstract String buildLockKey(RedisImportContext context);
/**
* 构造redis中任务key
*/
protected abstract String buildTaskKey(String taskId);
/**
* 读取文件
*/
protected abstract List<T> readExcelData(MultipartFile excelFile);
/**
* excel整体校验
*/
protected abstract void checkExcelSheetData(List<T> excelDataList);
/**
* excel 行校验
*/
protected abstract void checkExcelRowData(List<T> dataList, RedisImportContext context, F importNeedData);
/**
* 根据excel文件构造返回值
*/
protected abstract GeneralImportResultResp buildResp(List<T> excelDataList, String originalFilename);
protected abstract GeneralImportResultResp buildFinalResp(List<T> excelDataList, int successCount, int failCount,
String originalFilename);
/**
* 执行真正导入
*/
public abstract boolean saveDO(T excelData, ImportBaseUserReq userReq, F importNeedData);
三. 模版使用
本次导入模版需要提供给前端四个接口,如下:
1.前端接口
@PostMapping("/getImportTemplateUrl")
@ApiOperation("导入模板文件url")
public BaseResponse<String> getImportTemplateUrl() {
return BaseResponseBuilder.successString(IMPORT_TEMPLATE_URL);
}
@NoRepeatSubmit
@PostMapping(value = "/importList/check", consumes = {MediaType.MULTIPART_FORM_DATA_VALUE})
@ApiOperation("导入数据检查")
public BaseResponse<String> importListCheck(@RequestPart("file") MultipartFile file) {
BaseUserInfo user = BaseUserContextHolder.getUserInfo();
Assert.notNull(user, "用戶信息不能为空");
RedisImportContext context = buildImportContext(file, user, false);
return BaseResponseBuilder.successString(couponListImportService.importExcel(context));
}
@NoRepeatSubmit
@PostMapping(value = "/doImport", consumes = {MediaType.MULTIPART_FORM_DATA_VALUE})
@ApiOperation("执行导入接口")
public BaseResponse<String> doImport(@RequestPart("file") MultipartFile file) {
BaseUserInfo user = BaseUserContextHolder.getUserInfo();
Assert.notNull(user, "用戶信息不能为空");
RedisImportContext context = buildImportContext(file, user, true);
return BaseResponseBuilder.successString(couponListImportService.importExcel(context));
}
@ApiOperation("导入结果查询")
@PostMapping("/import/result/{taskId}")
public BaseResponse<GeneralImportResultResp> getImportResult(@PathVariable("taskId") String taskId) {
return BaseResponseBuilder.success(couponListImportService.getImportResult(taskId));
}
2.导入服务实现类
@Slf4j
@Service
public class CouponListImportServiceImpl extends RedisForEachImportTemplate<CouponImportDTO, CouponImportData> {
private static final String IMPORT_LOCK_KEY = "coupon_info_import_lock:%s";
private static final String IMPORT_PROGRESS_KEY = "coupon_info_import_progress:%s";
@Override
protected String buildLockKey(RedisImportContext context) {
return String.format(IMPORT_LOCK_KEY, context.getBaseUserReq().getTenantId());
}
@Override
protected List<CouponImportDTO> readExcelData(MultipartFile excelFile) {
CouponInfoListImportListener importListener = new CouponInfoListImportListener(1000);
try (InputStream is = excelFile.getInputStream()) {
EasyExcel.read(is, CouponImportDTO.class, importListener).sheet().doRead();
} catch (IOException e) {
log.error("优惠券信息导入Excel读取失败", e);
throw new ServiceException(BizErrorCodeEnum.OPERATION_FAILED.getCode(), "excel文件读取失败");
}
return importListener.getCouponInfoImports();
}
@Override
protected void checkExcelSheetData(List<CouponImportDTO> excelDataList) {
/* 校验 excel行不能有重复值 */
HashSet<String> hashSet = new HashSet<>();
excelDataList.forEach(dto -> {
String item = dto.getIntegralAccount() + ":" + dto.getCouponTemplateId();
if (hashSet.add(item)) {
return;
}
dto.setFailReason("表格中存在相同的积分账号和优惠券模版");
});
}
@Override
protected void checkExcelRowData(List<CouponImportDTO> dataList, RedisImportContext context, CouponImportData couponImportData) {
Map<String, List<SimpleUserResp>> userMap = couponImportData.getUserMap();
Map<Long, DmsStoreBaseInfoResp> storeMap = couponImportData.getStoreMap();
Map<Long, CouponTemplateBaseInfo> couponTemplateMap = couponImportData.getCouponTemplateMap();
Map<String, CustomerCouponInfo> couponInfoMap = couponImportData.getCouponInfoMap();
dataList.stream().filter(item -> StringUtils.isBlank(item.getFailReason())).forEach(item -> {
List<SimpleUserResp> userRes = userMap.get(item.getIntegralAccount());
if (CollectionUtils.isEmpty(userRes)) {
item.setFailReason("积分账号不存在");
return;
}
List<SimpleUserResp> enableList = userRes.stream()
.filter(user -> YesNoEnum.YES.getCode().equals(user.getIsEnable()))
.collect(Collectors.toList());
if (CollectionUtils.isEmpty(enableList)) {
item.setFailReason("易积分账号已禁用");
return;
}
});
}
@Override
protected GeneralImportResultResp buildResp(List<CouponImportDTO> excelDataList, String originalFilename) {
GeneralImportResultResp resp = new GeneralImportResultResp();
List<CouponImportDTO> failedImportDtoList = excelDataList.stream()
.filter(dto -> StringUtils.isNotEmpty(dto.getFailReason()))
.collect(Collectors.toList());
if (CollectionUtils.isEmpty(failedImportDtoList)) {
// 数据无问题
resp.setFailedCount(0);
resp.setSuccessCount(excelDataList.size());
resp.setTotalCount(excelDataList.size());
}else {
/* 数据有问题,失败信息导出 */
String exportFileName = Files.getNameWithoutExtension(originalFilename) + "_" + System.currentTimeMillis() + ".xlsx";
EasyExcelUtils.export(exportFileName, "积分优惠券导入失败信息", CouponImportDTO.class, excelDataList, new LongestMatchColumnWidthStyleStrategy());
String excelDownUrl = uploadService.uploadExcel(exportFileName);
resp.setFailedXlsUrl(excelDownUrl);
resp.setFailedCount(failedImportDtoList.size());
resp.setSuccessCount(excelDataList.size() - failedImportDtoList.size());
resp.setTotalCount(excelDataList.size());
return resp;
}
}
@Override
protected GeneralImportResultResp buildFinalResp(List<CouponImportDTO> excelDataList, int successCount, int failCount, String originalFilename) {
GeneralImportResultResp resp = new GeneralImportResultResp();
if (failCount == 0) {
/* 数据无问题 */
resp.setFailedCount(0);
resp.setSuccessCount(excelDataList.size());
resp.setTotalCount(excelDataList.size());
return resp;
} else {
/* 数据有问题,失败信息导出 */
String exportFileName = Files.getNameWithoutExtension(originalFilename) + "_" + System.currentTimeMillis() + ".xlsx";
EasyExcelUtils.export(exportFileName, "优惠券信息导入失败信息", CouponImportDTO.class, excelDataList, new LongestMatchColumnWidthStyleStrategy());
String excelDownUrl = uploadService.uploadExcel(exportFileName);
resp.setFailedXlsUrl(excelDownUrl);
resp.setFailedCount(failCount);
resp.setSuccessCount(successCount);
resp.setTotalCount(excelDataList.size());
return resp;
}
}
@Override
@Transactional(rollbackFor = Exception.class)
public boolean saveDO(CouponImportDTO excelData, ImportBaseUserReq userReq, CouponImportData importNeedData) {
//保存导入数据....
}
@Override
protected String buildTaskKey(String taskId) {
return String.format(IMPORT_PROGRESS_KEY, taskId);
}
@Override
protected void buildFailOrSuccessResp(CouponImportDTO excelData) {
if (excelData.getSuccess()) {
excelData.setIsSuccess("成功");
} else {
excelData.setIsSuccess("失败");
excelData.setFailReason(excelData.getErrorMsg());
}
}
/**
*
* @param 用户信息
* @param 读取的excel数据
* @return 返回导入或校验需要的数据
*/
@Override
protected CouponImportData getImportNeedData(ImportBaseUserReq userReq, List<CouponImportDTO> dataList) {
......
}
}
三. 总结
如果需要规范代码行为, 抽取公共代码逻辑可以使用模版模式