文件上传X Spring File Storage

开源项目

# 一、简介

文件操作是平时开发工作中最常接触的一个功能,虽然难度不大,但确实有点繁琐。数据流的开闭、读取很容易出错,尤其是在对接一些云对象存储平台,接一个云平台写一大堆SDK代码,看起来乱糟糟的。X Spring File Storage工具宣称一行代码将文件存储到本地。

官网:https://x-file-storage.xuyanwu.cn/ (opens new window)

# 二、SpringBoot快速集成

# 引入依赖

pom.xml 引入本项目,这里默认是 SpringBoot 环境,其它环境参考 脱离 SpringBoot 单独使用 (opens new window)

<dependency>
    <groupId>org.dromara.x-file-storage</groupId>
    <artifactId>x-file-storage-spring</artifactId>
    <version>2.1.0</version>
</dependency>
1
2
3
4
5

再引入对应平台的依赖(以阿里云OSS为例)

<!-- 阿里云OSS -->
<dependency>
    <groupId>com.aliyun.oss</groupId>
    <artifactId>aliyun-sdk-oss</artifactId>
    <version>3.16.1</version>
</dependency>
1
2
3
4
5
6

# 增加配置

application.yml 配置文件中先添加以下基础配置

dromara:
  x-file-storage: #文件存储配置
    default-platform: aliyun-oss-1 #默认使用的存储平台
    thumbnail-suffix: ".min.jpg" #缩略图后缀,例如【.min.jpg】【.png】
    #对应平台的配置写在这里,注意缩进要对齐
1
2
3
4
5

再添加对应平台的配置

aliyun-oss:
  - platform: aliyun-oss-1 # 存储平台标识
    enable-storage: true  # 启用存储
    access-key: ??
    secret-key: ??
    end-point: ??
    bucket-name: ??
    domain: ?? # 访问域名,注意“/”结尾,例如:https://abc.oss-cn-shanghai.aliyuncs.com/
    base-path: test/ # 基础路径
local-plus:
  - platform: local-plus-1 # 存储平台标识
    enable-storage: true  #启用存储
    enable-access: true #启用访问(线上请使用 Nginx 配置,效率更高)
    domain: http://127.0.0.1:8080/file/ # 访问域名,例如:“http://127.0.0.1:8030/file/”,注意后面要和 path-patterns 保持一致,“/”结尾,本地存储建议使用相对路径,方便后期更换域名
    base-path: local-plus/ # 基础路径
    path-patterns: /file/** # 访问路径
    storage-path: D:/Temp/ # 存储路径
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17

更多参数请参考 org.dromara.x.file.storage.spring.SpringFileStorageProperties.SpringAliyunOssConfig

注意配置每个平台前面都有个-号,单个平台可以配置多个platform

# 编码

在启动类上加上@EnableFileStorage注解

@EnableFileStorage
@SpringBootApplication
public class SpringFileStorageTestApplication {

    public static void main(String[] args) {
        SpringApplication.run(SpringFileStorageTestApplication.class,args);
    }

}
1
2
3
4
5
6
7
8
9

# 三、开始使用

# 上传

# 基本上传方式

支持 File、MultipartFile、byte[]、InputStream、URL、URI、String、HttpServletRequest,大文件会自动分片上传。

// 直接上传
fileStorageService.of(file).upload();

// 如果要用 byte[]、InputStream、URL、URI、String 等方式上传,暂时无法获取 originalFilename 属性,最好手动设置
fileStorageService.of(inputStream).setOriginalFilename("a.jpg").upload();

// 上传到指定路径下
fileStorageService.of(file)
        .setPath("upload/") // 保存到相对路径下,为了方便管理,不需要可以不写
        .upload();

// 关联文件参数并上传
fileStorageService.of(file)
        .setObjectId("0")   // 关联对象id,为了方便管理,不需要可以不写
        .setObjectType("0") // 关联对象类型,为了方便管理,不需要可以不写
        .putAttr("role","admin") //保存一些属性,可以在切面、保存上传记录、自定义存储平台等地方获取使用,不需要可以不写
        .putAttr("username","007")
        .upload();

// 上传到指定的存储平台
fileStorageService.of(file)
        .setPlatform("aliyun-oss-1")    // 使用指定的存储平台
        .upload();

// 对图片进行处理并上传,有多个重载方法。图片处理使用的是 https://github.com/coobird/thumbnailator
fileStorageService.of(file)
        .setThumbnailSuffix(".jpg") //指定缩略图后缀,必须是 thumbnailator 支持的图片格式,默认使用全局的
        .setSaveThFilename("thabc") //指定缩略图的保存文件名,注意此文件名不含后缀,默认自动生成
        .image(img -> img.size(1000,1000))  // 将图片大小调整到 1000*1000
        .thumbnail(th -> th.size(200,200))  // 再生成一张 200*200 的缩略图
        .upload();

// 其它更多方法以实际 API 为准
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33

代码示例

@RestController
public class FileDetailController {

    @Autowired
    private FileStorageService fileStorageService;//注入实列

    /**
     * 上传文件
     */
    @PostMapping("/upload")
    public FileInfo upload(MultipartFile file) {
        return fileStorageService.of(file).upload();
    }
    
    /**
     * 上传文件,成功返回文件 url
     */
    @PostMapping("/upload2")
    public String upload2(MultipartFile file) {
        FileInfo fileInfo = fileStorageService.of(file)
                .setPath("upload/") //保存到相对路径下,为了方便管理,不需要可以不写
                .setObjectId("0")   //关联对象id,为了方便管理,不需要可以不写
                .setObjectType("0") //关联对象类型,为了方便管理,不需要可以不写
                .putAttr("role","admin") //保存一些属性,可以在切面、保存上传记录、自定义存储平台等地方获取使用,不需要可以不写
                .upload();  //将文件上传到对应地方
        return fileInfo == null ? "上传失败!" : fileInfo.getUrl();
    }

    /**
     * 上传图片,成功返回文件信息
     * 图片处理使用的是 https://github.com/coobird/thumbnailator
     */
    @PostMapping("/upload-image")
    public FileInfo uploadImage(MultipartFile file) {
        return fileStorageService.of(file)
                .image(img -> img.size(1000,1000))  //将图片大小调整到 1000*1000
                .thumbnail(th -> th.size(200,200))  //再生成一张 200*200 的缩略图
                .upload();
    }

    /**
     * 上传文件到指定存储平台,成功返回文件信息
     */
    @PostMapping("/upload-platform")
    public FileInfo uploadPlatform(MultipartFile file) {
        return fileStorageService.of(file)
                .setPlatform("aliyun-oss-1")    //使用指定的存储平台
                .upload();
    }

    /**
     * 直接读取 HttpServletRequest 中的文件进行上传,成功返回文件信息
     * 使用这种方式有些注意事项,请查看文档 基础功能-上传 章节
     */
    @PostMapping("/upload-request")
    public FileInfo uploadPlatform(HttpServletRequest request) {
        return fileStorageService.of(request).upload();
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59

FileInfo 结果集

{
    "id": null,
    "url": "http://127.0.0.1:8080/file/local-plus/6633532212876ccd660cd50f.xlsx",
    "size": 3750,
    "filename": "6633532212876ccd660cd50f.xlsx",
    "originalFilename": "yuanJiHuoDongDaoChu20240430052317.xlsx",
    "basePath": "local-plus/",
    "path": "",
    "ext": "xlsx",
    "contentType": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
    "platform": "local-plus-1",
    "thUrl": null,
    "thFilename": null,
    "thSize": null,
    "thContentType": null,
    "objectId": null,
    "objectType": null,
    "metadata": {},
    "userMetadata": {},
    "thMetadata": {},
    "thUserMetadata": {},
    "attr": {},
    "fileAcl": null,
    "thFileAcl": null,
    "hashInfo": {},
    "uploadId": null,
    "uploadStatus": null,
    "createTime": "2024-05-02T08:47:30.439+0000"
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29

# 直接上传HttpServletRequest

通过直接读取输入流进行上传,可以实现文件不落盘,边读取边上传,速度更快

需要先在配置文件中开启 multipart 懒加载,不然在 Controller 中拿到输入流是已经被读取过的

spring.servlet.multipart.resolve-lazily: true
1

编码示例

@RestController
public class FileDetailController {
    /**
     * 直接读取 HttpServletRequest 中的文件进行上传,成功返回文件信息
     */
    @PostMapping("/upload-request")
    public FileInfo uploadRequest(HttpServletRequest request) {
        return fileStorageService.of(request).upload();
    }

    /**
     * 这里演示了其它参数的获取方式
     */
    @PostMapping("/upload-request2")
    public FileInfo uploadRequest2(HttpServletRequest request) {
        HttpServletRequestFileWrapper wrapper = (HttpServletRequestFileWrapper) fileStorageService.wrapper(request);

        //获取指定参数,注意无法获取文件类型的参数
        String aaa = wrapper.getParameter("aaa");
        log.info("aaa:{}",aaa);

        //获取全部参数,注意无法获取文件类型的参数
        MultipartFormDataReader.MultipartFormData formData = wrapper.getMultipartFormData();
        Map<String, String[]> parameterMap = formData.getParameterMap();
        log.info("parameterMap:{}",parameterMap);
        
        //请求头还是通过 request 获取
        String auth = request.getHeader("Authorization");

        return fileStorageService.of(wrapper).upload();
    }

    /**
     * 注意这里是错误的用法,在方法上定义参数来接收请求中的参数,这样会导致输入流被提前读取
     */
    @PostMapping("/upload-request3")
    public FileInfo uploadRequest3(HttpServletRequest request,String aaa) {
        //包括但不限于下面这几种通过 request 获取参数的方式也是不行的,同样会导致输入流被提前读取
        String bbb = request.getParameter("bbb");
        Map<String, String[]> parameterMap = request.getParameterMap();
        
        //总之就是任何会导致输入流被提前读取的行为都是不可以的
        return fileStorageService.of(request).upload();
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45

# 分片上传

一般情况下,使用上面的上传方式就已经足够使用了,大文件会在内部自动进行分片上传。 但是还存在着不足,例如无法多线程并行上传、无法断点续传等,现在可以参考以下方式使用手动分片上传来实现这些功能。

# 手动分片上传-是否支持
//当前默认的存储平台支付支持手动分片上传
MultipartUploadSupportInfo supportInfo = fileStorageService.isSupportMultipartUpload();
supportInfo.getIsSupport();//是否支持手动分片上传,正常情况下判断此参数就行了
supportInfo.getIsSupportListParts();//是否支持列举已上传的分片
supportInfo.getIsSupportAbort();//是否支持取消上传点击复制错误复制成功
1
2
3
4
5
# 手动分片上传-初始化

又拍云 USS 比较特殊,需要传入分片大小,虽然已有默认值(1M),但为了方便使用还是单独设置一下(5MB)

//是否为又拍云 USS
boolean isUpyunUss = fileStorageService.getFileStorage() instanceof UpyunUssFileStorage;
//手动分片上传-初始化
FileInfo fileInfo = fileStorageService.initiateMultipartUpload()
        .setPath("test/")   // 保存到相对路径下,为了方便管理,不需要可以不写
        .putMetadata(isUpyunUss, "X-Upyun-Multi-Part-Size", String.valueOf(5 * 1024 * 1024))// 设置 Metadata,不需要可以不写
        .init();点击复制错误复制成功
1
2
3
4
5
6
7
# 手动分片上传-上传分片

这里支持多个线程同时上传,充分利用带宽

int partNumber = 1;//分片号。每一个上传的分片都有一个分片号,一般情况下取值范围是1~10000
byte[] bytes = FileUtil.readBytes("C:\\001.part");//分片数据,和基本的上传方式一样,也支持各种数据源
FilePartInfo filePartInfo = fileStorageService.uploadPart(fileInfo, partNumber, bytes, (long) bytes.length).upload();点击复制错误复制成功
1
2
3
# 手动分片上传-完成
fileStorageService.completeMultipartUpload(fileInfo).complete();点击复制错误复制成功
1
# 手动分片上传-列举已上传的分片
FilePartInfoList partList = fileStorageService.listParts(fileInfo).listParts();点击复制错误复制成功
1
# 手动分片上传-取消
fileStorageService.abortMultipartUpload(fileInfo).abort();
1

# 监听上传进度

// 方式一
fileStorageService.of(file).setProgressListener(progressSize ->
    System.out.println("已上传:" + progressSize)
).upload();

// 方式二
fileStorageService.of(file).setProgressListener((progressSize,allSize) ->
    System.out.println("已上传 " + progressSize + " 总大小" + (allSize == null ? "未知" : allSize))
).upload();

// 方式三
fileStorageService.of(file).setProgressListener(new ProgressListener() {
    @Override
    public void start() {
    System.out.println("上传开始");
    }

    @Override
    public void progress(long progressSize,Long allSize) {
        System.out.println("已上传 " + progressSize + " 总大小" + (allSize == null ? "未知" : allSize));
    }

    @Override
    public void finish() {
        System.out.println("上传结束");
    }
}).upload();
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27

# 保存上传记录

# 下载

# 下载方式

// 获取文件信息
FileInfo fileInfo = fileStorageService.getFileInfoByUrl("https://file.abc.com/test/a.jpg");

// 下载为字节数组
byte[] bytes = fileStorageService.download(fileInfo).bytes();

// 下载到文件
fileStorageService.download(fileInfo).file("C:\\a.jpg");

// 下载到 OutputStream 中
ByteArrayOutputStream out = new ByteArrayOutputStream();
fileStorageService.download(fileInfo).outputStream(out);

// 获取 InputStream 手动处理
fileStorageService.download(fileInfo).inputStream(in -> {
    //TODO 读取 InputStream
});

// 直接通过文件信息中的 url 下载,省去手动查询文件信息记录的过程
fileStorageService.download("https://file.abc.com/test/a.jpg").file("C:\\a.jpg");

// 下载缩略图
fileStorageService.downloadTh(fileInfo).file("C:\\th.jpg");
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23

# 监听下载进度

// 方式一
fileStorageService.download(fileInfo).setProgressListener(progressSize ->
        System.out.println("已下载:" + progressSize)
).file("C:\\a.jpg");
        
// 方式二
fileStorageService.download(fileInfo).setProgressListener((progressSize,allSize) ->
        System.out.println("已下载 " + progressSize + " 总大小" + allSize)
).file("C:\\a.jpg");

// 方式三
fileStorageService.download(fileInfo).setProgressListener(new ProgressListener() {
    @Override
    public void start() {
        System.out.println("下载开始");
    }

    @Override
    public void progress(long progressSize,Long allSize) {
        System.out.println("已下载 " + progressSize + " 总大小" + allSize);
    }

    @Override
    public void finish() {
        System.out.println("下载结束");
    }
}).file("C:\\a.jpg");
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27

# 删除

//获取文件信息
FileInfo fileInfo = fileStorageService.getFileInfoByUrl("https://file.abc.com/test/a.jpg");

//直接删除
fileStorageService.delete(fileInfo);

//条件删除
fileStorageService.delete(fileInfo,info -> {
    //TODO 检查是否满足删除条件
    return true;
});

//直接通过文件信息中的 url 删除,省去手动查询文件信息记录的过程
fileStorageService.delete("https://file.abc.com/test/a.jpg");
1
2
3
4
5
6
7
8
9
10
11
12
13
14

# 判断文件是否存在

//获取文件信息
FileInfo fileInfo = fileStorageService.getFileInfoByUrl("https://file.abc.com/test/a.jpg");

//判断文件是否存在
boolean exists = fileStorageService.exists(fileInfo);

//直接通过文件信息中的 url 判断文件是否存在,省去手动查询文件信息记录的过程
boolean exists2 = fileStorageService.exists("https://file.abc.com/test/a.jpg");
1
2
3
4
5
6
7
8

# 复制

复制分为 同存储平台复制跨存储平台复制,默认会自动选择

同存储平台复制 直接调用每个存储平台提供的复制方法,速度快,不额外占用网络及本地硬盘空间

跨存储平台复制 是通过先下载再上传的方式实现的,正常情况下上传下载是同时进行的,不会过多占用内存,不占用硬盘空间,但是会占用网络带宽,速度受网络影响

FTPSFTPFastDFS 不支持 同存储平台复制 ,默认会自动使用 跨存储平台复制

// 上传源文件
FileInfo fileInfo = fileStorageService.of(new File("D:\\Desktop\\a.png")).thumbnail().upload();

// 复制到 copy 这个路径下(同存储平台复制)
FileInfo destFileInfo = fileStorageService.copy(fileInfo)
        .setPath("copy/")
        .copy();

//复制到同路径下不同文件名(同存储平台复制)
FileInfo destFileInfo = fileStorageService.copy(fileInfo)
        .setFilename("aaaCopy." + FileNameUtil.extName(fileInfo.getFilename()))
        .setThFilename("aaaCopy.min." + FileNameUtil.extName(fileInfo.getThFilename()))
        .copy();

//复制到其它存储平台(跨存储平台复制)
FileInfo destFileInfo = fileStorageService.copy(fileInfo)
        .setPlatform("local-plus-1")
        .setProgressListener((progressSize, allSize) ->
            log.info("文件复制进度:{} {}%", progressSize, progressSize * 100 / allSize))
        .copy();

//强制使用跨存储平台复制
FileInfo destFileInfo = fileStorageService.copy(fileInfo)
        .setCopyMode(Constant.CopyMode.CROSS)
        .setPath("copy/")
        .copy();

//是否支持同存储平台复制
boolean supportSameCopy = fileStorageService.isSupportSameCopy("aliyun-oss-1");
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29

# 移动(重命名)

移动分为 同存储平台移动跨存储平台移动,默认会自动选择

同存储平台移动 直接调用每个存储平台提供的移动方法,速度快,不额外占用网络及本地硬盘空间

跨存储平台移动 是通过先复制再删除源文件的方式实现的,跨存储平台复制 时速度受网络影响,详情见 复制 (opens new window) 章节

本地FTPSFTPWebDAV七牛云 Kodo又拍云 USS 支持 同存储平台移动 ,其它不支持的存储平台默认会自动使用 跨存储平台移动

// 上传源文件
FileInfo fileInfo = fileStorageService.of(new File("D:\\Desktop\\a.png")).thumbnail().upload();

// 移动到 move 这个路径下(同存储平台移动)
FileInfo destFileInfo = fileStorageService.move(fileInfo)
        .setPath("move/")
        .move();

//移动到同路径下不同文件名(同存储平台移动)
FileInfo destFileInfo = fileStorageService.move(fileInfo)
        .setFilename("aaaMove." + FileNameUtil.extName(fileInfo.getFilename()))
        .setThFilename("aaaMove.min." + FileNameUtil.extName(fileInfo.getThFilename()))
        .move();

//移动到其它存储平台(跨存储平台移动)
FileInfo destFileInfo = fileStorageService.move(fileInfo)
        .setPlatform("local-plus-1")
        .setProgressListener((progressSize, allSize) ->
            log.info("文件移动进度:{} {}%", progressSize, progressSize * 100 / allSize))
        .move();

//强制使用跨存储平台移动
FileInfo destFileInfo = fileStorageService.move(fileInfo)
        .setMoveMode(Constant.MoveMode.CROSS)
        .setPath("move/")
        .move();

//是否支持同存储平台移动
boolean supportSameMove = fileStorageService.isSupportSameMove("aliyun-oss-1");
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29

上面代码可访问博主的仓库:springboot_chowder/springboot_x_file_storage at main · Daneliya/springboot_chowder (github.com) (opens new window)

# 四、其它功能

X FIle Storage还有访问策略、签名生成、Metadata 和 UserMetadata上传、存储平台动态配置及自定义Client、文件适配器、MIME类型识别、哈希计算、切面增强等功能,详细可参考官方文档。

最近修改于: 2024/5/7 00:30:22
和宇宙温柔的关联
房东的猫