在 Web 后台管理系统里,数据的增、删、改几乎是标配,本质都是对数据库表记录的操作用户界面。当操作的数据里包含头像图片时,事情就多了一环——文件上传。
这篇从”一次提交到底发了什么”讲起,再对比几种实现方式,最后用”学生头像上传”的完整实例把前后端串起来,并补上现代写法和你必须注意的安全坑。
一、文件上传的底层原理
1. 一次请求,本质是发送一段文本
当浏览器向服务器发一次请求,本质上就是发送一段按 HTTP 协议格式组装的文本数据。普通表单提交是 application/x-www-form-urlencoded,而一旦表单里要带文件,就必须改成 multipart/form-data——它会把每个字段(包括文件)用”分隔符(boundary)”切成一段一段。
原文对 HTTP 请求格式有配图,报文结构如下:

下面用一段 multipart/form-data 请求体帮你再看清结构:
POST /addStudentServlet.do HTTP/1.1
Host: localhost
Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryABC123
------WebKitFormBoundaryABC123
Content-Disposition: form-data; name="userName"
张三
------WebKitFormBoundaryABC123
Content-Disposition: form-data; name="number"
2021001
------WebKitFormBoundaryABC123
Content-Disposition: form-data; name="headerImg"; filename="avatar.png"
Content-Type: image/png
<这里是头像文件的二进制数据>
------WebKitFormBoundaryABC123--
注意三点:
-
普通文本字段(userName、number)和文件字段(headerImg)被同一个 boundary 隔开;
-
文件字段额外带
filename 和 Content-Type;
-
文件内容就是原始二进制流,服务器要按这个格式把它”切”出来、再写到磁盘。
2. 上传 = 按格式读 + io 流写文件
所以”自己实现上传”的思路很朴素:按 HTTP 协议格式把请求体读出来,找到文件那段二进制,用 IO 流 write 到服务器磁盘。难点在于解析格式繁琐、边界情况多——所以生产环境都用现成库。
二、三种实现方式对比
| 方式 |
思路 |
现状 |
| ① 自己解析 HTTP |
手动读流、切 boundary、写文件 |
适合理解原理,生产不推荐 |
| ② SmartUpload 库 |
一行 su.upload() 搞定 |
已停止维护,仅历史项目可见 |
| ③ commons-fileupload |
Apache 组件,解析封装到 FileItem |
经典但 已 EOL(2021 起废弃),新项目用 Servlet 3.0+ |
1. 自己解析实现
用到两块知识:HTTP 协议(按上面格式组装/拆解请求)+ IO 流(把读到的头像二进制写文件)。理解了它,你就明白”上传”没有任何魔法。
2. SmartUpload 上传库
早年很流行,几行代码就能上传:
SmartUpload su = new SmartUpload();
su.initialize(getServletConfig(), req, resp);
su.upload();
su.save(file);
注记:SmartUpload 早已停止维护,且对中文、大文件支持差,现代项目不要再用。
3. commons-fileupload 上传库(经典方案)
下载 commons-fileupload-x.x.jar 和 `commons-io-x.x.jar(地址),核心就两个类。
① 文件项目工厂 DiskFileItemFactory
决定”小文件放内存、大文件落临时盘”:
-
DiskFileItemFactory():默认 sizeThreshold = 10KB,超过则存到临时目录(System.getProperty("java.io.tmpdir") 获取);
-
DiskFileItemFactory(int sizeThreshold, File repository):可自定义阈值和临时目录路径。
② 请求解析类 ServletFileUpload
把上传请求解析成 List<FileItem>,每个 FileItem 就是表单里的一个字段(文本或文件)。关键设置:
| 方法 |
作用 |
setHeaderEncoding("UTF-8") |
读取请求头用的编码,防中文乱码 |
setSizeMax(long) |
单次请求总大小上限,默认 -1 不限制 |
setFileSizeMax(long) |
单个文件大小上限,默认 -1 不限制 |
ServletFileUpload sfu = new ServletFileUpload(factory);
sfu.setHeaderEncoding("UTF-8");
List<FileItem> items = sfu.parseRequest(req);
注记:Apache 已于 2021 年宣布 commons-fileupload 停止维护(EOL),并建议迁移到 Servlet 3.0 内置的 Part API 或框架封装。下面的实例保留其写法以便读老代码,现代写法见第六节。
三、实战:学生头像上传实例
下面是一套**添加学生(含头像)+ 修改学生(换头像、删旧图)**的完整实现,逻辑清晰,是理解”表单 + 文件”混合提交的好样本。
1. 添加学生
前端 addstudent.jsp —— 关键是 enctype="multipart/form-data":
<div class="login-form">
<form action="./addStudentServlet.do" method="post" enctype="multipart/form-data">
<input type="text" name="userName" placeholder="请输入学生姓名">
<input type="text" name="number" placeholder="请输入学生学号"><br><br>
头像 :<input type="file" name="headerImg"/>
<input type="submit" value="添加">
</form>
</div>
后端 Servlet —— 把”解析请求”和”存数据库”拆成两个方法:
protected void doPost(HttpServletRequest request, HttpServletResponse response)
throws ServletException, IOException {
HashMap<String, String> parameterMap = new HashMap<String, String>();
uploadHeader(request, parameterMap);
addStudent(parameterMap, request, response);
}
public void uploadHeader(HttpServletRequest request, HashMap<String, String> parameterMap)
throws UnsupportedEncodingException {
ServletFileUpload servletFileUpload = new ServletFileUpload(new DiskFileItemFactory());
servletFileUpload.setHeaderEncoding("UTF-8");
List<FileItem> fileItemLists = null;
try {
fileItemLists = servletFileUpload.parseRequest(request);
} catch (FileUploadException e) {
e.printStackTrace();
}
Iterator<FileItem> iterator = fileItemLists.iterator();
while (iterator.hasNext()) {
FileItem fileItem = iterator.next();
if (fileItem.isFormField()) {
String fieldName = fileItem.getFieldName();
String fileValue = fileItem.getString("UTF-8");
parameterMap.put(fieldName, fileValue);
} else {
String rootDir = getServletContext().getRealPath("/");
String fileDir = "upload/" + fileItem.getName();
File file = new File(rootDir + fileDir);
FileUtil.createFile(file);
try {
fileItem.write(file);
} catch (Exception e) {
e.printStackTrace();
}
parameterMap.put(fileItem.getFieldName(), fileDir);
}
}
}
private void addStudent(HashMap<String, String> parameters,
HttpServletRequest request, HttpServletResponse response)
throws ServletException, IOException {
String userName = parameters.get("userName");
String number = parameters.get("number");
int numbers = Integer.parseInt(number);
int ages = 23;
String fileUrl = parameters.get("headerImg");
System.out.println("userName:" + userName + ", number:" + number + ", fileUrl:" + fileUrl);
}
核心思路一句话:isFormField() 区分文本和文件——文本直接取值,文件先写盘再把”相对路径”当作普通参数存起来,最后一起进数据库。
2. 修改学生(换头像)
修改比添加多一步:先上传新头像,再删掉旧头像,最后更新数据库字段。
protected void doPost(HttpServletRequest request, HttpServletResponse response)
throws ServletException, IOException {
HashMap<String, String> parameterMap = new HashMap<>();
uploadHeader(request, parameterMap);
deleteHeaderImg(parameterMap);
updateStudent(parameterMap);
response.sendRedirect(request.getContextPath() + "/student_list.jsp");
}
顺序很重要:先拿到新图路径,再根据数据库里的旧路径删文件,最后更新字段。若先删再传,中途失败就会”图没了字段也没更新”。
四、现代写法(推荐新项目用)
上面的实例是理解原理的教科书,但新项目请用更简洁的方案。
1. 后端:Servlet 3.0+ 的 Part API(无需任何上传库)
Servlet 3.0 起内置了文件上传,加一个 @MultipartConfig 注解即可:
@WebServlet("/upload")
@MultipartConfig(
fileSizeThreshold = 1024 * 1024, // 1MB 以下留内存
maxFileSize = 5 * 1024 * 1024, // 单文件最大 5MB
maxRequestSize = 10 * 1024 * 1024 // 单次请求最大 10MB
)
public class UploadServlet extends HttpServlet {
protected void doPost(HttpServletRequest req, HttpServletResponse resp)
throws IOException, ServletException {
String userName = req.getParameter("userName");
Part filePart = req.getPart("headerImg");
String fileName = UUID.randomUUID() + "_" +
filePart.getSubmittedFileName();
String saveDir = getServletContext().getRealPath("/") + "upload/";
filePart.write(saveDir + fileName);
}
}
2. 后端:Spring Boot 的 MultipartFile
Spring 项目更直接,参数直接是对象:
@PostMapping("/students")
public String addStudent(@RequestParam String userName,
@RequestParam MultipartFile headerImg) throws IOException {
String fileName = UUID.randomUUID() + "_" + headerImg.getOriginalFilename();
headerImg.transferTo(new File(uploadDir + fileName));
return "redirect:/student_list";
}
3. 前端:AJAX + FormData 异步上传(带头像预览)
现代后台几乎都用异步提交,体验更好——选完图立刻预览,不必整页刷新:
<form id="stuForm">
<input type="text" name="userName" placeholder="学生姓名">
<input type="file" id="headerImg" name="headerImg" accept="image/*">
<img id="preview" src="" alt="头像预览" style="max-width:120px">
<button type="button" onclick="submitForm()">添加</button>
</form>
<script>
document.getElementById('headerImg').onchange = e => {
const url = URL.createObjectURL(e.target.files[0]);
document.getElementById('preview').src = url;
};
function submitForm() {
const fd = new FormData(document.getElementById('stuForm'));
fetch('/students', { method: 'POST', body: fd })
.then(r => r.json())
.then(data => console.log('上传成功', data));
}
</script>
五、安全与避坑(务必看)
文件上传是 Web 安全的重灾区,上线前至少做这几件事:
| 风险 |
对策 |
传了可执行脚本(如 .jsp/.php)被绕过后门 |
扩展名白名单校验,只允许 jpg/png/gif/webp 等 |
| 超大文件撑爆磁盘 |
设 fileSizeMax / Spring 的 max-file-size 上限 |
路径穿越(filename="../../web.xml") |
文件名用 UUID 重命名,丢弃原始名;或用 FilenameUtils.getName() 只取最后一段 |
| 重新部署丢文件 |
不要存到 webapp/upload/ 下(打包会覆盖),应存项目外目录或对象存储(OSS/COS) |
| 图片内容伪造 |
校验真实 Content-Type 或读取文件头(Magic Number),不只看后缀 |
一句话:永远不要把用户传上来的东西,原封不动地当成”可信文件”放在可被直接访问/执行的路径下。
六、结语
“表单提交 + 头像上传”看似是两件事,其实是一件事:表单提交负责文本,文件上传负责二进制,二者靠 multipart/form-data 在同一个请求里被一起打包。
-
想懂原理:看 commons-fileupload 怎么把请求解析成
FileItem;
-
想写新项目:用 Servlet 3.0 的
Part 或 Spring 的 MultipartFile,配 AJAX 异步上传;
-
想上线:务必过一遍”扩展名白名单 + 大小限制 + UUID 重命名 + 对象存储”四道闸。
把这一套跑通,你就具备了后台管理系统里最典型的一类”图文混合提交”能力。