概述
前面已经讲清了 FastDFS 的 Tracker/Storage 结构与工作原理,本文解决最后一公里:Java 项目怎么把一个文件真正传上去。接入只需三步,引依赖、写配置、注入客户端调用 uploadFile,核心代码就一行。
纲要
- 接入前的环境确认(docker-compose 启动 Tracker/Storage,两个关键地址)
- 接入三步
- 引入依赖:
com.github.tobato:fastdfs-client:1.26.7(社区纯 Java 客户端) application.yml的fdfs配置段:逐项说明- 注入
FastFileStorageClient与FdfsWebServer(自动装配,无需手写配置类) - 上传代码:
FastDFSTest逐段拆解,StorePath与getFullPath() - 上传结果的三种用法:fullPath 存库、拼 URL 访问、deleteFile 删除
- 下载:
downloadFile点到为止 - 验证方式:跑测试 → 打印 fullPath → 浏览器访问
- 常见坑:Tracker 地址写错、防火墙双端口、fileExt 无点、InputStream 复用、fullPath 与 URL 混淆
- 常见误解澄清表、官方文档、总结
环境:先把服务跑起来
项目中的中间件全部用 docker-compose 编排,FastDFS 也不例外。示例环境在虚拟机的 /root/docker-file/fastdfs/ 目录下执行:
shell
# 进入 fastdfs 编排目录
cd /root/docker-file/fastdfs/
# 启动(tracker + storage 两个容器)
docker-compose up -d
# 查看容器是否处于 Up 状态
docker ps -a
FastDFS 占虚拟机资源较多,如果容器启动后随即退出,多半是内存不足,docker-compose restart 重启或扩大虚拟机内存即可。
环境跑起来后有两个地址,后面配置全靠它们:
| 角色 | 地址 | 用途 |
|---|---|---|
| Tracker 调度服务器 | 192.168.136.160:22122 |
Java 客户端连接它,获取可用的 Storage |
| Storage 的 Nginx 入口 | http://192.168.136.160:8888/ |
浏览器/HTTP 访问已上传文件的入口 |
示例环境把 Tracker 和 Storage 部署在同一台机器;企业里它们是各自独立的服务器,客户端只需要知道 Tracker 地址,不需要直连 Storage。
接入只动 mf-app-server 里的三个位置:
tree
mf-app-server
├── pom.xml # ① 引入 fastdfs-client 依赖
└── src
├── main
│ ├── java/com/mf/server
│ │ └── AppServerApplication.java # ③ 启动类,测试类以它为上下文
│ └── resources
│ └── application.yml # ② fdfs 配置段
└── test
└── java/com/wb/test
└── FastDFSTest.java # ③ 注入 client 的上传测试
接入的整体流程如下,第一步(引依赖)到第三步(调 API)对应下文三个小节:
#mermaid-svg-0oss44CkqUp9Pmlj{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-0oss44CkqUp9Pmlj .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-0oss44CkqUp9Pmlj .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-0oss44CkqUp9Pmlj .error-icon{fill:#552222;}#mermaid-svg-0oss44CkqUp9Pmlj .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-0oss44CkqUp9Pmlj .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-0oss44CkqUp9Pmlj .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-0oss44CkqUp9Pmlj .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-0oss44CkqUp9Pmlj .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-0oss44CkqUp9Pmlj .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-0oss44CkqUp9Pmlj .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-0oss44CkqUp9Pmlj .marker{fill:#333333;stroke:#333333;}#mermaid-svg-0oss44CkqUp9Pmlj .marker.cross{stroke:#333333;}#mermaid-svg-0oss44CkqUp9Pmlj svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-0oss44CkqUp9Pmlj p{margin:0;}#mermaid-svg-0oss44CkqUp9Pmlj .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-0oss44CkqUp9Pmlj .cluster-label text{fill:#333;}#mermaid-svg-0oss44CkqUp9Pmlj .cluster-label span{color:#333;}#mermaid-svg-0oss44CkqUp9Pmlj .cluster-label span p{background-color:transparent;}#mermaid-svg-0oss44CkqUp9Pmlj .label text,#mermaid-svg-0oss44CkqUp9Pmlj span{fill:#333;color:#333;}#mermaid-svg-0oss44CkqUp9Pmlj .node rect,#mermaid-svg-0oss44CkqUp9Pmlj .node circle,#mermaid-svg-0oss44CkqUp9Pmlj .node ellipse,#mermaid-svg-0oss44CkqUp9Pmlj .node polygon,#mermaid-svg-0oss44CkqUp9Pmlj .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-0oss44CkqUp9Pmlj .rough-node .label text,#mermaid-svg-0oss44CkqUp9Pmlj .node .label text,#mermaid-svg-0oss44CkqUp9Pmlj .image-shape .label,#mermaid-svg-0oss44CkqUp9Pmlj .icon-shape .label{text-anchor:middle;}#mermaid-svg-0oss44CkqUp9Pmlj .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-0oss44CkqUp9Pmlj .rough-node .label,#mermaid-svg-0oss44CkqUp9Pmlj .node .label,#mermaid-svg-0oss44CkqUp9Pmlj .image-shape .label,#mermaid-svg-0oss44CkqUp9Pmlj .icon-shape .label{text-align:center;}#mermaid-svg-0oss44CkqUp9Pmlj .node.clickable{cursor:pointer;}#mermaid-svg-0oss44CkqUp9Pmlj .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-0oss44CkqUp9Pmlj .arrowheadPath{fill:#333333;}#mermaid-svg-0oss44CkqUp9Pmlj .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-0oss44CkqUp9Pmlj .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-0oss44CkqUp9Pmlj .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-0oss44CkqUp9Pmlj .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-0oss44CkqUp9Pmlj .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-0oss44CkqUp9Pmlj .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-0oss44CkqUp9Pmlj .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-0oss44CkqUp9Pmlj .cluster text{fill:#333;}#mermaid-svg-0oss44CkqUp9Pmlj .cluster span{color:#333;}#mermaid-svg-0oss44CkqUp9Pmlj 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-0oss44CkqUp9Pmlj .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-0oss44CkqUp9Pmlj rect.text{fill:none;stroke-width:0;}#mermaid-svg-0oss44CkqUp9Pmlj .icon-shape,#mermaid-svg-0oss44CkqUp9Pmlj .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-0oss44CkqUp9Pmlj .icon-shape p,#mermaid-svg-0oss44CkqUp9Pmlj .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-0oss44CkqUp9Pmlj .icon-shape .label rect,#mermaid-svg-0oss44CkqUp9Pmlj .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-0oss44CkqUp9Pmlj .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-0oss44CkqUp9Pmlj .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-0oss44CkqUp9Pmlj :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 引入 fastdfs-client 依赖
application.yml 配置 fdfs 段
注入 FastFileStorageClient
uploadFile 上传文件
StorePath 返回存储路径
getFullPath 拼出 fullPath
存库 / 拼 URL 访问 / 删除
第一步:引入依赖
pom.xml 依赖已经在 mf-app-server 中配置好(版本 1.26.7,并排除了自带的 logback,避免与 Spring Boot 的日志体系冲突):
xml
<dependency>
<groupId>com.github.tobato</groupId>
<artifactId>fastdfs-client</artifactId>
<version>1.26.7</version>
<exclusions>
<exclusion>
<groupId>ch.qos.logback</groupId>
<artifactId>logback-classic</artifactId>
</exclusion>
</exclusions>
</dependency>
FastDFS 官方提供的客户端是 C 语言实现的,Java 项目直接用很别扭。com.github.tobato:fastdfs-client 是社区维护的纯 Java 客户端 ,而且按 Spring Boot 的方式做了封装:自带连接池管理、支持 fdfs 前缀的配置绑定。项目这种 Spring Boot 项目选它而不是官方 C 客户端的 JNI 封装,集成成本低得多。
第二步:application.yml 配置
在 mf-app-server 的 application.yml 中添加 fdfs 配置段(与工程实际内容一致):
yaml
# 分布式文件系统FDFS配置
# ===================================================================
fdfs:
so-timeout: 1500 # 读取超时时间(毫秒)
connect-timeout: 600 # 连接超时时间(毫秒)
#缩略图生成参数
thumb-image:
width: 150
height: 150
#TrackerList参数,支持多个
tracker-list: 192.168.136.160:22122
web-server-url: http://192.168.136.160:8888/
逐项说明:
| 配置项 | 实读值 | 作用 |
|---|---|---|
so-timeout |
1500 |
Socket 读超时,单位毫秒。上传大文件或网络慢时读超时会先爆,生产可适当调大 |
connect-timeout |
600 |
建立连接的超时,单位毫秒。连不上 Tracker 时 600ms 内就报错,别等操作系统默认的超时 |
thumb-image.width/height |
150 / 150 |
生成缩略图的宽高(调用上传缩略图 API 时生效,如 uploadImageAndCrtThumbImage) |
tracker-list |
192.168.136.160:22122 |
最关键的配置。客户端连 Tracker 的地址列表,支持配置多个做容灾;上传、下载都从这里获取 Storage |
web-server-url |
http://192.168.136.160:8888/ |
Storage 前面 Nginx 的访问地址。代码里通过 FdfsWebServer 注入它,用于把存储路径拼成可访问的 URL |
两个超时值一个管「读得慢」(so-timeout),一个管「连不上」(connect-timeout),排查问题时先看是哪一类报错,就能定位是网络不通还是服务端处理慢。
第三步:注入客户端,直接用
tobato 客户端是 Spring Boot 风格的 starter:引入依赖 + fdfs 前缀配置后,会自动装配好 FastFileStorageClient(上传/下载)和 FdfsWebServer(读取 web-server-url 配置)。工程里没有任何手写的 FastDFS 配置类 ,实读整个 mf-app-server,没有 @Import(FdfsAutoConfig.class),也没有自定义配置类,@Autowired 直接注入即可:
java
@Autowired
private FastFileStorageClient client; // 文件上传、下载
@Autowired
private FdfsWebServer webServer; // 读取 web-server-url,拼接访问地址
上传代码:FastDFSTest 逐段拆解
测试类在 mf-app-server/src/test/java/com/wb/test/FastDFSTest.java,以下是与工程逐行一致的完整代码(讲义里写的类名是 TestFastDFS、启动类是 mfServerApplication,工程实际是 FastDFSTest、AppServerApplication,以工程为准):
java
package com.wb.test;
import com.github.tobato.fastdfs.domain.conn.FdfsWebServer;
import com.github.tobato.fastdfs.domain.fdfs.StorePath;
import com.github.tobato.fastdfs.service.FastFileStorageClient;
import com.mf.server.AppServerApplication;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.junit4.SpringRunner;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileNotFoundException;
@RunWith(SpringRunner.class)
@SpringBootTest(classes = AppServerApplication.class)
public class FastDFSTest {
/**
* 测试FastDFS的文件上传
*/
//用于文件上传或者下载
@Autowired
private FastFileStorageClient client;
@Autowired
private FdfsWebServer webServer;
@Test
public void testUpload() throws FileNotFoundException {
//1、指定文件
File file = new File("D:\\1.jpg");
//2、文件上传
StorePath path = client.uploadFile(new FileInputStream(file), file.length(), "jpg", null);
//3、拼接请求路径
String fullPath = path.getFullPath();
System.out.println(fullPath); //group1/M00/00/00/xxx.jpg
String url = webServer.getWebServerUrl() + fullPath;
System.out.println(url);
}
}
拆开看三步:
① 指定文件 :new File("D:\\1.jpg") 构建本地文件对象,再用 FileInputStream 打开输入流。实际项目里文件多来自 Controller 的 MultipartFile,思路一样,拿到 InputStream 和文件大小就能传,后面业务代码里就是这么用的。
② 文件上传,核心只有这一行:
java
StorePath path = client.uploadFile(new FileInputStream(file), file.length(), "jpg", null);
四个参数逐个说:
| 参数 | 传入值 | 说明 |
|---|---|---|
InputStream |
new FileInputStream(file) |
文件输入流,客户端把流里的内容写给 Storage |
fileSize |
file.length() |
文件长度(字节),必须与流内容一致,否则传输截断 |
fileExt |
"jpg" |
文件后缀,不带点 。写 ".jpg" 存出来的文件名会带两个点 |
metaData |
null |
文件元数据,SetMetaData 集合,比如图片的长宽、拍摄作者等自定义键值对。业务不传就写 null |
方法内部做的事比看上去多:客户端先连 tracker-list 里的 Tracker,Tracker 按调度策略选中一个 Storage 并返回其地址,客户端再把流写到那台 Storage 上,上一篇FastDFS:工作原理讲的就是这段流程,这里不再展开。
③ 处理返回值 :uploadFile 返回 StorePath 对象,封装了 Storage 分配的文件 ID,三个取值方法要分清:
| 方法 | 返回示例 | 含义 |
|---|---|---|
getGroup() |
group1 |
存储组名 |
getPath() |
M00/00/00/CgAIC2Gx...jpg |
虚拟磁盘路径 + 两级数据目录 + 生成的文件名 |
getFullPath() |
group1/M00/00/00/CgAIC2Gx...jpg |
组名 + 路径,即完整文件 ID,存库用它 |
打印结果(实测形态):
text
group1/M00/00/00/CgAIC2GxLiKAUO1_AANdF3gQo0835.jpg
http://192.168.136.160:8888/group1/M00/00/00/CgAIC2GxLiKAUO1_AANdF3gQo0835.jpg
上传结果的三种用法
拿到 StorePath 之后,fullPath 在业务里通常有三种去向:
① 存库 。业务表存 group1/M00/... 这种相对形式,而不是完整 URL,好处是日后 Nginx 域名迁移、换 CDN 时只改拼接逻辑,不动数据。后续发布小视频时,就是把 videoUrl/picUrl 对应的文件 ID 存进 MongoDB 的 Video 集合。
② 拼 URL 访问 。webServer.getWebServerUrl() + path.getFullPath() 得到 http://192.168.136.160:8888/group1/M00/...,浏览器直接打开就能看到文件。能这样访问是因为 Storage 前面挂了 Nginx 做反向代理:请求打到 8888 端口,Nginx 按 URL 里的 group 和路径去 Storage 磁盘上取文件返回。
③ 删除。拿着 fullPath 就能删:
java
client.deleteFile("group1/M00/00/00/CgAIC2GxLiKAUO1_AANdF3gQo0835.jpg");
传给 deleteFile 的是 fullPath(含 group) ,不是 URL,也不是 getPath() 的结果。
下载:一句话带过
java
byte[] bytes = client.downloadFile("group1", "M00/00/00/CgAIC2GxLiKAUO1_AANdF3gQo0835.jpg");
downloadFile(group, path) 返回字节数组,走的是「客户端 → Tracker → Storage」的下载流程。实际业务里很少在 Java 端下载,前端直接拿拼好的 URL 从 Nginx 读,比走应用层中转快得多。
验证:跑起来才算接入成功
按顺序走一遍,每步都有可观察的输出:
- 跑测试类 :执行
FastDFSTest#testUpload,控制台打印出group1/M00/...和拼好的 URL。若抛Connect timed out,先回查tracker-list和防火墙。 - 浏览器验证 :直接访问打印出的 URL(
http://192.168.136.160:8888/group1/M00/...),能看到上传的那张图,说明 Storage 写入成功、Nginx 反代正常。如果 Nginx 那层没配好,可以先绕过它用 Storage 数据端口直连测试:telnet 192.168.136.160 23000通了说明 Storage 端口可达,问题定位在 8888 的 Nginx 配置上。 - 业务衔接 :把 fullPath 存进 MongoDB(后续小视频业务用
mongosh查Video集合时,videoUrl字段里存的就是这类文件 ID 拼出的地址),列表接口返回给前端后,<video>标签直接加载 URL 播放。
常见坑
| 坑 | 现象 | 原因与解法 |
|---|---|---|
| Tracker 地址写错 | connect timed out / Can't create connection to 192.168.x.x:22122 |
tracker-list 里的 IP/端口与实际部署不符;docker ps -a 确认 Tracker 容器和映射端口 |
| 防火墙只放行 22122 | 连接 Tracker 正常,上传时报错或卡死 | FastDFS 有两个端口:22122(Tracker)+ 23000(Storage 数据端口),后者常被漏掉,两个都要放行 |
fileExt 带点 |
文件名变成 xxx..jpg |
uploadFile 的第三个参数传 "jpg",不带点 |
| InputStream 复用 | 第二次上传内容为空或报错 | 流只能读一次,读完就到末尾。每个 uploadFile 调用都要开新流(MultipartFile 可先 getBytes() 缓存再按需重开) |
| fullPath 与 URL 混淆 | 把 http://...:8888/group1/... 整串存库,删除时传给 deleteFile 报 Invalid format |
存库存 getFullPath()(不含域名),拼 URL 只在返回前端时做;删除接口同样只认 fullPath |
| 文件大小传 0 | 上传成功但文件打不开 | fileSize 必须传 file.length()(或 MultipartFile.getSize()),写死 0 会让 Storage 只落一个空文件 |
常见误解澄清
| 误解 | 澄清 |
|---|---|
「需要写一个配置类注册 FastFileStorageClient」 |
不用。tobato 客户端自带 Spring Boot 自动装配,工程实读确认没有 @Import(FdfsAutoConfig.class),注入即用 |
| 「上传是客户端直接把文件发给 Tracker」 | Tracker 只做调度,文件内容写到 Storage 上;Tracker 不落盘 |
「StorePath.getPath() 就是完整路径」 |
getPath() 不含组名,getFullPath() 才是 group1/M00/... 的完整文件 ID |
「web-server-url 配置的是 Storage 的 23000 端口」 |
它配的是 Nginx 的 HTTP 端口(8888),供浏览器访问文件用,与内部通信端口无关 |
| 「metaData 必须传」 | 元数据是可选的自定义键值对,业务不用就传 null |
| 「删除文件传 URL」 | deleteFile 只认 fullPath(group1/M00/...),URL 是访问层的产物 |
API 速览
| API | 签名 | 用途 |
|---|---|---|
| 上传 | uploadFile(InputStream, long fileSize, String fileExt, Set<MetaData>) → StorePath |
通用文件上传 |
| 取完整路径 | storePath.getFullPath() |
返回 group1/M00/...,存库/删除用它 |
| 拼访问地址 | webServer.getWebServerUrl() + fullPath |
得到 Nginx 层的 HTTP URL |
| 删除 | deleteFile(String fullPath) |
按 fullPath 删除文件 |
| 下载 | downloadFile(String groupName, String path) → byte[] |
字节流下载,业务少用 |
官方文档
总结
- 接入三步:
fastdfs-client:1.26.7依赖 →fdfs配置段(Tracker 地址 + Nginx 地址是核心两项)→ 注入FastFileStorageClient,无需手写配置类。 - 核心代码一行:
uploadFile(InputStream, size, ext, metaData),返回StorePath,getFullPath()拿完整文件 ID。 - full path 三种去向:存库、拼 URL 给前端、
deleteFile删除;存库永远存 fullPath 而不是 URL。 - 排障先分清两个端口:22122 连 Tracker、23000 走 Storage 数据,防火墙缺一个都会卡在上传环节。
下一篇进入视频功能:业务概述,看这些上传能力如何撑起小视频的发布与播放链路。