设计模式实战01-模版模式

120 阅读7分钟

设计模式实战01-模版模式

一.需求描述

我们在项目开发中经常碰到导入的需求,由于导入的逻辑通常比较复杂,涉及到一些业务上的校验等逻辑,每个人遇到这些需求可能会依据自己的习惯写很多代码, 导致项目冗长且没有规范, 不美观; 本文将基于一个导入需求,设计一个后端通用导入模版.提高代码的可读性与易维护性;

具体需求如下:

1.选择导入文件界面

image.png

2.导入校验进度条

image.png

3.导入数据校验失败结果

image.png

4.导入数据校验成功结果

image.png

5.最终导入成功结果

image.png

二.代码实现

根据以上需求,我们提取到以下几点重要信息

  • 导入数据校验及返回结果
  • 实时查询导入的进度
  • 最终执行导入

通过分析,确定了异步导入+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) {
        ......
    }
    
}

三. 总结

如果需要规范代码行为, 抽取公共代码逻辑可以使用模版模式