[临窗旋墨]载均衡后对文件上传逻辑的调整(基于NAS)以及springmvc文件上传的部分源码说明

004-负载均衡后对文件上传逻辑的调整(基于NAS)以及springmvc文件上传的部分源码说明

本文来自 临窗旋墨的博客 转载望指明出处。

这是一次对现有代码调整的过程。

一 业务背景
1.1 开发环境

spring + springmvc 4.1.6, 其他略。

文件上传使用的是CommonsMultipartResolver,需要额外依赖commons-fileupload

1.2 简单交代下背景

​ 一个老系统的升级调整总难免要引发很多奇奇怪怪的问题。比如这一次的系统要做负载均衡,要上session共享。按道理来说,其实没那么复杂,可惜问题的关键就是系统已经跑了很多年,技术也比较老旧。没有maven,没有springboot,没有redis;spring还停留所在4.16的版本等等。

​ 在升级的过程中遇到过jar包兼容问题,代码不规范问题,复制粘贴很随意的问题等。那么session共享对文件上传有什么影响呢?

1.3 原来的文件上传逻辑
  1. 先异步上传,在重写的文件解析器中,把FileItem包装为自定义的BufferedMultipartFile( extends CommonsMultipartFile)
  2. 在controller中生成对应文件的UUID,然后缓存到ConcurrentHashMap,key为uuid,value为BufferedMultipartFile
  3. 提交表单的时候,同步提交步骤2中的uuid,name为特定的字符串;在通过自定义文件解析器的时候,判断是这个特定的name,则根据值去map中找到缓存的BufferedMultipartFile,完成parseFileItem
1.4负载均衡后产生的问题

由于负载均衡,那么在第一步上传完成后,第二步提交表单的时候可能访问到其他节点,导致在内存或者磁盘中找不到第一步上传的文件。

二 解决方案

由于生产环境中具有NAS环境,可以为不同的节点提供文件共享。所以修改变的简单起来。

  1. 多节点共享临时存储文件夹),保证多节点访问同一磁盘位置;配置各个节点的文件上传存储节点为共享文件夹;

  2. 配置文件解析器,存入临时文件夹的阈值为0,保证临时文件存储在磁盘而不是内存;

  3. 保存UUID和对应临时文件的方式不再是Map,而是redis,保证节点能访问到其他节点在第一步中上传的文件。

三 上代码之前先看看源码
3.1 springMVC文件上传解析器CommonsMultipartResolver简单说明

​ 现在springboot的时代,默认的文件解析器为StandardServletMultipartResolver,参见源码MultipartAutoConfiguration,少了个commons-fileupload的依赖,但是需要容器对Servlet3.0的支持,此处不展开说明。

3.2 CommonsMultipartResolver源码概述-(本人只是走马观花截取一二,如有谬误,望指正)
3.3.1 代码入口DispatcherServlet#doDispatch

部分代码截取

protected void doDispatch(HttpServletRequest request, HttpServletResponse response) throws Exception {
	//...........略	
	boolean multipartRequestParsed = false;
    //判断是不是文件上传,非常重要
	processedRequest = checkMultipart(request);
	multipartRequestParsed = (processedRequest != request);
	if (multipartRequestParsed) {
        //调用文件解析器的cleanupMultipart方法清理临时附件
		cleanupMultipart(processedRequest);
	}
}

checkMultipart中有一个判断当前文件是否处理过文件上传,这个判断比较重要,因为request中的流是不可重复读取的,除非定义变量保存,然后重写getInputStream方法,读取保存的流信息,

	protected HttpServletRequest checkMultipart(HttpServletRequest request) throws MultipartException {
        //是否配置了文件解析器
        //根据请求方式和请求ContentType判断是否是文件上传
		if (this.multipartResolver != null && this.multipartResolver.isMultipart(request)) {
            //判断request是否可转化为MultipartHttpServletRequest,以判断是否处理过文件上传
			if (WebUtils.getNativeRequest(request, MultipartHttpServletRequest.class) != null) {
				if (request.getDispatcherType().equals(DispatcherType.REQUEST)) {
					logger.trace("Request already resolved to MultipartHttpServletRequest, e.g. by MultipartFilter");
				}
			}
			//.............略
            //调用文件解析器解析request,入口
			return this.multipartResolver.resolveMultipart(request);
				
		// If not returned before: return original request.
		return request;
	}

题外话:曾遇到的问题是,由于参数加密,希望在filter中解密,然后再controller中无感知使用,就写了参数解密Filter,写了个RequestWrapper,导致文件上传解析器解析不到FileItem,只能解析一次原因是springMVC使用的是common-fileUplad的工具类解析数据的,参照代码ServletFileUpload.parseRequest(request); 其中的copy方法会从HttpServletRequest中读取流,而读完后的position会到-1,在未显式调用reset方法之前,再次读取流是都不到的,而ServletInputStream中并未重写该方法.(此处的解决方案是做一些判断,先做文件解析)

另外:可参见我曾写过的代码片段:重复读取request请求中的body: 因为request中的流只能读取一次,此处存起来,保证可重复读

3.3.2 CommonsMultipartResolver的两个重要方法,

在3.3.1中,我们知道,CommonsMultipartResolver解析器的调用时机,那么解析器到底做了些什么呢?

部分代码摘录如下:

public class CommonsMultipartResolver extends CommonsFileUploadSupport
		implements MultipartResolver, ServletContextAware {

    // 解析文件
	public MultipartHttpServletRequest resolveMultipart(final HttpServletRequest request) throws MultipartException {
        	//部分代码略... 	
        	//解析为MultipartParsingResult
			MultipartParsingResult parsingResult = parseRequest(request);
			
			return new DefaultMultipartHttpServletRequest(request, parsingResult.getMultipartFiles(),
					parsingResult.getMultipartParameters(), parsingResult.getMultipartParameterContentTypes());
		
		}
	} 

	//清理临时文件
	public void cleanupMultipart(MultipartHttpServletRequest request) {
	    //最终调用了fileItem的delete方法
		 cleanupFileItems(request.getMultiFileMap());
		 
	}
    
}    

解析文件继续追踪

protected MultipartParsingResult parseRequest(HttpServletRequest request) throws MultipartException {
	//调用common-fileupload进行文件解析,这个是最重要的方法
	List<FileItem> fileItems = ((ServletFileUpload) fileUpload).parseRequest(request);
	return parseFileItems(fileItems, encoding);
		
}

上面代码中的parseRequest,最终调用的是FileUploadBase#parseRequest,

这个方法非常重要,我们为解析器配置的各种信息都会在这个地方被使用,其中最重要的是Streams.copy(item.openStream(), fileItem.getOutputStream(), true); 有兴趣的可以追踪进去查看我们配置的相信信息是如何被使用的(注意fileItem.getOutputStream()),如保存到临时文件的阈值,临时文件夹,临时文件命名规则等;

public List<FileItem> parseRequest(RequestContext ctx)
        throws FileUploadException {
    List<FileItem> items = new ArrayList<FileItem>();
   
        FileItemIterator iter = getItemIterator(ctx);
        FileItemFactory fac = getFileItemFactory();
        
        while (iter.hasNext()) {
            final FileItemStream item = iter.next();
            // Don't use getName() here to prevent an InvalidFileNameException.
            final String fileName = ((FileItemIteratorImpl.FileItemStreamImpl) item).name;
            //根据配置创建FileItem
            FileItem fileItem = fac.createItem(item.getFieldName(), item.getContentType(),
                                               item.isFormField(), fileName);
            items.add(fileItem);
          	//把流copy到FileItem,这个方法非常重要,里面用到了我们配置的大部分信息
            Streams.copy(item.openStream(), fileItem.getOutputStream(), true);
             
            final FileItemHeaders fih = item.getHeaders();
            fileItem.setHeaders(fih);
      }
    }
}

parseFileItems方法,把FileItem转化为CommonsMultipartFile

protected MultipartParsingResult parseFileItems(List<FileItem> fileItems, String encoding) {
		MultiValueMap<String, MultipartFile> multipartFiles = new LinkedMultiValueMap<>();
		Map<String, String[]> multipartParameters = new HashMap<>();
		Map<String, String> multipartParameterContentTypes = new HashMap<>();

		// Extract multipart files and multipart parameters.
		for (FileItem fileItem : fileItems) {
		    //判断是否是普通的表单字段
			if (fileItem.isFormField()) {
				//....
			}
			else {
				// 封装为CommonsMultipartFile
				CommonsMultipartFile file = createMultipartFile(fileItem);
				multipartFiles.add(file.getName(), file);
				
				);
			}
		}
		return new MultipartParsingResult(multipartFiles, multipartParameters, multipartParameterContentTypes);
	}

3.3.3 MultipartFilespringmvc参数解析器RequestParamMethodArgumentResolver

spirng是如何把文件解析器中的文件匹配到controller中方法里对应的MultipartFile 参数的呢?

可参考RequestParamMethodArgumentResolver源码,本文不再赘述(部分源码摘录如下)

	protected Object resolveName(String name, MethodParameter parameter, NativeWebRequest request) throws Exception {
		Object arg = null;
		MultipartRequest multipartRequest = request.getNativeRequest(MultipartRequest.class);
		if (multipartRequest != null) {
			List<MultipartFile> files = multipartRequest.getFiles(name);
			if (!files.isEmpty()) {
				arg = (files.size() == 1 ? files.get(0) : files);
			}
		}
	 
		return arg;
	}
四 真的开始上代码了

其实明白了springmvc文件上传原理之后,改起来代码就很简单了,下面还是简单记录下吧

4.1 保证临时文件都保存在文件夹,而不是内存,修改maxInMemorySize的值为0;
4.2 修改临时文件的目录为共享文件夹

貌似,CommonsMultipartResolver配置的uploadTempDir 不支持项目外的地址啊

public void setUploadTempDir(Resource uploadTempDir) throws IOException {
		if (!uploadTempDir.exists() && !uploadTempDir.getFile().mkdirs()) {
			throw new IllegalArgumentException("Given uploadTempDir [" + uploadTempDir + "] could not be created");
		}
		this.fileItemFactory.setRepository(uploadTempDir.getFile());
		this.uploadTempDirSpecified = true;
	}

简单,反正已经重写了解析器,就再写个配置绝对路径的临时文件夹吧,代码如下:配置绝对路径uploadTemp,然后自己构造个FileSystemResource

public void setUploadTemp(String uploadTemp) throws IOException {
		File file = new java.io.File(uploadTemp);
		if (!file.exists() && !file.mkdirs()) {
			throw new IllegalArgumentException((new StringBuilder()).append("Given uploadTempDir [").append(file)
					.append("] could not be created").toString());
		}
		FileSystemResource resource = new FileSystemResource(file);
		super.setUploadTempDir(resource);
	}
4.3 临时文件的存储由Map转为redis

原来的map中存的是uuid对应BufferedMultipartFile,这里不大适合把包流信息的MultipartFileFileItem存如redis中,理想的状况是把临时文件全路径,参数名等最基本信息存入redis,然后根据这些信息反向构造MultipartFile。OK,也是问题不大。

4.3.1 构造临时文件信息的实体,根据FileItem构造
public class TempFileInfo implements Serializable {
	private static final long	serialVersionUID	= 1L;
	/**
	 * 临时文件全路径
	 */
	private String				storeLocation;

	private boolean				formField;

	private String				fieldName;

	private String				fileName;

	private String				contentType;
	/**
	 * 使用的时间
	 */
	private long				accessTime;

	/**
	 * FileItem must could cast to DiskFileItem
	 * @param file
	 */
	public TempFileInfo(FileItem fileItem) {
		DiskFileItem item = (DiskFileItem) fileItem;
		this.storeLocation = item.getStoreLocation().getAbsolutePath();
		this.formField = item.isFormField();
		this.fieldName = item.getFieldName();
		this.fileName = file.getOriginalFilename();
		this.contentType = item.getContentType();
	}
}
4.3.2 在第一次异步上传的文件解析器中把FileItem构造为BufferedMultipartFile(继承自CommonsMultipartFile,这里主要是为了做一个区分,方便后续清理时使用);
protected MultipartParsingResult parseFileItems(List<FileItem> fileItems, String encoding) {
		MultiValueMap<String, MultipartFile> multipartFiles = new LinkedMultiValueMap<>();
		Map<String, String[]> multipartParameters = new HashMap<>();
		Map<String, String> multipartParameterContentTypes = new HashMap<>();

		// Extract multipart files and multipart parameters.
		for (FileItem fileItem : fileItems) {
		    //判断是否是普通的表单字段
			if (fileItem.isFormField()) {
				//....
			}
			else {
				// 构造为 BufferedMultipartFile
				BufferedMultipartFile file = new BufferedMultipartFile(fileItem);
				multipartFiles.add(file.getName(), file);
			}
		}
		return new MultipartParsingResult(multipartFiles, multipartParameters, multipartParameterContentTypes);
	}
4.3.3 在第一次的一部上传的controller中生成UUID,缓存文件信息到redis (BuffereUtils)

TempFileInfojson为存入redis,key为命名空间 + uuid

public static BufferResult add(MultipartFile attachment) throws BufferAttachmentException {		
		String id = UUID.randomUUID().toString().replace("-", "");
		if (attachment instanceof BufferedMultipartFile) {
			BufferedMultipartFile bmf = (BufferedMultipartFile) attachment;
			//缓存临时文件的信息
			TempFileInfo info = new TempFileInfo(bmf.getFileItem);
			JedisUtil.mapPut(ATTACHMENT_HASH_REDIS_KEY, buildValue(buildField(bmf.getSessionId(), id), info));
		} 
    //....
	}

4.4在第二次使用的时候,绑定对应属性为第一步上传的文件

文件绑定,以及把非文件类型删除临时文件

protected MultipartParsingResult parseFileItems(List<FileItem> fileItems, String encoding) {
		MultiValueMap<String, MultipartFile> multipartFiles = new LinkedMultiValueMap<>();
		Map<String, String[]> multipartParameters = new HashMap<>();
		Map<String, String> multipartParameterContentTypes = new HashMap<>();

		// Extract multipart files and multipart parameters.
		for (FileItem fileItem : fileItems) {
		    //判断是否是普通的表单字段
			if (fileItem.isFormField()) {
				//....
                	//从缓存中取出文件
				if (BuffereUtils.isBufferedItem(fileItem.getFieldName())) {
					TempFileInfo file = BufferPool.get(sessionId, value);
					//包装为CommonsMultipartFile 而非BufferedMultipartFile
					CommonsMultipartFile file2 = new CommonsMultipartFile(buildFileItem(file));
					if (file != null) {
						multipartFiles.add(file.getFieldName(), file2);
						continue;
					}
				} else {
					//把非文件类型删除 主要是为了删除临时文件
					fileItem.delete();
				}
			}
			else {
				// BufferedMultipartFile
				BufferedMultipartFile file = new BufferedMultipartFile(fileItem);
				multipartFiles.add(file.getName(), file);
				
				);
			}
		}
		return new MultipartParsingResult(multipartFiles, multipartParameters, multipartParameterContentTypes);
	}

/**
	 * 根据缓存的附件信息 手动构造FileItem  Vic.xu
	 * @see FileUploadBase#parseRequest(org.apache.commons.fileupload.RequestContext)
	 * @param item
	 */
	public FileItem buildFileItem(TempFileInfo item) {

		File file = new File(item.getStoreLocation());
		if (!file.exists()) {
			throw new BufferAttachmentException("缓存附件已被超时清理,请重新上传");
		}
		FileInputStream inputStream = null;
		try {

			inputStream = new FileInputStream(file);
			FileItemFactory fac = getFileItemFactory();
			FileItem fileItem = fac.createItem(item.getFieldName(), item.getContentType(), item.isFormField(),
					item.getFileName());
			Streams.copy(inputStream, fileItem.getOutputStream(), true);
			return fileItem;
		} catch (Exception e) {
			e.printStackTrace();
			throw new BufferAttachmentException("缓存附件已被超时清理,请重新上传");
		} finally {
			IOUtils.closeQuietly(inputStream);
		}
	}
4.5 文件解析器清理文件逻辑修改

在清理的时候(cleanupMultipart方法中)判断是BufferedMultipartFile则不清理,因为在第二步中需要使用

4.6 临时文件的清理

代码略

  1. 新增配置,本节点是否开启清理
  2. 初始化的时候,清理临时文件夹,判断不再redis中的文件则清理掉
  3. 开启定时器,清理超出阈值的临时文件;
  4. 用户退出登录的时候,清理和当前session相关的临时文件
五 完结

到这里的话,关于负载均衡后对文件上传逻辑的调整的代码基本就完结了,中间还穿插的说了一点点springmvc文件上传的部分源码。

如果在上述描述中,有所谬误,还望指正。

本文来自 临窗旋墨的博客 转载望指明出处。

202008 临窗旋墨

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值