本文来自 临窗旋墨的博客 转载望指明出处。
这是一次对现有代码调整的过程。
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 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 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 {
// 构造为 BufferedMultipartFile
BufferedMultipartFile 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 而非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);
}
}
在清理的时候(cleanupMultipart
方法中)判断是BufferedMultipartFile
则不清理,因为在第二步中需要使用
代码略
redis
中的文件则清理掉到这里的话,关于负载均衡后对文件上传逻辑的调整的代码基本就完结了,中间还穿插的说了一点点springmvc
文件上传的部分源码。
如果在上述描述中,有所谬误,还望指正。
本文来自 临窗旋墨的博客 转载望指明出处。
202008 临窗旋墨