文件下载、ServletOutputStream 与 img 标签回显
纲要
- 下载本质 :把服务端文件以流的形式写回浏览器,是上传的逆过程
- 两种表现形式:附件形式(弹出保存对话框)与内联形式(浏览器直接打开),本项目用后者
- 触发方式 :
<img :src="/common/download?name=xxx">,浏览器自动发GET请求 - 双流配合 :
FileInputStream读文件内容,ServletOutputStream写回响应 - 响应头 :
response.setContentType("image/jpeg")告诉浏览器按图片渲染 - 方法设计 :返回
void,不经过R封装,直接操作HttpServletResponse - 缓冲区读取 :
byte[1024]循环读写,read()返回-1表示读完 - 资源关闭 :
outputStream.close()与fileInputStream.close()
文件下载的两种形态
附件形式下载
访问下载链接时,浏览器弹出「另存为」对话框,让用户选择保存位置。实现方式是设置响应头:
java
response.setHeader("Content-Disposition", "attachment; filename=" + fileName);
典型场景:下载软件安装包、Excel 导出、合同的 PDF。
内联形式下载
文件直接在浏览器页面中打开。图片、PDF、TXT 这类浏览器能解析的格式,默认就是内联。
#mermaid-svg-v8eZnl3oWP2tVPkN{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-v8eZnl3oWP2tVPkN .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-v8eZnl3oWP2tVPkN .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-v8eZnl3oWP2tVPkN .error-icon{fill:#552222;}#mermaid-svg-v8eZnl3oWP2tVPkN .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-v8eZnl3oWP2tVPkN .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-v8eZnl3oWP2tVPkN .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-v8eZnl3oWP2tVPkN .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-v8eZnl3oWP2tVPkN .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-v8eZnl3oWP2tVPkN .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-v8eZnl3oWP2tVPkN .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-v8eZnl3oWP2tVPkN .marker{fill:#333333;stroke:#333333;}#mermaid-svg-v8eZnl3oWP2tVPkN .marker.cross{stroke:#333333;}#mermaid-svg-v8eZnl3oWP2tVPkN svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-v8eZnl3oWP2tVPkN p{margin:0;}#mermaid-svg-v8eZnl3oWP2tVPkN .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-v8eZnl3oWP2tVPkN .cluster-label text{fill:#333;}#mermaid-svg-v8eZnl3oWP2tVPkN .cluster-label span{color:#333;}#mermaid-svg-v8eZnl3oWP2tVPkN .cluster-label span p{background-color:transparent;}#mermaid-svg-v8eZnl3oWP2tVPkN .label text,#mermaid-svg-v8eZnl3oWP2tVPkN span{fill:#333;color:#333;}#mermaid-svg-v8eZnl3oWP2tVPkN .node rect,#mermaid-svg-v8eZnl3oWP2tVPkN .node circle,#mermaid-svg-v8eZnl3oWP2tVPkN .node ellipse,#mermaid-svg-v8eZnl3oWP2tVPkN .node polygon,#mermaid-svg-v8eZnl3oWP2tVPkN .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-v8eZnl3oWP2tVPkN .rough-node .label text,#mermaid-svg-v8eZnl3oWP2tVPkN .node .label text,#mermaid-svg-v8eZnl3oWP2tVPkN .image-shape .label,#mermaid-svg-v8eZnl3oWP2tVPkN .icon-shape .label{text-anchor:middle;}#mermaid-svg-v8eZnl3oWP2tVPkN .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-v8eZnl3oWP2tVPkN .rough-node .label,#mermaid-svg-v8eZnl3oWP2tVPkN .node .label,#mermaid-svg-v8eZnl3oWP2tVPkN .image-shape .label,#mermaid-svg-v8eZnl3oWP2tVPkN .icon-shape .label{text-align:center;}#mermaid-svg-v8eZnl3oWP2tVPkN .node.clickable{cursor:pointer;}#mermaid-svg-v8eZnl3oWP2tVPkN .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-v8eZnl3oWP2tVPkN .arrowheadPath{fill:#333333;}#mermaid-svg-v8eZnl3oWP2tVPkN .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-v8eZnl3oWP2tVPkN .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-v8eZnl3oWP2tVPkN .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-v8eZnl3oWP2tVPkN .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-v8eZnl3oWP2tVPkN .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-v8eZnl3oWP2tVPkN .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-v8eZnl3oWP2tVPkN .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-v8eZnl3oWP2tVPkN .cluster text{fill:#333;}#mermaid-svg-v8eZnl3oWP2tVPkN .cluster span{color:#333;}#mermaid-svg-v8eZnl3oWP2tVPkN div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-v8eZnl3oWP2tVPkN .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-v8eZnl3oWP2tVPkN rect.text{fill:none;stroke-width:0;}#mermaid-svg-v8eZnl3oWP2tVPkN .icon-shape,#mermaid-svg-v8eZnl3oWP2tVPkN .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-v8eZnl3oWP2tVPkN .icon-shape p,#mermaid-svg-v8eZnl3oWP2tVPkN .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-v8eZnl3oWP2tVPkN .icon-shape .label rect,#mermaid-svg-v8eZnl3oWP2tVPkN .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-v8eZnl3oWP2tVPkN .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-v8eZnl3oWP2tVPkN .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-v8eZnl3oWP2tVPkN :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 能(image/jpeg、application/pdf)
不能(application/octet-stream、zip)
浏览器发起下载请求
服务端返回字节流 + Content-Type
浏览器能否解析该 MIME?
直接在页面渲染
弹出保存对话框
Content-Disposition: attachment
外卖项目里菜品图片是内联形式------列表页的 <img> 直接显示缩略图,不需要用户点保存。
为什么内联也算「下载」
从网络角度讲,两者完全一致:服务端把文件字节通过 HTTP 响应体传给客户端。区别只在于浏览器拿到数据后怎么处理。所以「在浏览器里显示图片」本质上就是一次下载。
执行流程
HttpServletResponse 磁盘 D:\img CommonController 浏览器 upload.html 用户 HttpServletResponse 磁盘 D:\img CommonController 浏览器 upload.html 用户 #mermaid-svg-aD5hBs1708FSBLpk{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-aD5hBs1708FSBLpk .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-aD5hBs1708FSBLpk .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-aD5hBs1708FSBLpk .error-icon{fill:#552222;}#mermaid-svg-aD5hBs1708FSBLpk .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-aD5hBs1708FSBLpk .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-aD5hBs1708FSBLpk .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-aD5hBs1708FSBLpk .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-aD5hBs1708FSBLpk .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-aD5hBs1708FSBLpk .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-aD5hBs1708FSBLpk .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-aD5hBs1708FSBLpk .marker{fill:#333333;stroke:#333333;}#mermaid-svg-aD5hBs1708FSBLpk .marker.cross{stroke:#333333;}#mermaid-svg-aD5hBs1708FSBLpk svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-aD5hBs1708FSBLpk p{margin:0;}#mermaid-svg-aD5hBs1708FSBLpk .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-aD5hBs1708FSBLpk text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-aD5hBs1708FSBLpk .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-aD5hBs1708FSBLpk .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-aD5hBs1708FSBLpk .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-aD5hBs1708FSBLpk .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-aD5hBs1708FSBLpk #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-aD5hBs1708FSBLpk .sequenceNumber{fill:white;}#mermaid-svg-aD5hBs1708FSBLpk #sequencenumber{fill:#333;}#mermaid-svg-aD5hBs1708FSBLpk #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-aD5hBs1708FSBLpk .messageText{fill:#333;stroke:none;}#mermaid-svg-aD5hBs1708FSBLpk .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-aD5hBs1708FSBLpk .labelText,#mermaid-svg-aD5hBs1708FSBLpk .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-aD5hBs1708FSBLpk .loopText,#mermaid-svg-aD5hBs1708FSBLpk .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-aD5hBs1708FSBLpk .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-aD5hBs1708FSBLpk .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-aD5hBs1708FSBLpk .noteText,#mermaid-svg-aD5hBs1708FSBLpk .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-aD5hBs1708FSBLpk .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-aD5hBs1708FSBLpk .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-aD5hBs1708FSBLpk .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-aD5hBs1708FSBLpk .actorPopupMenu{position:absolute;}#mermaid-svg-aD5hBs1708FSBLpk .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-aD5hBs1708FSBLpk .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-aD5hBs1708FSBLpk .actor-man circle,#mermaid-svg-aD5hBs1708FSBLpk line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-aD5hBs1708FSBLpk :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} loop 循环读取 选择图片 POST /common/upload 转存为 uuid.jpg {"code":1,"data":"uuid.jpg"} "handleAvatarSuccess: imageUrl = /common/download?name=uuid.jpg" "<img src='/common/download?name=uuid.jpg'>" GET /common/download?name=uuid.jpg new FileInputStream(D:\img\uuid.jpg) 文件输入流 response.setContentType("image/jpeg") len = fis.read(bytes) out.write(bytes, 0, len) out.flush() 关闭两个流 图片字节流 按 image/jpeg 渲染 页面显示图片
前端:<img> 标签即下载请求
页面代码
html
<el-upload class="avatar-uploader"
action="/common/upload"
:show-file-list="false"
:on-success="handleAvatarSuccess"
:before-upload="beforeUpload"
ref="upload">
<img v-if="imageUrl" :src="imageUrl" class="avatar"></img>
<i v-else class="el-icon-plus avatar-uploader-icon"></i>
</el-upload>
js
handleAvatarSuccess (response, file, fileList) {
this.imageUrl = `/common/download?name=${response.data}`
}
逻辑拆解
| 步骤 | 代码 | 说明 |
|---|---|---|
| 上传成功回调 | handleAvatarSuccess(response) |
Element UI 在 HTTP 200 后调用 |
| 取出文件名 | response.data |
即 R.success(fileName) 中的 data |
| 拼接下载地址 | /common/download?name=${response.data} |
模板字符串拼 URL |
| 赋值给模型 | this.imageUrl = ... |
触发 Vue 响应式更新 |
<img> 的 src 变化 |
:src="imageUrl" |
浏览器自动发起 GET 请求 |
| 拿到字节流 | --- | 浏览器按 Content-Type 渲染 |
关键点 :我们从来没有手动发过下载请求。是 <img> 的 src 属性被赋值后,浏览器自动发起的。这是 HTML 的固有行为。
为什么 v-if="imageUrl"
上传前 imageUrl 为空字符串,此时渲染 <i class="el-icon-plus"> 加号图标;上传成功后 imageUrl 有值,切换为 <img>。v-else 保证了两种状态的互斥。
后端实现
方法签名
java
/**
* 文件下载
* @param name
* @param response
*/
@GetMapping("/download")
public void download(String name, HttpServletResponse response){
// ...
}
三个设计决策:
为什么返回 void
下载方法直接往 HttpServletResponse 的输出流里写二进制数据。如果返回 R<String>,Spring MVC 会尝试在写流之后再做一次 JSON 序列化,导致:
java.lang.IllegalStateException: getWriter() has already been called for this response
所以下载类接口一律返回 void,由开发者完全接管响应。
为什么需要 HttpServletResponse 形参
ServletOutputStream 不能 new,必须从响应对象获取:
java
ServletOutputStream outputStream = response.getOutputStream();
Spring MVC 支持在 Controller 方法形参中直接声明 HttpServletRequest/HttpServletResponse,运行时由 ServletRequestMethodArgumentResolver 注入。
为什么用 GET 而不是 POST
<img src> 触发的是 GET 请求,这是 HTML 规范决定的。另外 GET 的响应可以被浏览器缓存,图片重复展示时能省流量。
完整实现
java
@GetMapping("/download")
public void download(String name, HttpServletResponse response){
try {
//输入流,通过输入流读取文件内容
FileInputStream fileInputStream = new FileInputStream(new File(basePath + name));
//输出流,通过输出流将文件写回浏览器
ServletOutputStream outputStream = response.getOutputStream();
response.setContentType("image/jpeg");
int len = 0;
byte[] bytes = new byte[1024];
while ((len = fileInputStream.read(bytes)) != -1){
outputStream.write(bytes,0,len);
outputStream.flush();
}
//关闭资源
outputStream.close();
fileInputStream.close();
} catch (Exception e) {
e.printStackTrace();
}
}
逐行解析
构造输入流
java
FileInputStream fileInputStream = new FileInputStream(new File(basePath + name));
basePath 来自 @Value("${reggie.path}"),与上传时是同一个目录。name 是请求参数中传来的文件名(含后缀)。两者拼接得到磁盘上的完整路径。
获取输出流
java
ServletOutputStream outputStream = response.getOutputStream();
ServletOutputStream 继承自 OutputStream,是 Servlet 规范提供的响应输出流。
设置 Content-Type
java
response.setContentType("image/jpeg");
告诉浏览器响应体是 JPEG 图片。MIME 类型决定了浏览器的处理方式:
Content-Type |
浏览器行为 |
|---|---|
image/jpeg、image/png、image/gif |
内联渲染为图片 |
application/pdf |
调用内置 PDF 阅读器 |
text/plain |
直接显示文本 |
application/octet-stream |
下载保存 |
本项目固定写死
image/jpeg。如果上传了PNG,返回的类型头不准确,但绝大多数浏览器会嗅探内容而非严格信任响应头,因此仍能正常显示。严谨做法是根据文件后缀动态设置。
循环读写
java
int len = 0;
byte[] bytes = new byte[1024];
while ((len = fileInputStream.read(bytes)) != -1){
outputStream.write(bytes, 0, len);
outputStream.flush();
}
这是一段标准的流拷贝代码,几个细节:
| 元素 | 作用 |
|---|---|
byte[] bytes = new byte[1024] |
1KB 缓冲区,避免单字节读写的极低效率 |
fileInputStream.read(bytes) |
读最多 bytes.length 个字节,返回实际读到的字节数 |
!= -1 |
InputStream.read 的约定:读到末尾返回 -1 |
outputStream.write(bytes, 0, len) |
只写实际读到的 len 个字节 |
flush() |
强制刷出缓冲区 |
为什么 write 必须传 len :最后一次读取时缓冲区往往填不满,比如文件共 1500 字节,第二次 read 只返回 476。若写成 write(bytes),会把上一次残留的 524 字节也写出去,图片尾部出现脏数据(表现为图片下方有杂色条纹)。
#mermaid-svg-3qvKVodhZmuu3HIr{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-3qvKVodhZmuu3HIr .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-3qvKVodhZmuu3HIr .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-3qvKVodhZmuu3HIr .error-icon{fill:#552222;}#mermaid-svg-3qvKVodhZmuu3HIr .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-3qvKVodhZmuu3HIr .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-3qvKVodhZmuu3HIr .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-3qvKVodhZmuu3HIr .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-3qvKVodhZmuu3HIr .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-3qvKVodhZmuu3HIr .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-3qvKVodhZmuu3HIr .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-3qvKVodhZmuu3HIr .marker{fill:#333333;stroke:#333333;}#mermaid-svg-3qvKVodhZmuu3HIr .marker.cross{stroke:#333333;}#mermaid-svg-3qvKVodhZmuu3HIr svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-3qvKVodhZmuu3HIr p{margin:0;}#mermaid-svg-3qvKVodhZmuu3HIr .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-3qvKVodhZmuu3HIr .cluster-label text{fill:#333;}#mermaid-svg-3qvKVodhZmuu3HIr .cluster-label span{color:#333;}#mermaid-svg-3qvKVodhZmuu3HIr .cluster-label span p{background-color:transparent;}#mermaid-svg-3qvKVodhZmuu3HIr .label text,#mermaid-svg-3qvKVodhZmuu3HIr span{fill:#333;color:#333;}#mermaid-svg-3qvKVodhZmuu3HIr .node rect,#mermaid-svg-3qvKVodhZmuu3HIr .node circle,#mermaid-svg-3qvKVodhZmuu3HIr .node ellipse,#mermaid-svg-3qvKVodhZmuu3HIr .node polygon,#mermaid-svg-3qvKVodhZmuu3HIr .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-3qvKVodhZmuu3HIr .rough-node .label text,#mermaid-svg-3qvKVodhZmuu3HIr .node .label text,#mermaid-svg-3qvKVodhZmuu3HIr .image-shape .label,#mermaid-svg-3qvKVodhZmuu3HIr .icon-shape .label{text-anchor:middle;}#mermaid-svg-3qvKVodhZmuu3HIr .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-3qvKVodhZmuu3HIr .rough-node .label,#mermaid-svg-3qvKVodhZmuu3HIr .node .label,#mermaid-svg-3qvKVodhZmuu3HIr .image-shape .label,#mermaid-svg-3qvKVodhZmuu3HIr .icon-shape .label{text-align:center;}#mermaid-svg-3qvKVodhZmuu3HIr .node.clickable{cursor:pointer;}#mermaid-svg-3qvKVodhZmuu3HIr .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-3qvKVodhZmuu3HIr .arrowheadPath{fill:#333333;}#mermaid-svg-3qvKVodhZmuu3HIr .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-3qvKVodhZmuu3HIr .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-3qvKVodhZmuu3HIr .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-3qvKVodhZmuu3HIr .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-3qvKVodhZmuu3HIr .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-3qvKVodhZmuu3HIr .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-3qvKVodhZmuu3HIr .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-3qvKVodhZmuu3HIr .cluster text{fill:#333;}#mermaid-svg-3qvKVodhZmuu3HIr .cluster span{color:#333;}#mermaid-svg-3qvKVodhZmuu3HIr div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-3qvKVodhZmuu3HIr .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-3qvKVodhZmuu3HIr rect.text{fill:none;stroke-width:0;}#mermaid-svg-3qvKVodhZmuu3HIr .icon-shape,#mermaid-svg-3qvKVodhZmuu3HIr .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-3qvKVodhZmuu3HIr .icon-shape p,#mermaid-svg-3qvKVodhZmuu3HIr .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-3qvKVodhZmuu3HIr .icon-shape .label rect,#mermaid-svg-3qvKVodhZmuu3HIr .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-3qvKVodhZmuu3HIr .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-3qvKVodhZmuu3HIr .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-3qvKVodhZmuu3HIr :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 文件 1500 字节
第1次 read → len=1024
write(bytes,0,1024)
第2次 read → len=476
write(bytes,0,476) ✓ 正确
write(bytes) ✗ 多写 548 字节脏数据
第3次 read → len=-1 结束
关闭资源
java
outputStream.close();
fileInputStream.close();
顺序上先关输出流再关输入流,确保数据全部刷出。
用 try-with-resources 改进
课程代码用 try-catch + 手动 close,如果中途抛异常,流不会被关闭,Linux 下会耗尽文件描述符。推荐写法:
java
@GetMapping("/download")
public void download(String name, HttpServletResponse response) {
try (FileInputStream fileInputStream = new FileInputStream(new File(basePath + name));
ServletOutputStream outputStream = response.getOutputStream()) {
response.setContentType("image/jpeg");
int len;
byte[] bytes = new byte[1024];
while ((len = fileInputStream.read(bytes)) != -1) {
outputStream.write(bytes, 0, len);
outputStream.flush();
}
} catch (Exception e) {
log.error("文件下载失败:{}", e.getMessage());
}
}
try-with-resources 会在代码块结束时自动调用 close(),无论是否发生异常。
用 Spring 工具类进一步简化
Spring 提供了 FileCopyUtils,一行搞定:
java
@GetMapping("/download")
public void download(String name, HttpServletResponse response) {
try (FileInputStream fileInputStream = new FileInputStream(new File(basePath + name))) {
response.setContentType("image/jpeg");
FileCopyUtils.copy(fileInputStream, response.getOutputStream());
} catch (Exception e) {
log.error("文件下载失败:{}", e.getMessage());
}
}
FileCopyUtils.copy(InputStream, OutputStream) 内部就是上面那段循环,返回拷贝的字节数。
功能测试
测试步骤
- 启动项目,访问
http://localhost:8080/backend/page/demo/upload.html - 点击上传区域,选择一张菜品图片
- 观察浏览器
Network面板
请求记录
会看到两条连续的请求:
txt
第 1 条:
Request URL: http://localhost:8080/common/upload
Request Method: POST
Status Code: 200 OK
Response: {"code":1,"data":"6a8f1c2e-3b4d-4f5a-9c8e-1d2f3a4b5c6d.jpg","msg":null,"map":{}}
第 2 条(由 img 的 src 自动触发):
Request URL: http://localhost:8080/common/download?name=6a8f1c2e-3b4d-4f5a-9c8e-1d2f3a4b5c6d.jpg
Request Method: GET
Status Code: 200 OK
Content-Type: image/jpeg
Response: (binary image data)
页面效果
上传区域从加号图标变成刚上传的图片------这就是下载的成果。
直接访问 URL 验证
在地址栏直接输入:
url
http://localhost:8080/common/download?name=6a8f1c2e-3b4d-4f5a-9c8e-1d2f3a4b5c6d.jpg
浏览器全屏显示该图片,证明下载接口本身工作正常,与前端无关。
异常场景:文件名不存在
传入一个不存在的文件名:
curl
http://localhost:8080/common/download?name=notexist.jpg
new FileInputStream 抛 FileNotFoundException,被 catch (Exception e) 捕获并打印堆栈。响应是空的 200,页面显示一个破图图标。
改进建议:返回 404 而不是静默失败。
java
@GetMapping("/download")
public void download(String name, HttpServletResponse response) {
File file = new File(basePath + name);
if (!file.exists()) {
response.setStatus(HttpServletResponse.SC_NOT_FOUND);
return;
}
// ... 正常下载
}
上传与下载的对称性
#mermaid-svg-M0ge71FAdr7n5s3a{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-M0ge71FAdr7n5s3a .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-M0ge71FAdr7n5s3a .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-M0ge71FAdr7n5s3a .error-icon{fill:#552222;}#mermaid-svg-M0ge71FAdr7n5s3a .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-M0ge71FAdr7n5s3a .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-M0ge71FAdr7n5s3a .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-M0ge71FAdr7n5s3a .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-M0ge71FAdr7n5s3a .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-M0ge71FAdr7n5s3a .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-M0ge71FAdr7n5s3a .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-M0ge71FAdr7n5s3a .marker{fill:#333333;stroke:#333333;}#mermaid-svg-M0ge71FAdr7n5s3a .marker.cross{stroke:#333333;}#mermaid-svg-M0ge71FAdr7n5s3a svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-M0ge71FAdr7n5s3a p{margin:0;}#mermaid-svg-M0ge71FAdr7n5s3a .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-M0ge71FAdr7n5s3a .cluster-label text{fill:#333;}#mermaid-svg-M0ge71FAdr7n5s3a .cluster-label span{color:#333;}#mermaid-svg-M0ge71FAdr7n5s3a .cluster-label span p{background-color:transparent;}#mermaid-svg-M0ge71FAdr7n5s3a .label text,#mermaid-svg-M0ge71FAdr7n5s3a span{fill:#333;color:#333;}#mermaid-svg-M0ge71FAdr7n5s3a .node rect,#mermaid-svg-M0ge71FAdr7n5s3a .node circle,#mermaid-svg-M0ge71FAdr7n5s3a .node ellipse,#mermaid-svg-M0ge71FAdr7n5s3a .node polygon,#mermaid-svg-M0ge71FAdr7n5s3a .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-M0ge71FAdr7n5s3a .rough-node .label text,#mermaid-svg-M0ge71FAdr7n5s3a .node .label text,#mermaid-svg-M0ge71FAdr7n5s3a .image-shape .label,#mermaid-svg-M0ge71FAdr7n5s3a .icon-shape .label{text-anchor:middle;}#mermaid-svg-M0ge71FAdr7n5s3a .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-M0ge71FAdr7n5s3a .rough-node .label,#mermaid-svg-M0ge71FAdr7n5s3a .node .label,#mermaid-svg-M0ge71FAdr7n5s3a .image-shape .label,#mermaid-svg-M0ge71FAdr7n5s3a .icon-shape .label{text-align:center;}#mermaid-svg-M0ge71FAdr7n5s3a .node.clickable{cursor:pointer;}#mermaid-svg-M0ge71FAdr7n5s3a .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-M0ge71FAdr7n5s3a .arrowheadPath{fill:#333333;}#mermaid-svg-M0ge71FAdr7n5s3a .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-M0ge71FAdr7n5s3a .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-M0ge71FAdr7n5s3a .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-M0ge71FAdr7n5s3a .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-M0ge71FAdr7n5s3a .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-M0ge71FAdr7n5s3a .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-M0ge71FAdr7n5s3a .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-M0ge71FAdr7n5s3a .cluster text{fill:#333;}#mermaid-svg-M0ge71FAdr7n5s3a .cluster span{color:#333;}#mermaid-svg-M0ge71FAdr7n5s3a div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-M0ge71FAdr7n5s3a .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-M0ge71FAdr7n5s3a rect.text{fill:none;stroke-width:0;}#mermaid-svg-M0ge71FAdr7n5s3a .icon-shape,#mermaid-svg-M0ge71FAdr7n5s3a .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-M0ge71FAdr7n5s3a .icon-shape p,#mermaid-svg-M0ge71FAdr7n5s3a .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-M0ge71FAdr7n5s3a .icon-shape .label rect,#mermaid-svg-M0ge71FAdr7n5s3a .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-M0ge71FAdr7n5s3a .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-M0ge71FAdr7n5s3a .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-M0ge71FAdr7n5s3a :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 下载
上传
文件名入库 dish.image
浏览器 multipart 请求
MultipartFile
transferTo(dest)
磁盘 D:\\img\\uuid.jpg
磁盘 D:\\img\\uuid.jpg
FileInputStream
ServletOutputStream
浏览器渲染
| 维度 | 上传 | 下载 |
|---|---|---|
HTTP 方法 |
POST |
GET |
Content-Type |
multipart/form-data |
image/jpeg |
| 参数 | MultipartFile file |
String name |
| 核心对象 | MultipartFile |
HttpServletResponse |
| 流的方向 | 浏览器 → 磁盘 | 磁盘 → 浏览器 |
| 返回值 | R<String>(文件名) |
void |
| 路径来源 | basePath + fileName |
basePath + name |
两者共用同一个 basePath,这是保证「传上去能下载回来」的前提。
完整可运行代码
目录结构
txt
reggie_take_out/
├── src/main/
│ ├── java/com/itheima/reggie/
│ │ ├── controller/
│ │ │ └── CommonController.java 上传 + 下载
│ │ └── common/
│ │ └── R.java
│ └── resources/
│ ├── application.yml reggie.path = D:\img\
│ └── backend/page/demo/
│ └── upload.html
└── src/test/
CommonController.java(完整)
java
package com.itheima.reggie.controller;
import com.itheima.reggie.common.R;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import javax.servlet.ServletOutputStream;
import javax.servlet.http.HttpServletResponse;
import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.util.UUID;
/**
* 文件上传和下载
*/
@RestController
@RequestMapping("/common")
@Slf4j
public class CommonController {
@Value("${reggie.path}")
private String basePath;
/**
* 文件上传
* @param file
* @return
*/
@PostMapping("/upload")
public R<String> upload(MultipartFile file){
//file是一个临时文件,需要转存到指定位置,否则本次请求完成后临时文件会删除
log.info(file.toString());
//原始文件名
String originalFilename = file.getOriginalFilename();//abc.jpg
String suffix = originalFilename.substring(originalFilename.lastIndexOf("."));
//使用UUID重新生成文件名,防止文件名称重复造成文件覆盖
String fileName = UUID.randomUUID().toString() + suffix;//dfsdfdfd.jpg
//创建一个目录对象
File dir = new File(basePath);
//判断当前目录是否存在
if(!dir.exists()){
//目录不存在,需要创建
dir.mkdirs();
}
try {
//将临时文件转存到指定位置
file.transferTo(new File(basePath + fileName));
} catch (IOException e) {
e.printStackTrace();
}
return R.success(fileName);
}
/**
* 文件下载
* @param name
* @param response
*/
@GetMapping("/download")
public void download(String name, HttpServletResponse response){
try {
//输入流,通过输入流读取文件内容
FileInputStream fileInputStream = new FileInputStream(new File(basePath + name));
//输出流,通过输出流将文件写回浏览器
ServletOutputStream outputStream = response.getOutputStream();
response.setContentType("image/jpeg");
int len = 0;
byte[] bytes = new byte[1024];
while ((len = fileInputStream.read(bytes)) != -1){
outputStream.write(bytes,0,len);
outputStream.flush();
}
//关闭资源
outputStream.close();
fileInputStream.close();
} catch (Exception e) {
e.printStackTrace();
}
}
}
改进版:动态 Content-Type + try-with-resources + 404 处理
java
package com.itheima.reggie.controller;
import com.itheima.reggie.common.R;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.util.FileCopyUtils;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import javax.servlet.ServletOutputStream;
import javax.servlet.http.HttpServletResponse;
import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.util.HashMap;
import java.util.Map;
import java.util.UUID;
/**
* 文件上传和下载(改进版)
*/
@RestController
@RequestMapping("/common")
@Slf4j
public class CommonController {
private static final Map<String, String> MIME_MAP = new HashMap<>();
static {
MIME_MAP.put(".jpg", "image/jpeg");
MIME_MAP.put(".jpeg", "image/jpeg");
MIME_MAP.put(".png", "image/png");
MIME_MAP.put(".gif", "image/gif");
MIME_MAP.put(".webp", "image/webp");
}
@Value("${reggie.path}")
private String basePath;
/**
* 文件上传
* @param file
* @return
*/
@PostMapping("/upload")
public R<String> upload(MultipartFile file) {
String originalFilename = file.getOriginalFilename();
String suffix = originalFilename.substring(originalFilename.lastIndexOf(".")).toLowerCase();
String fileName = UUID.randomUUID().toString() + suffix;
File dir = new File(basePath);
if (!dir.exists()) {
dir.mkdirs();
}
try {
file.transferTo(new File(basePath + fileName));
} catch (IOException e) {
log.error("文件上传失败:{}", e.getMessage());
throw new RuntimeException("文件上传失败");
}
return R.success(fileName);
}
/**
* 文件下载
* @param name
* @param response
*/
@GetMapping("/download")
public void download(String name, HttpServletResponse response) {
File target = new File(basePath + name);
if (!target.exists()) {
response.setStatus(HttpServletResponse.SC_NOT_FOUND);
return;
}
String suffix = name.substring(name.lastIndexOf(".")).toLowerCase();
response.setContentType(MIME_MAP.getOrDefault(suffix, "application/octet-stream"));
try (FileInputStream fileInputStream = new FileInputStream(target);
ServletOutputStream outputStream = response.getOutputStream()) {
int len;
byte[] bytes = new byte[1024];
while ((len = fileInputStream.read(bytes)) != -1) {
outputStream.write(bytes, 0, len);
outputStream.flush();
}
} catch (Exception e) {
log.error("文件下载失败:{}", e.getMessage());
}
}
}
application.yml
yaml
server:
port: 8080
spring:
application:
name: reggie_take_out
datasource:
druid:
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://localhost:3306/reggie?serverTimezone=Asia/Shanghai&useUnicode=true&characterEncoding=utf-8&zeroDateTimeBehavior=convertToNull&useSSL=false&allowPublicKeyRetrieval=true
username: root
password: root
mybatis-plus:
configuration:
map-underscore-to-camel-case: true
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
global-config:
db-config:
id-type: ASSIGN_ID
reggie:
path: D:\img\
upload.html
html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>文件上传</title>
<link rel="stylesheet" href="../../plugins/element-ui/index.css" />
<link rel="stylesheet" href="../../styles/common.css" />
<link rel="stylesheet" href="../../styles/page.css" />
</head>
<body>
<div class="addBrand-container" id="food-add-app">
<div class="container">
<el-upload class="avatar-uploader"
action="/common/upload"
:show-file-list="false"
:on-success="handleAvatarSuccess"
:before-upload="beforeUpload"
ref="upload">
<img v-if="imageUrl" :src="imageUrl" class="avatar"></img>
<i v-else class="el-icon-plus avatar-uploader-icon"></i>
</el-upload>
</div>
</div>
<script src="../../plugins/vue/vue.js"></script>
<script src="../../plugins/element-ui/index.js"></script>
<script src="../../plugins/axios/axios.min.js"></script>
<script src="../../js/index.js"></script>
<script>
new Vue({
el: '#food-add-app',
data() {
return {
imageUrl: ''
}
},
methods: {
handleAvatarSuccess (response, file, fileList) {
this.imageUrl = `/common/download?name=${response.data}`
},
beforeUpload (file) {
if(file){
const suffix = file.name.split('.')[1]
const size = file.size / 1024 / 1024 < 2
if(['png','jpeg','jpg'].indexOf(suffix) < 0){
this.$message.error('上传图片只支持 png、jpeg、jpg 格式!')
this.$refs.upload.clearFiles()
return false
}
if(!size){
this.$message.error('上传文件大小不能超过 2MB!')
return false
}
return file
}
}
}
})
</script>
</body>
</html>
API 速览
API |
所属框架 | 作用 |
|---|---|---|
@GetMapping |
Spring MVC |
限定 GET 方法 |
HttpServletResponse |
Servlet |
响应对象,可用于获取输出流、设置响应头 |
response.getOutputStream() |
Servlet |
获取二进制输出流 |
response.setContentType(String) |
Servlet |
设置响应体的 MIME 类型 |
response.setStatus(int) |
Servlet |
设置 HTTP 状态码 |
HttpServletResponse.SC_NOT_FOUND |
Servlet |
状态码常量 404 |
ServletOutputStream |
Servlet |
响应二进制输出流抽象 |
FileInputStream |
JDK |
文件字节输入流 |
InputStream.read(byte[]) |
JDK |
读取到缓冲区,返回实际字节数,末尾返回 -1 |
OutputStream.write(byte[], int, int) |
JDK |
写出指定区间的字节 |
OutputStream.flush() |
JDK |
刷新缓冲区 |
FileCopyUtils.copy(InputStream, OutputStream) |
Spring |
一行完成流拷贝 |
try-with-resources |
JDK |
自动关闭实现了 AutoCloseable 的资源 |
官方文档
- Servlet HttpServletResponse Javadoc
- Spring FileCopyUtils
- MDN Content-Type
- MDN Content-Disposition
- Java try-with-resources
总结
这篇文章围绕外卖平台项目里的文件下载功能,把 ServletOutputStream 和 <img> 标签回显这条链路讲透了。核心就一句话:下载的本质是把磁盘文件以字节流的形式写回浏览器,和上传正好是反方向。
几个关键点值得记住:
- 两种下载形态 :附件形式弹保存框,内联形式直接在页面渲染。菜品图片走的是内联,靠
Content-Type告诉浏览器怎么处理。 - 前端不用发请求 :
<img>的src一旦赋值,浏览器自动发GET,这是HTML的固有行为。 - 后端返回
void:直接操作HttpServletResponse的输出流,避免Spring MVC二次序列化报错。 - 循环读写要传
len:write(bytes, 0, len)只写实际读到的字节,否则缓冲区残留会污染图片。 - 资源关闭别偷懒 :
try-with-resources比手动close更稳妥,异常时也不会泄漏文件描述符。 Content-Type别写死 :按文件后缀动态设置更严谨,PNG传成image/jpeg虽然多数浏览器能嗅探,但终究不专业。
一句话总结:上传是把文件从浏览器搬到磁盘,下载就是原路搬回去,中间靠 FileInputStream 读、ServletOutputStream 写,<img> 标签负责触发请求,Content-Type 决定浏览器怎么展示。