设计实现系统的多附件上传统一处理功能

附件上传是绝大多数系统都有的功能,通常情况下我们都是每一个不同的附件上传请求去做生成路径,保存文件,记录附件等工作,尽管我们有通用的附件处理类,但实际上在开发过程当中依然需要占用一部分精力在附件的保存记录等环节。对于需要大量上传附件的系统来说,尤其如此。其实想到这里,我们是否可以考虑有一个方法,对于带有附件的请求,由系统自动帮我们做这前面一系列的工作,而我们只需要处理我们的核心业务即可?这就是我们这篇要说的,用切面来检查请求的类型,并统一处理上传的附件。

1. 接口定义

通常我们希望附件由框架统一处理记录入库,文件存储等一些工作。但还有一些情况,我们其实想要更灵活一些由自己来处理上传的附件,如不需要保存附件,只需要读取内容等等,这个时候我们就用自定义注解@FileBatchDisabled来处理,让框架忽略统一处理工作,因此凡是在Action上添加了这个注解,则我们都不会对附件进行统一处理,因此后续我们的设计也要遵守这个规则。

/**
 * 不统一处理multipart注解,无此注解则统一处理multipart
 */
@Target({ ElementType.PARAMETER, ElementType.METHOD })
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface FileBatchDisabled {
}

同样,我们在请求调用的时候,还需要知道哪些Action请求需要统一处理附件。我们可以通过获取Action中的参数类型,检查是否实现了我们定义的一个接口FileBatch,来确定这个请求是否有上传附件的功能。凡是有参数实现了该接口,我们都会统一处理。同一个表单中,我们可以添加多个附件,这样的一次提交,我们定义为一个批次。因此在这次的请求中,我们为所有的附件定义一个共同的编号batchId(批次号)。

/**
 * 定义文件批次,一批次文件可以上传多附件。
 * 凡是实现该接口的实体类, 代表这个类有附件; 附件的增删改均通过切面MultipartAspect统一处理
 * 数据保存为AttDTO
 */
public interface FileBatch {

    static String newBatchId() {
        return UUID.randomUUID().toString();
    }

    /**
     * 文件批次号, 一个批次号关联多个AttDTO
     * @return
     */
    String getBatchId();

    /**
     * 设置文件批次号
     * @param batchId
     */
    void setBatchId(String batchId);

    /**
     * 与批次号关联的附件实体AttDTO列表。
     * @return
     */
    List<AttDTO> getBatchFiles();
}

这里我们给出一个接口,定义了从请求中获取分类的方法,用于将上传的附件分类。

public interface CategorialFileBatch extends FileBatch {
    /**
     * 用户自定义的文件类别.
     * 当上传文件时, 该方法获取传来的category, 并保存到对应的AttDTO中。
     * @return
     */
    String getBatchFileCategoryOnUploading();
}

Request包裹类,并且我们在这里将request转换成了MultipartHttpServletRequest,并且提供了一个自定义的方法isMultipartRequest用来检查是否是多附件请求。

public class MultipartRequestWrapper {

    private HttpServletRequest request;

    public MultipartRequestWrapper(HttpServletRequest request) {
        this.request = request;
    }

     /**
     * 判断是否是带附件的Request
     */
    public boolean isMultipartRequest() {
        return (this.request != null)
                && (this.request instanceof MultipartHttpServletRequest || this.request instanceof StandardMultipartHttpServletRequest);
    }

    public MultipartHttpServletRequest getRequest() {
        return (MultipartHttpServletRequest) request;
    }

    public void setRequest(HttpServletRequest request) {
        this.request = request;
    }

    /**
     * 获取给定属性名称的Value
     */
    public Object getAttribute(String name) {
        return request.getAttribute(name);
    }
}

当然还有我们的附件处理接口,定义两个参数,其中FileBatch entity 是继承了我们FileBatch接口的业务实体类,所以这里用FileBatch就已经足够了。

public interface FileResolver {
    void resolveFile(MultipartRequestWrapper multipartRequestAttributes, FileBatch entity) throws Exception;
}

文件路径生成策略:

/**
 * 文件路径生成策略
 */
public interface FileArchiveStrategy {
    Path createPath(String sourceUri, String originalFilename);
}

2. AOP处理上传请求

首先我们定义个拦截器,将是MultipartRequest的请求转换后放到当前的线程中。

/**
 * Multipart请求拦截器. 把request对象放到当前Thread中, 供后面的MultipartAspect使用。
 */
public class MultipartHandlerInterceptor extends HandlerInterceptorAdapter {

	@Value("${file.multipart.autoSaveEnabled:true}")
    private Boolean autoSaveEnabled = true;

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response,
            Object handler) throws Exception {
        if (!this.autoSaveEnabled) {
            return super.preHandle(request, response, handler);
        }

        MultipartRequestWrapper requestWrapper = new MultipartRequestWrapper(request);

        if (!requestWrapper.isMultipartRequest()) {
            return super.preHandle(request, response, handler);
        }

        HandlerMethod method = (HandlerMethod) handler;

        // 如果有@FileBatchDisabled注解, 不继续处理。
        FileBatchDisabled fileBatchDisabledAnnotation = method
                .getMethodAnnotation(FileBatchDisabled.class);
        if (fileBatchDisabledAnnotation != null) {
            return super.preHandle(request, response, handler);
        }

        RequestMapping requestMappingAnnotation = method
                .getMethodAnnotation(RequestMapping.class);
        if (requestMappingAnnotation == null) {
            return super.preHandle(request, response, handler);
        }

        MultipartContextHolder.setRequestWrapper(requestWrapper);

        return super.preHandle(request, response, handler);
    }

    @Override
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response,
            Object handler, Exception ex) throws Exception {
        super.afterCompletion(request, response, handler, ex);
        // 清理
        MultipartContextHolder.resetRequestWrapper();
    }
}
public class MultipartContextHolder {
    private static final ThreadLocal<MultipartRequestWrapper> requestAttributesHolder = new NamedThreadLocal<MultipartRequestWrapper>(
            "Multipart Special Use");

    public static void resetRequestWrapper() {
        requestAttributesHolder.remove();
    }

    /**
     * 绑定 MultipartRequestWrapper 到当前的线程
     */
    public static void setRequestWrapper(MultipartRequestWrapper request) {
        requestAttributesHolder.set(request);
    }

    /**
     * 返回绑定到当前线程的MultipartRequestWrapper 
     */
    public static MultipartRequestWrapper getRequestWrapper() {
        MultipartRequestWrapper request = requestAttributesHolder.get();
        return request;
    }

    /**
     * 返回绑定到当前线程的MultipartRequestWrapper 
     */
    public static MultipartRequestWrapper currentRequestAttributes()
            throws IllegalStateException {
        MultipartRequestWrapper request = getRequestWrapper();
        if (request == null) {
            // throw new IllegalStateException("No thread-bound request found");
        }
        return request;
    }
}

接下来这里我们定义一个切面,对于所有有@PostMapping注解的方法进行处理,同时检查是否有我们的自定义注解@FileBatchDisabled,如果有,则放行,不进行统一处理,否则由我们的FileResolver来进行统一处理。

@Aspect
@Order(2)
public class MultipartAspect {
	protected final Log logger = LogFactory.getLog(MultipartAspect.class);

	@Value("${file.multipart.autoSaveEnabled:true}")
	private Boolean autoSaveEnabled = true;

	@Autowired
	FileResolver fileResolver;

	@Before(value = "@annotation(org.springframework.web.bind.annotation.PostMapping)")
	public void beforAdvice(JoinPoint joinPoint) throws Throwable {
		if (!this.autoSaveEnabled) {
			return;
		}

		// 检查Action是否有@FileBatchDisabled注解, 有则不继续处理。
		MethodSignature signature = (MethodSignature) joinPoint.getSignature();
		Method method = signature.getMethod();
		FileBatchDisabled fileBatchDisabledAnnotation = method.getAnnotation(FileBatchDisabled.class);
		if (fileBatchDisabledAnnotation != null) {
			return;
		}

		Object[] args = joinPoint.getArgs();
		if (args == null) {
			return;
		}

		Object target = joinPoint.getTarget();
		if (target instanceof ErrorController) {
			return;
		}

		MultipartRequestWrapper requestWrapper = MultipartContextHolder.currentRequestAttributes();
		if (requestWrapper == null) {
			return;
		}

		if (!requestWrapper.isMultipartRequest()) {
			return;
		}

		for (Object arg : args) {
			if (arg instanceof FileBatch) {
				fileResolver.resolveFile(requestWrapper, (FileBatch) arg);
			}
		}
	}
}

总结下大体流程是这样:
1. 检查是否有FileBatchDisabled注解;
2. 检查是否是带附件的请求 MultipartRequest;
3. 获取Action中的参,若有参数是从FileBatch继承,则我们就用fileResolver.resolveFile(requestWrapper, (FileBatch) arg);来处理附件。


3. 系统全局配置

这里当然要将我们的切面以及拦截器配置到系统中:

@Configuration
public class MultipartUploadConfig extends WebMvcConfigurationSupport {

    @Override
    protected void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(new MultipartHandlerInterceptor()).addPathPatterns("/**");
        super.addInterceptors(registry);
    }

    @Bean
    public MultipartAspect multipartAspect() {
        return new MultipartAspect();
    }

    @Bean
    @ConditionalOnMissingBean(FileArchiveStrategy.class)
    public FileArchiveStrategy fileArchiveStrategy() {
        return new DefaultFileArchiveStrategy();
    }

    @Bean
    @ConditionalOnMissingBean(StorageService.class)
    public StorageService defaultStorageService() {
        return new StorageServiceImpl();
    }

    @Bean
    @ConditionalOnMissingBean(FileResolver.class)
    public FileResolver defaultFileResolver() {
        return new DefaultFileResolver();
    }
}

同时还有我们在配置文件中增加的开关及路径,autoSaveEnabled标识是否统一处理附件,自动保存附件。

file:
  multipart:
    autoSaveEnabled: true
    baseDir: /data/upload/

4. 实现类

这里就比较简单了,主要是我们要如何对删除或者保存文件的处理,以及生成文件路径的策略类。

/**
 * 默认的文件上传处理类
 */
public class DefaultFileResolver implements FileResolver {

	@Autowired
	private FileService fileService;

	@Override
	public void resolveFile(MultipartRequestWrapper multipartRequestWrapper, FileBatch fileBatch) throws Exception {

		MultipartHttpServletRequest multipartRequest = multipartRequestWrapper.getRequest();

		String sourceUri = multipartRequest.getRequestURI();

		Map<String, MultipartFile> multipartMap = multipartRequest.getFileMap();

		String batchId = fileBatch.getBatchId();
		List<AttDTO> batchFilesFromRequest = fileBatch.getBatchFiles();

		if (StringUtils.isNotEmpty(batchId)) {
			// 1. 先根据批次号从数据库中取出已存在的文件,与请求的文件ID对比。
			// 请求中缺少的文件, 认为是此次要删除的;请求中多出的文件,不予处理。
			List<AttPO> batchFilesInDb = fileService.findByBatch(batchId);
			for (AttPO fileEntity : batchFilesInDb) {
				final Long fileId = fileEntity.getId();
				Object batchFileFromRequest = CollectionUtils.find(batchFilesFromRequest, new Predicate() {
					@Override
					public boolean evaluate(Object arg0) {
						AttPO f = (AttPO) arg0;
						if (fileId.equals(f.getId())) {
							return true;
						}

						return false;
					}
				});
				if (batchFileFromRequest == null) {
					// 删除
					fileService.del(fileEntity.getId());
				}
			}
		} else {
			// 如果文件插槽为空
			// 只走第二步
			batchId = FileBatch.newBatchId();
			fileBatch.setBatchId(batchId);
		}

		// 2. 取Multipart文件, 保存文件体, 保存文件数据。
		for (Entry<String, MultipartFile> entry : multipartMap.entrySet()) {
			MultipartFile multipartFile = entry.getValue();
			try {
				this.fileService.save(sourceUri, multipartFile, fileBatch);
			} catch (IllegalStateException | IOException e) {
				throw new Exception("sys.multipart.save.error");
			}
		}

	}
}
/**
 * 生成文件路径默认策略。
 */
public class DefaultFileArchiveStrategy implements FileArchiveStrategy {

    /**
     * 创建路径
     */
    @Override
    public Path createPath(String sourceUri, String originalFilename) {
        if (StringUtils.isEmpty(sourceUri)) {
            sourceUri = "";
        }
        if (sourceUri.startsWith("/")) {
            sourceUri = sourceUri.replaceFirst("/", "");
        }

        if (StringUtils.isNotEmpty(sourceUri) && !sourceUri.endsWith("/")) {
            sourceUri += "/";
        }

        // YYYY/mm/YYYYmmDD_originalFilename
        String path = sourceUri
                + DateUtils.formatDate(DateUtils.currentDate(), TimeFormat.LONG_DATE_PATTERN_WITH_MILSEC_NONE2);
        path += "_." + originalFilename.substring(originalFilename.lastIndexOf(".") + 1);

        return Paths.get(path);
    }
}

5. 上传示例

前端我们使用ElementUI的组件来上传,可以直接配置我们的Action地址即可。

<el-upload
        class="upload-demo"
        action="/sys/biz/article/update"
        :on-preview="handlePreview"
        :on-remove="handleRemove"
        :before-remove="beforeRemove"
        multiple
        :limit="13"
        :data="fileUploadParam"
        :on-exceed="handleExceed"
        :file-list="fileList">
        <el-button size="small" type="primary">点击上传</el-button>
        <div slot="tip" class="el-upload__tip">只能上传jpg/png文件,且不超过500kb</div>
      </el-upload>

再看看Action方法上,当我们需要自己单独处理上传的附件,不使用统一处理时,可以在方法中加上注解。如果不加@FileBatchDisabled注解,则自动保存附件,我们在Action中,就可以直接通过articleDTO.getBatchId获取到附件的批次号,当然通过批次号,也就能得到所有的附件了。

@FileBatchDisabled
@PostMapping(value = "/update")
public BaseResp<ArticleDTO> updateArticle(ArticleDTO articleDTO) {
	BaseResp<ArticleDTO> result = new BaseResp<>();
	ArticleDTO attRespDTO = articleService.persist(articleDTO);
	result.setData(attRespDTO);
	return result;
}

好了,上面记录了自动保存请求附件的核心代码,其实思想还是很简单,通过切面检查是否附件请求,是则保存附件。当然这样的处理让我们的焦点不再集中到附件的请求上,而是可以将MultipartRequest当成普通的Request来处理,省去了开发人员的部分工作。同时对于附件名称,格式的验证工作我们也可以统一处理,是不是方便了很多?

发表评论

电子邮件地址不会被公开。 必填项已用*标注