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

文章来源原创   作者:临窗旋墨   发布时间:2020-08-31   阅读:1416   标签: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

部分代码截取

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

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

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

题外话:曾遇到的问题是,由于参数加密,希望在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解析器的调用时机,那么解析器到底做了些什么呢?

部分代码摘录如下:

  1. public class CommonsMultipartResolver extends CommonsFileUploadSupport
  2. implements MultipartResolver, ServletContextAware {
  3. // 解析文件
  4. public MultipartHttpServletRequest resolveMultipart(final HttpServletRequest request) throws MultipartException {
  5. //部分代码略...
  6. //解析为MultipartParsingResult
  7. MultipartParsingResult parsingResult = parseRequest(request);
  8. return new DefaultMultipartHttpServletRequest(request, parsingResult.getMultipartFiles(),
  9. parsingResult.getMultipartParameters(), parsingResult.getMultipartParameterContentTypes());
  10. }
  11. }
  12. //清理临时文件
  13. public void cleanupMultipart(MultipartHttpServletRequest request) {
  14. //最终调用了fileItem的delete方法
  15. cleanupFileItems(request.getMultiFileMap());
  16. }
  17. }

解析文件继续追踪

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

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

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

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

parseFileItems方法,把FileItem转化为CommonsMultipartFile

  1. protected MultipartParsingResult parseFileItems(List<FileItem> fileItems, String encoding) {
  2. MultiValueMap<String, MultipartFile> multipartFiles = new LinkedMultiValueMap<>();
  3. Map<String, String[]> multipartParameters = new HashMap<>();
  4. Map<String, String> multipartParameterContentTypes = new HashMap<>();
  5. // Extract multipart files and multipart parameters.
  6. for (FileItem fileItem : fileItems) {
  7. //判断是否是普通的表单字段
  8. if (fileItem.isFormField()) {
  9. //....
  10. }
  11. else {
  12. // 封装为CommonsMultipartFile
  13. CommonsMultipartFile file = createMultipartFile(fileItem);
  14. multipartFiles.add(file.getName(), file);
  15. );
  16. }
  17. }
  18. return new MultipartParsingResult(multipartFiles, multipartParameters, multipartParameterContentTypes);
  19. }

3.3.3 MultipartFilespringmvc参数解析器RequestParamMethodArgumentResolver

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

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

  1. protected Object resolveName(String name, MethodParameter parameter, NativeWebRequest request) throws Exception {
  2. Object arg = null;
  3. MultipartRequest multipartRequest = request.getNativeRequest(MultipartRequest.class);
  4. if (multipartRequest != null) {
  5. List<MultipartFile> files = multipartRequest.getFiles(name);
  6. if (!files.isEmpty()) {
  7. arg = (files.size() == 1 ? files.get(0) : files);
  8. }
  9. }
  10. return arg;
  11. }

四 真的开始上代码了

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

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

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

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

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

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

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

4.3.1 构造临时文件信息的实体,根据FileItem构造
  1. public class TempFileInfo implements Serializable {
  2. private static final long serialVersionUID = 1L;
  3. /**
  4. * 临时文件全路径
  5. */
  6. private String storeLocation;
  7. private boolean formField;
  8. private String fieldName;
  9. private String fileName;
  10. private String contentType;
  11. /**
  12. * 使用的时间
  13. */
  14. private long accessTime;
  15. /**
  16. * FileItem must could cast to DiskFileItem
  17. * @param file
  18. */
  19. public TempFileInfo(FileItem fileItem) {
  20. DiskFileItem item = (DiskFileItem) fileItem;
  21. this.storeLocation = item.getStoreLocation().getAbsolutePath();
  22. this.formField = item.isFormField();
  23. this.fieldName = item.getFieldName();
  24. this.fileName = file.getOriginalFilename();
  25. this.contentType = item.getContentType();
  26. }
  27. }
4.3.2 在第一次异步上传的文件解析器中把FileItem构造为BufferedMultipartFile(继承自CommonsMultipartFile,这里主要是为了做一个区分,方便后续清理时使用);
  1. protected MultipartParsingResult parseFileItems(List<FileItem> fileItems, String encoding) {
  2. MultiValueMap<String, MultipartFile> multipartFiles = new LinkedMultiValueMap<>();
  3. Map<String, String[]> multipartParameters = new HashMap<>();
  4. Map<String, String> multipartParameterContentTypes = new HashMap<>();
  5. // Extract multipart files and multipart parameters.
  6. for (FileItem fileItem : fileItems) {
  7. //判断是否是普通的表单字段
  8. if (fileItem.isFormField()) {
  9. //....
  10. }
  11. else {
  12. // 构造为 BufferedMultipartFile
  13. BufferedMultipartFile file = new BufferedMultipartFile(fileItem);
  14. multipartFiles.add(file.getName(), file);
  15. }
  16. }
  17. return new MultipartParsingResult(multipartFiles, multipartParameters, multipartParameterContentTypes);
  18. }
4.3.3 在第一次的一部上传的controller中生成UUID,缓存文件信息到redis (BuffereUtils)

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

  1. public static BufferResult add(MultipartFile attachment) throws BufferAttachmentException {
  2. String id = UUID.randomUUID().toString().replace("-", "");
  3. if (attachment instanceof BufferedMultipartFile) {
  4. BufferedMultipartFile bmf = (BufferedMultipartFile) attachment;
  5. //缓存临时文件的信息
  6. TempFileInfo info = new TempFileInfo(bmf.getFileItem);
  7. JedisUtil.mapPut(ATTACHMENT_HASH_REDIS_KEY, buildValue(buildField(bmf.getSessionId(), id), info));
  8. }
  9. //....
  10. }
4.4在第二次使用的时候,绑定对应属性为第一步上传的文件

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

  1. protected MultipartParsingResult parseFileItems(List<FileItem> fileItems, String encoding) {
  2. MultiValueMap<String, MultipartFile> multipartFiles = new LinkedMultiValueMap<>();
  3. Map<String, String[]> multipartParameters = new HashMap<>();
  4. Map<String, String> multipartParameterContentTypes = new HashMap<>();
  5. // Extract multipart files and multipart parameters.
  6. for (FileItem fileItem : fileItems) {
  7. //判断是否是普通的表单字段
  8. if (fileItem.isFormField()) {
  9. //....
  10. //从缓存中取出文件
  11. if (BuffereUtils.isBufferedItem(fileItem.getFieldName())) {
  12. TempFileInfo file = BufferPool.get(sessionId, value);
  13. //包装为CommonsMultipartFile 而非BufferedMultipartFile
  14. CommonsMultipartFile file2 = new CommonsMultipartFile(buildFileItem(file));
  15. if (file != null) {
  16. multipartFiles.add(file.getFieldName(), file2);
  17. continue;
  18. }
  19. } else {
  20. //把非文件类型删除 主要是为了删除临时文件
  21. fileItem.delete();
  22. }
  23. }
  24. else {
  25. // BufferedMultipartFile
  26. BufferedMultipartFile file = new BufferedMultipartFile(fileItem);
  27. multipartFiles.add(file.getName(), file);
  28. );
  29. }
  30. }
  31. return new MultipartParsingResult(multipartFiles, multipartParameters, multipartParameterContentTypes);
  32. }
  33. /**
  34. * 根据缓存的附件信息 手动构造FileItem Vic.xu
  35. * @see FileUploadBase#parseRequest(org.apache.commons.fileupload.RequestContext)
  36. * @param item
  37. */
  38. public FileItem buildFileItem(TempFileInfo item) {
  39. File file = new File(item.getStoreLocation());
  40. if (!file.exists()) {
  41. throw new BufferAttachmentException("缓存附件已被超时清理,请重新上传");
  42. }
  43. FileInputStream inputStream = null;
  44. try {
  45. inputStream = new FileInputStream(file);
  46. FileItemFactory fac = getFileItemFactory();
  47. FileItem fileItem = fac.createItem(item.getFieldName(), item.getContentType(), item.isFormField(),
  48. item.getFileName());
  49. Streams.copy(inputStream, fileItem.getOutputStream(), true);
  50. return fileItem;
  51. } catch (Exception e) {
  52. e.printStackTrace();
  53. throw new BufferAttachmentException("缓存附件已被超时清理,请重新上传");
  54. } finally {
  55. IOUtils.closeQuietly(inputStream);
  56. }
  57. }
4.5 文件解析器清理文件逻辑修改

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

4.6 临时文件的清理

代码略

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

五 完结

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

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

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

202008 临窗旋墨


发表评论

目录