本文来自 临窗旋墨的博客 转载望指明出处。
这是一次对现有代码调整的过程。
spring + springmvc 4.1.6, 其他略。
文件上传使用的是CommonsMultipartResolver,需要额外依赖commons-fileupload。
一个老系统的升级调整总难免要引发很多奇奇怪怪的问题。比如这一次的系统要做负载均衡,要上session共享。按道理来说,其实没那么复杂,可惜问题的关键就是系统已经跑了很多年,技术也比较老旧。没有maven,没有springboot,没有redis;spring还停留所在4.16的版本等等。
在升级的过程中遇到过jar包兼容问题,代码不规范问题,复制粘贴很随意的问题等。那么session共享对文件上传有什么影响呢?
FileItem包装为自定义的BufferedMultipartFile( extends CommonsMultipartFile)UUID,然后缓存到ConcurrentHashMap,key为uuid,value为BufferedMultipartFile;uuid,name为特定的字符串;在通过自定义文件解析器的时候,判断是这个特定的name,则根据值去map中找到缓存的BufferedMultipartFile,完成parseFileItem由于负载均衡,那么在第一步上传完成后,第二步提交表单的时候可能访问到其他节点,导致在内存或者磁盘中找不到第一步上传的文件。
由于生产环境中具有
NAS环境,可以为不同的节点提供文件共享。所以修改变的简单起来。
多节点共享临时存储文件夹),保证多节点访问同一磁盘位置;配置各个节点的文件上传存储节点为共享文件夹;
配置文件解析器,存入临时文件夹的阈值为0,保证临时文件存储在磁盘而不是内存;
UUID和对应临时文件的方式不再是Map,而是redis,保证节点能访问到其他节点在第一步中上传的文件。springMVC文件上传解析器CommonsMultipartResolver简单说明 现在springboot的时代,默认的文件解析器为StandardServletMultipartResolver,参见源码MultipartAutoConfiguration,少了个commons-fileupload的依赖,但是需要容器对Servlet3.0的支持,此处不展开说明。
CommonsMultipartResolver源码概述-(本人只是走马观花截取一二,如有谬误,望指正)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中的流只能读取一次,此处存起来,保证可重复读
CommonsMultipartResolver的两个重要方法,在3.3.1中,我们知道,CommonsMultipartResolver解析器的调用时机,那么解析器到底做了些什么呢?
部分代码摘录如下:
public class CommonsMultipartResolver extends CommonsFileUploadSupportimplements MultipartResolver, ServletContextAware {// 解析文件public MultipartHttpServletRequest resolveMultipart(final HttpServletRequest request) throws MultipartException {//部分代码略...//解析为MultipartParsingResultMultipartParsingResult 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;//根据配置创建FileItemFileItem 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 {// 封装为CommonsMultipartFileCommonsMultipartFile file = createMultipartFile(fileItem);multipartFiles.add(file.getName(), file););}}return new MultipartParsingResult(multipartFiles, multipartParameters, multipartParameterContentTypes);}
3.3.3 MultipartFile 和springmvc参数解析器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文件上传原理之后,改起来代码就很简单了,下面还是简单记录下吧
maxInMemorySize的值为0; 貌似,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);}
redis;原来的map中存的是uuid对应BufferedMultipartFile,这里不大适合把包流信息的MultipartFile或FileItem存如redis中,理想的状况是把临时文件全路径,参数名等最基本信息存入redis,然后根据这些信息反向构造MultipartFile。OK,也是问题不大。
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();}}
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 {// 构造为 BufferedMultipartFileBufferedMultipartFile file = new BufferedMultipartFile(fileItem);multipartFiles.add(file.getName(), file);}}return new MultipartParsingResult(multipartFiles, multipartParameters, multipartParameterContentTypes);}
UUID,缓存文件信息到redis (BuffereUtils)把
TempFileInfo转json为存入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));}//....}
文件绑定,以及把非文件类型删除临时文件
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 而非BufferedMultipartFileCommonsMultipartFile file2 = new CommonsMultipartFile(buildFileItem(file));if (file != null) {multipartFiles.add(file.getFieldName(), file2);continue;}} else {//把非文件类型删除 主要是为了删除临时文件fileItem.delete();}}else {// BufferedMultipartFileBufferedMultipartFile 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);}}
在清理的时候(cleanupMultipart方法中)判断是BufferedMultipartFile则不清理,因为在第二步中需要使用
代码略
redis中的文件则清理掉到这里的话,关于负载均衡后对文件上传逻辑的调整的代码基本就完结了,中间还穿插的说了一点点springmvc文件上传的部分源码。
如果在上述描述中,有所谬误,还望指正。
本文来自 临窗旋墨的博客 转载望指明出处。
202008 临窗旋墨