1. 为什么今天还要聊SmartUpload这个"老古董"
第一次接触SmartUpload大概是在十几年前做某高校内部信息管理系统的时候。那会儿项目用的是JSP+Servlet+JavaBean的经典三层结构,服务器是Tomcat 6,JDK还是1.6。需求很简单:让管理员能上传Excel成绩单,同时允许学生下载课程资料。当时团队里有人提议用Apache Commons FileUpload,但带我的那位前辈直接甩了一句:"用SmartUpload,三行代码搞定,别折腾。"
说实话,当时我是不服气的。Commons FileUpload是Apache基金会的项目,文档齐全、社区活跃,凭什么用一个看起来像"个人作品"的组件?但真正上手之后才发现,SmartUpload在处理"表单里既有普通字段又有文件字段"这种混合场景时,确实省事得离谱。它把request.getParameter()和文件上传的逻辑揉在了一起,你不需要自己去解析multipart/form-data的边界,也不需要手动管理FileItem的迭代器。
不过话说回来,SmartUpload这个组件本身确实有年头了。它最早是jspsmart.com出品的一个商业组件,后来因为各种原因停止了维护,现在网上流传的版本基本都是com.jspsmart.upload这个包下的免费版。它的核心类就三个:SmartUpload、SmartRequest、SmartFiles。别看类少,功能覆盖得挺全——单文件上传、多文件批量上传、文件下载、限制扩展名、限制文件大小,甚至还能直接获取上传文件的二进制内容。
那为什么现在还有人搜"jsp SmartUpload上传下载"?我观察下来,主要是三类人:一是学校里做课程设计的学生,老师指定的技术栈就是JSP+SmartUpload;二是在维护老系统的开发者,系统跑了好多年,不想动底层框架;三是想快速搭个原型验证想法的人,不想在文件上传这种"基础设施"上花太多时间。不管你是哪一类,这篇文章都会把SmartUpload的脾气秉性、使用套路、以及那些文档里不会写的坑,一次性讲透。
提示:SmartUpload并不是Servlet 3.0规范的一部分,它依赖的是自己的一套请求解析逻辑。在Servlet 3.0之后,容器自带了
Part接口,但SmartUpload依然能正常工作,因为它走的是原始输入流解析的路子。
2. SmartUpload的核心工作逻辑与请求解析机制
2.1 它到底是怎么"截胡"表单数据的
要理解SmartUpload,得先明白一个普通表单提交和文件上传表单提交的区别。普通表单的Content-Type是application/x-www-form-urlencoded,数据格式是key1=value1&key2=value2,服务器端用request.getParameter()就能直接拿到。但文件上传表单的Content-Type必须写成multipart/form-data,数据会被切分成多个"部分",每个部分有自己的头部和内容体,中间用一串随机生成的边界字符串隔开。
SmartUpload干的事情,就是在你的Servlet或JSP里,抢在容器解析请求之前,把request.getInputStream()这个原始输入流拿过来,自己按照multipart/form-data的格式逐字节解析。解析出来的普通字段放进SmartRequest对象,文件字段放进SmartFiles对象。所以你调用smartUpload.getRequest().getParameter("username")拿到的值,其实是SmartUpload自己解析出来的,而不是容器给的。
这个机制带来一个很关键的副作用:一旦你调用了smartUpload.upload(),原始的request对象里的参数就"空"了。因为输入流已经被读完了,容器再想解析也解析不出东西。很多新手在这里翻车——在JSP页面顶部写了<% SmartUpload su = new SmartUpload(); su.upload(); %>,然后在页面下面又写<%=request.getParameter("name")%>,结果拿到的是null。正确的做法是统一用su.getRequest().getParameter("name")。
2.2 初始化与上传的完整调用链
SmartUpload的标准调用流程分四步,每一步都有讲究:
// 第一步:实例化 SmartUpload su = new SmartUpload(); // 第二步:初始化,传入当前页面的ServletConfig和HttpServletRequest su.initialize(pageContext); // 在JSP里用pageContext,在Servlet里用 this.getServletConfig() 和 request // 第三步:设置限制条件(可选但强烈建议) su.setMaxFileSize(10 * 1024 * 1024); // 单个文件最大10MB su.setTotalMaxFileSize(50 * 1024 * 1024); // 总上传量最大50MB su.setAllowedFilesList("jpg,png,pdf,xls,xlsx"); // 白名单 su.setDeniedFilesList("exe,bat,sh,jsp"); // 黑名单 // 第四步:执行上传 su.upload();这里有几个细节值得展开。initialize()方法接收的是PageContext,这是JSP特有的上下文对象。如果你在纯Servlet里用,需要传入ServletConfig和HttpServletRequest,方法签名是initialize(ServletConfig config, HttpServletRequest request, HttpServletResponse response)。很多人从JSP迁移到Servlet时忘了改这个,导致NullPointerException。
setAllowedFilesList()和setDeniedFilesList()这两个方法,参数是逗号分隔的扩展名字符串,不带点。写"jpg,png"是对的,写".jpg,.png"就匹配不上了。而且这两个方法可以同时使用,SmartUpload会先检查黑名单再检查白名单。如果某个扩展名既不在白名单也不在黑名单,默认是允许的——所以只设黑名单是有漏网之鱼的,最佳实践是白名单为主、黑名单为辅。
2.3 SmartFiles与SmartRequest的分工
上传完成后,文件信息都在SmartFiles对象里。su.getFiles()返回的就是它。这个对象有几个常用方法:
| 方法 | 作用 | 返回值示例 |
|---|---|---|
getCount() | 获取上传文件总数 | 3 |
getFile(i) | 获取第i个文件对象 | SmartFile实例 |
getSize() | 获取所有文件总大小 | 204800(字节) |
getCollection() | 获取文件集合 | Files接口实现 |
单个SmartFile对象的方法更丰富:
| 方法 | 作用 | 注意事项 |
|---|---|---|
getFileName() | 获取原始文件名 | 只含文件名,不含路径 |
getFilePathName() | 获取完整路径名 | 某些浏览器会带路径 |
getFileExt() | 获取扩展名 | 不含点 |
getSize() | 获取文件大小 | 单位字节 |
saveAs(String path) | 保存到指定路径 | 路径需用/或\\ |
saveAs(String path, int option) | 带覆盖选项的保存 | SAVEAS_AUTO等常量 |
getBinaryData() | 获取二进制内容 | 返回byte[] |
saveAs()的第二个参数是个容易被忽略的宝藏。SmartUpload定义了三个常量:SAVEAS_AUTO(自动重命名,如果文件已存在则加数字后缀)、SAVEAS_VIRTUAL(虚拟路径)、SAVEAS_PHYSICAL(物理路径)。默认情况下,如果目标文件已存在,saveAs()会直接覆盖,不会报错也不会提示。这在生产环境里是致命的——用户上传一个同名文件,把之前的覆盖了,你连日志都查不到。所以我的习惯是永远显式传入SmartUpload.SAVEAS_AUTO,让它自动处理重名。
3. 从零搭建一个可用的上传下载模块
3.1 环境准备与依赖放置
SmartUpload不是Maven中央仓库里的标准依赖,你得手动把jar包放到WEB-INF/lib目录下。网上流传的版本比较多,我建议用com.jspsmart.upload这个包名的版本,jar包大小在30KB左右。放好之后,在JSP页面顶部导入:
<%@ page import="com.jspsmart.upload.*" %>如果你用的是IDE,可能会提示找不到包,这时候检查一下项目的Build Path里有没有把WEB-INF/lib下的jar加进去。Eclipse需要手动Add to Build Path,IntelliJ IDEA一般会自动识别。
还有一个坑:SmartUpload的jar包和Servlet API的版本可能有冲突。某些老版本的SmartUpload里引用了javax.servlet.ServletInputStream的readLine()方法,这个方法在Servlet 2.4之后被废弃了,在Servlet 3.0+的容器里可能直接抛NoSuchMethodError。解决办法是换一个较新的SmartUpload版本,或者自己重写那个解析方法。我实测下来,2010年之后流传的版本基本都修复了这个问题。
3.2 上传页面的表单写法
上传页面的HTML表单有三个必须设置的属性,缺一不可:
<form action="upload.jsp" method="post" enctype="multipart/form-data"> <input type="text" name="username" /> <input type="file" name="file1" /> <input type="file" name="file2" /> <input type="submit" value="上传" /> </form>method必须是post,enctype必须是multipart/form-data,action指向处理页面。很多人忘了写enctype,结果文件根本传不上去,getFiles().getCount()返回0,还以为是代码问题。
文件输入框的name属性可以相同也可以不同。如果相同,SmartUpload会把它们当作多个文件处理,getCount()返回实际数量。如果不同,也不影响,因为SmartUpload是按顺序索引的,不依赖name来区分。
3.3 上传处理页面的完整实现
下面是一个我常用的上传处理模板,包含了大小限制、类型检查、自动重命名、异常处理:
<%@ page language="java" contentType="text/html; charset=UTF-8" pageEncoding="UTF-8"%> <%@ page import="com.jspsmart.upload.*" %> <%@ page import="java.io.*" %> <% SmartUpload su = new SmartUpload(); try { su.initialize(pageContext); su.setMaxFileSize(10 * 1024 * 1024); su.setTotalMaxFileSize(50 * 1024 * 1024); su.setAllowedFilesList("jpg,jpeg,png,gif,pdf,doc,docx,xls,xlsx"); su.setDeniedFilesList("exe,bat,sh,jsp,js,vbs"); su.upload(); String username = su.getRequest().getParameter("username"); int fileCount = su.getFiles().getCount(); String saveDir = application.getRealPath("/") + "uploads/"; File dir = new File(saveDir); if (!dir.exists()) { dir.mkdirs(); } for (int i = 0; i < fileCount; i++) { SmartFile file = su.getFiles().getFile(i); if (file.isMissing()) { continue; } String ext = file.getFileExt(); String newName = System.currentTimeMillis() + "_" + i + "." + ext; file.saveAs(saveDir + newName, SmartUpload.SAVEAS_AUTO); } out.println("上传成功,共" + fileCount + "个文件"); } catch (SmartUploadException e) { out.println("上传失败:" + e.getMessage()); } %>这段代码里有几个我踩过坑之后加上的保护措施。file.isMissing()用来判断这个文件字段是否真的选了文件——如果用户没选,SmartUpload也会创建一个SmartFile对象,但它是"缺失"状态,直接saveAs()会抛异常。System.currentTimeMillis()加索引的方式重命名,比用原始文件名安全得多,既避免了中文乱码问题,也防止了恶意文件名注入。
3.4 文件下载的实现方式
SmartUpload的下载功能其实用得不多,因为大多数人下载文件直接用超链接指向静态资源就行了。但如果你需要做权限控制——比如只有登录用户才能下载,或者要统计下载次数——那就得走Servlet/JSP动态输出。
SmartUpload提供了downloadFile()方法,但它的使用场景比较特殊,需要配合SmartUpload的initialize()和downloadFile()调用。更通用的做法是直接用response的输出流:
<%@ page import="java.io.*" %> <% String filePath = application.getRealPath("/") + "uploads/" + request.getParameter("file"); File file = new File(filePath); if (file.exists()) { response.setContentType("application/octet-stream"); response.setHeader("Content-Disposition", "attachment;filename=" + new String(file.getName().getBytes("GBK"), "ISO-8859-1")); FileInputStream fis = new FileInputStream(file); OutputStream os = response.getOutputStream(); byte[] buffer = new byte[4096]; int len; while ((len = fis.read(buffer)) != -1) { os.write(buffer, 0, len); } fis.close(); os.close(); } %>这里的中文文件名处理是个经典问题。Content-Disposition头里的文件名如果直接写中文,浏览器可能乱码。上面的代码先把文件名用GBK编码成字节数组,再用ISO-8859-1解码成字符串,这样在大多数浏览器里能正常显示。当然,更现代的做法是用URL编码,但考虑到SmartUpload的使用场景往往比较老旧,这个兼容性写法更稳妥。
4. 那些文档里不会写的坑与排查实录
4.1 中文乱码的三层根源
SmartUpload的中文乱码问题,我遇到过至少三种不同的表现,根源各不相同。
第一种是文件名乱码。上传一个叫"成绩单.xls"的文件,getFileName()返回的是"????.xls"或者一串问号。这是因为SmartUpload内部解析时用的字符集和浏览器提交时用的不一致。解决办法是在initialize()之后、upload()之前,调用su.setCharset("UTF-8")。注意,这个方法必须在upload()之前调用,之后调用无效。
第二种是普通字段乱码。表单里的username字段输入中文,su.getRequest().getParameter("username")拿到乱码。这个和文件名乱码是同一个根源,setCharset()同样能解决。但如果你的JSP页面本身没有设置pageEncoding="UTF-8",那还得先把页面编码统一了。
第三种是保存后的文件内容乱码。这个和SmartUpload无关,是文件本身的编码问题。比如上传一个CSV文件,用Excel打开发现中文乱码,那是CSV的BOM头问题,需要在写入时加上UTF-8 BOM。
注意:
setCharset()方法在部分SmartUpload版本里不存在,如果你的jar包里没有这个方法,可以尝试用su.setCharset("GBK")或者升级jar包。实在不行,就在获取文件名后手动转码:new String(file.getFileName().getBytes("ISO-8859-1"), "UTF-8")。
4.2 文件大小限制的"双重标准"
SmartUpload的大小限制有两个层面:setMaxFileSize()限制单个文件,setTotalMaxFileSize()限制所有文件总和。但很多人不知道的是,这两个限制是在解析过程中动态检查的,一旦超限,upload()会直接抛SmartUploadException,而且此时已经解析的部分数据不会回滚。
这意味着如果你设置了10MB的单文件限制,用户传了一个15MB的文件,SmartUpload在读到第10MB的时候就会抛异常,但前面10MB的数据已经在内存里了。如果并发量大的话,内存会被迅速吃满。所以我的建议是:在SmartUpload的限制之外,再在web.xml里配置容器的最大请求大小,让容器在更早的层面拦截超大请求。
<multipart-config> <max-file-size>10485760</max-file-size> <max-request-size>52428800</max-request-size> <file-size-threshold>0</file-size-threshold> </multipart-config>虽然SmartUpload不走容器的multipart解析,但容器的连接器层面还是会对请求体大小做限制,这能起到兜底作用。
4.3 保存路径的"相对与绝对"陷阱
saveAs()方法的路径参数,在不同操作系统下的表现不一样。在Windows下用/和\\都可以,在Linux下只能用/。更麻烦的是,如果你传入的是相对路径,SmartUpload会相对于当前JSP页面的工作目录来解析,而不是相对于Web应用的根目录。
我见过最典型的事故是:开发环境在Windows下用saveAs("uploads/" + fileName),文件保存到了Tomcat/bin/uploads/下面,而不是webapps/项目名/uploads/。部署到Linux服务器后,因为工作目录不同,文件又跑到了别的地方。正确的做法是始终用application.getRealPath("/")获取Web应用的绝对路径,然后拼接子目录。
String basePath = application.getRealPath("/"); String savePath = basePath + "uploads" + File.separator + fileName;用File.separator代替硬编码的斜杠,能自动适配不同操作系统。
4.4 并发上传时的文件名冲突
前面提到了用System.currentTimeMillis()重命名,但在高并发场景下,同一毫秒内可能有多个请求,时间戳会重复。更稳妥的方案是用UUID:
String newName = java.util.UUID.randomUUID().toString().replace("-", "") + "." + ext;UUID碰撞的概率极低,而且不依赖系统时间,分布式环境下也能用。如果你需要保留原始文件名的可读性,可以存两份:一份用UUID命名存磁盘,一份把原始文件名和UUID的映射关系存数据库。
4.5 SmartUpload与Filter的冲突
如果你在项目里用了Filter来做编码统一处理(比如CharacterEncodingFilter),可能会和SmartUpload冲突。因为Filter通常在chain.doFilter()之前调用request.setCharacterEncoding(),但SmartUpload根本不走容器的参数解析,所以这个设置对它无效。更严重的是,如果Filter里调用了request.getParameter(),会触发容器对请求体的解析,把输入流读掉,导致SmartUpload拿不到数据。
解决办法是:在Filter里判断请求的Content-Type,如果是multipart/form-data,就跳过参数解析逻辑,直接放行。
String contentType = request.getContentType(); if (contentType != null && contentType.startsWith("multipart/form-data")) { chain.doFilter(request, response); return; } // 其他请求正常处理编码 request.setCharacterEncoding("UTF-8"); chain.doFilter(request, response);5. 从SmartUpload迁移到现代方案的思路
5.1 什么时候该考虑换掉它
SmartUpload虽然能用,但它的局限性也很明显。首先是不支持大文件,因为它把整个文件读进内存再写磁盘,传一个500MB的文件,JVM直接OOM。其次是不支持进度条,因为它是同步阻塞的,上传过程中无法获取进度。再者是安全性,老版本的SmartUpload对路径穿越攻击的防护很弱,如果文件名里带了../,可能被写到预期之外的目录。
我的判断标准是:如果项目只是内部使用、文件大小在10MB以内、并发量不高,SmartUpload完全够用,没必要为了"技术先进"去重构。但如果是对外的文件服务、需要支持大文件断点续传、或者有严格的安全审计要求,那就该考虑迁移了。
5.2 迁移到Servlet 3.0 Part接口
Servlet 3.0引入了Part接口,配合@MultipartConfig注解,可以替代SmartUpload的大部分功能。迁移的核心改动是把su.getFiles().getFile(i)换成request.getParts()的遍历,把file.saveAs()换成part.write()。
@WebServlet("/upload") @MultipartConfig(maxFileSize = 10485760, maxRequestSize = 52428800) public class UploadServlet extends HttpServlet { protected void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { request.setCharacterEncoding("UTF-8"); String username = request.getParameter("username"); for (Part part : request.getParts()) { if (part.getName().startsWith("file")) { String fileName = extractFileName(part); String savePath = getServletContext().getRealPath("/uploads/") + fileName; part.write(savePath); } } } private String extractFileName(Part part) { String header = part.getHeader("content-disposition"); for (String token : header.split(";")) { if (token.trim().startsWith("filename")) { return token.substring(token.indexOf('=') + 1).trim().replace("\"", ""); } } return "unknown"; } }Part接口的优势是容器原生支持,不需要额外jar包,而且容器会自动处理临时文件的清理。但它的API相对底层,获取文件名需要自己解析content-disposition头,不如SmartUpload的getFileName()直观。
5.3 迁移到Apache Commons FileUpload
如果项目还在用Servlet 2.5或更早的版本,没法用Part接口,那Commons FileUpload是更成熟的选择。它的核心类是ServletFileUpload和DiskFileItemFactory,支持将大文件缓存到磁盘而不是全部读进内存。
DiskFileItemFactory factory = new DiskFileItemFactory(); factory.setSizeThreshold(1024 * 1024); // 1MB以上写磁盘 factory.setRepository(new File(System.getProperty("java.io.tmpdir"))); ServletFileUpload upload = new ServletFileUpload(factory); upload.setFileSizeMax(10 * 1024 * 1024); upload.setSizeMax(50 * 1024 * 1024); List<FileItem> items = upload.parseRequest(request); for (FileItem item : items) { if (item.isFormField()) { String value = item.getString("UTF-8"); } else { String fileName = item.getName(); item.write(new File(saveDir, fileName)); } }Commons FileUpload的API设计更清晰,isFormField()区分普通字段和文件字段,getString("UTF-8")直接指定编码,比SmartUpload的setCharset()更直观。而且它支持进度监听,配合ProgressListener可以实现上传进度条。
5.4 迁移过程中的兼容性处理
迁移最麻烦的不是代码改写,而是行为差异。SmartUpload的getFiles().getCount()返回的是文件字段的数量,而Commons FileUpload的items.size()返回的是所有字段(包括普通字段)的数量。如果直接替换,循环逻辑会出错。
另外,SmartUpload在文件字段为空时也会创建一个SmartFile对象,而Commons FileUpload在用户没选文件时,对应的FileItem的getName()返回空字符串。迁移时需要加判断:
if (fileName == null || fileName.trim().isEmpty()) { continue; }还有一个容易忽略的点:SmartUpload的saveAs()会自动创建父目录,而Commons FileUpload的item.write()不会。如果目标目录不存在,会直接抛FileNotFoundException。迁移后需要手动加mkdirs()。
6. 一些实战中的经验碎片
关于SmartUpload的setMaxFileSize(),有个细节值得单独说:它的单位是字节,但如果你传入的值小于实际文件大小,抛出的异常信息里不会告诉你具体超了多少。我习惯在catch块里把su.getFiles().getSize()也打印出来,方便定位问题。
还有getFileExt()方法,它返回的扩展名是小写的,但原始文件名里可能是大写。如果你用扩展名做白名单匹配,记得统一转成小写再比较。我见过有人白名单里写"JPG",结果用户传.jpg文件被拒绝的案例。
最后说一个关于文件存储路径的心得。不要把上传文件保存在Web应用的目录下,因为重新部署WAR包时,整个目录会被清空。正确的做法是保存在Web应用之外的独立目录,比如/data/uploads/,然后在Web服务器层面配置一个虚拟路径映射过去。这样既保证了文件安全,也方便做备份和迁移。
如果你正在维护一个用SmartUpload的老系统,我的建议是:先别急着换,把现有的坑填好,加上大小限制、类型检查、UUID重命名、路径隔离这四道防线,它能稳稳当当再跑好几年。技术选型没有绝对的对错,能解决问题的就是好方案。