Java 项目实战: 交友项目 - FastDFS:入门案例

概述

前面已经讲清了 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 读,比走应用层中转快得多。

验证:跑起来才算接入成功

按顺序走一遍,每步都有可观察的输出:

  1. 跑测试类 :执行 FastDFSTest#testUpload,控制台打印出 group1/M00/... 和拼好的 URL。若抛 Connect timed out,先回查 tracker-list 和防火墙。
  2. 浏览器验证 :直接访问打印出的 URL(http://192.168.136.160:8888/group1/M00/...),能看到上传的那张图,说明 Storage 写入成功、Nginx 反代正常。如果 Nginx 那层没配好,可以先绕过它用 Storage 数据端口直连测试:telnet 192.168.136.160 23000 通了说明 Storage 端口可达,问题定位在 8888 的 Nginx 配置上。
  3. 业务衔接 :把 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 数据,防火墙缺一个都会卡在上传环节。

下一篇进入视频功能:业务概述,看这些上传能力如何撑起小视频的发布与播放链路。

相关推荐
apcipot_rain1 小时前
C++转Java日记(03)
java·开发语言·c++
皮皮林5511 小时前
Java实现微信扫码登录
java
布莱克6051 小时前
C++ 中 map 的 insert 与 emplace:key 已存在时会覆盖吗?
开发语言·数据结构·c++·算法·map
xys_6781 小时前
深挖C++:类和对象(中)
开发语言·c++
灿飞屋2 小时前
Rust 编程其实很简单
开发语言·rust
Yyyyyy~4 小时前
【Java】Day4Web基础知识
java·spring boot
MOONICK4 小时前
QT元数据
开发语言·qt
D e re k ZH4 小时前
C++ 系统学习手记:从语法细节到工程实践
开发语言·数据结构·c++·算法
灿飞屋5 小时前
Kotlin与Java虚拟机三:控制流
android·java·开发语言·后端·python·kotlin·安卓