Java 项目实战: 外卖平台-文件下载与ServletOutputStream回写浏览器

文件下载、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

内联形式下载

文件直接在浏览器页面中打开。图片、PDFTXT 这类浏览器能解析的格式,默认就是内联。
#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 UIHTTP 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/jpegimage/pngimage/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 FileInputStreamFileNotFoundException,被 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 的资源

官方文档

总结

这篇文章围绕外卖平台项目里的文件下载功能,把 ServletOutputStream<img> 标签回显这条链路讲透了。核心就一句话:下载的本质是把磁盘文件以字节流的形式写回浏览器,和上传正好是反方向。

几个关键点值得记住:

  • 两种下载形态 :附件形式弹保存框,内联形式直接在页面渲染。菜品图片走的是内联,靠 Content-Type 告诉浏览器怎么处理。
  • 前端不用发请求<img>src 一旦赋值,浏览器自动发 GET,这是 HTML 的固有行为。
  • 后端返回 void :直接操作 HttpServletResponse 的输出流,避免 Spring MVC 二次序列化报错。
  • 循环读写要传 lenwrite(bytes, 0, len) 只写实际读到的字节,否则缓冲区残留会污染图片。
  • 资源关闭别偷懒try-with-resources 比手动 close 更稳妥,异常时也不会泄漏文件描述符。
  • Content-Type 别写死 :按文件后缀动态设置更严谨,PNG 传成 image/jpeg 虽然多数浏览器能嗅探,但终究不专业。

一句话总结:上传是把文件从浏览器搬到磁盘,下载就是原路搬回去,中间靠 FileInputStream 读、ServletOutputStream 写,<img> 标签负责触发请求,Content-Type 决定浏览器怎么展示。

相关推荐
琥珀色糖1 小时前
SimlpeHttp
linux·服务器
第十人i2 小时前
Linux 日志管理系统:journald 配置与优化
linux·服务器
ITxiaobing20233 小时前
IP 定位服务选型指南:从准确率到工程落地的技术考察
linux·服务器·网络
wuyk5553 小时前
【Socket 进阶之路】第 9 章 Linux 网络服务量产稳定性优化|心跳保活、TIME_WAIT、SO_LINGER、内存池、断线重连、完整异常防护框架
linux·服务器·开发语言·网络·物联网
Julien20043 小时前
Docker 容器存储原理(一)
linux·运维·服务器·ssh·学习方法
筝筝ba3 小时前
GUI 测试之只读用户恢复BMC出厂设置
服务器
Wang's Blog4 小时前
Java 项目实战: 外卖平台-新增菜品DishDto双表插入与事务控制
服务器·项目开发
凤年徐4 小时前
C++ 仿 muduo 高并发服务器:日志模块
linux·服务器
狂师5 小时前
cloudflared,不需要服务器和公网 IP 的免费内网穿透,一条命令上手
服务器·程序员·开源