===============================================
ccb_warlock
===============================================
今年1月临时接手了一个spring boot项目的开发,其中包含了文件上传和获取的功能。但是发现原功能通过直接压缩文件成字符串然后存入数据库来实现,于是我准备改写存入FTP来优化。但是发现查了很多文章,几乎找不到一篇代码结构清晰且能跑的起来的代码片段,于是我整理了这篇记录供需要的人参考。
这里我只实现文件的上传功能,文件的下载因为是内网项目,所以我还是采取了nginx代理FTP的的方式直接通过url来访问。
一、部署ftp
二、pom引用
这里我使用的是java中普遍操作ftp的轮子org.apache.commons.net。
<dependency>
<groupId>commons-net</groupId>
<artifactId>commons-net</artifactId>
<version>3.8.0</version>
</dependency>
三、application.yaml增加配置信息
将FTP的配置记录到配置文件中。
ftp:
# ftp服务的地址
host: 127.0.0.1
# 连接端口
port: 38021
# 用户名
username: myftp
# 密码
password: 123456
# 模式(PORT.主动模式,PASV.被动模式)
mode: PASV
# http访问的路径前缀
url: http://127.0.0.1:8001/ftp
四、工具类封装
为了方便后续调用,我抽象了ftp操作的方法集成到了一个独立的工具类(FTPUtil)。
1 package com.example.demo.utils;
2
3 import lombok.extern.slf4j.Slf4j;
4 import org.apache.commons.lang3.StringUtils;
5 import org.apache.commons.net.ftp.FTP;
6 import org.apache.commons.net.ftp.FTPClient;
7 import org.apache.commons.net.ftp.FTPReply;
8 import org.springframework.beans.factory.annotation.Value;
9 import org.springframework.stereotype.Component;
10 import org.springframework.web.multipart.MultipartFile;
11
12 import java.io.IOException;
13 import java.io.InputStream;
14
15 @Slf4j
16 @Component
17 public class FTPUtil {
18 private static String host;
19 private static int port;
20 private static String userName;
21 private static String password;
22 private static String mode;
23
24 @Value("${ftp.host:127.0.0.1}")
25 private void setHost(String host) {
26 FTPUtil.host = host;
27 }
28
29 @Value("${ftp.port:21}")
30 private void setPort(int port){
31 FTPUtil.port = port;
32 }
33
34 @Value("${ftp.username:''}")
35 private void setUserName(String userName){
36 FTPUtil.userName = userName;
37 }
38
39 @Value("${ftp.password:''}")
40 private void setPassword(String password){
41 FTPUtil.password = password;
42 }
43
44 @Value("${ftp.mode:PASV}")
45 private void setMode(String mode){
46 FTPUtil.mode = mode;
47 }
48
49 private static FTPClient getInstance(String workingDirectory) {
50 FTPClient ftpClient = new FTPClient();
51 ftpClient.setControlEncoding("UTF-8");
52
53 try{
54 ftpClient.connect(host, port);
55 ftpClient.login(userName, password);
56
57 int replyCode = ftpClient.getReplyCode();
58
59 if(!FTPReply.isPositiveCompletion(replyCode)){
60 log.error("FTP服务({}:{})连接失败。", host, port);
61 throw new Exception("FTP服务连接失败");
62 }
63 log.info("FTP服务({}:{})连接成功。", host, port);
64
65 if("PORT".equals(mode)){
66 ftpClient.enterLocalActiveMode();
67 }
68 else{
69 ftpClient.enterLocalPassiveMode();
70 }
71
72 ftpClient.setFileType(FTP.BINARY_FILE_TYPE);
73 changeWorkingDirectory(ftpClient, workingDirectory);
74 }
75 catch(Exception e){
76 e.printStackTrace();
77 }
78
79 return ftpClient;
80 }
81
82 private static void changeWorkingDirectory(FTPClient ftpClient, String workingDirectory) throws IOException {
83 String[] directories = workingDirectory.split("/");
84
85 for(String directory : directories){
86 if(StringUtils.isBlank(directory)){
87 continue;
88 }
89
90 if(ftpClient.changeWorkingDirectory(directory)){
91 continue;
92 }
93
94 ftpClient.makeDirectory(directory);
95 ftpClient.changeWorkingDirectory(directory);
96 }
97 }
98
99 private static void close(FTPClient client){
100 if(null == client){
101 return;
102 }
103
104 try{
105 client.logout();
106 }
107 catch(Exception e){
108 log.error("FTP退出登录失败。异常信息:{}", e.getMessage());
109 }
110 finally {
111 if(client.isConnected()){
112 try{
113 client.disconnect();
114 log.info("FTP断开连接成功。");
115 }
116 catch(Exception e){
117 log.error("FTP断开连接失败。异常信息:{}", e.getMessage());
118 }
119 }
120 }
121 }
122
123 public static void upload(String workingDirectory, String fileName, MultipartFile file) throws Exception {
124 InputStream inputStream = file.getInputStream();
125 FTPClient client = getInstance(workingDirectory);
126
127 if (client.storeFile(fileName, inputStream)) {
128 log.info("上传文件{}成功。", fileName);
129 }
130 else{
131 log.error("上传文件{}失败({})。", fileName, client.getReplyString());
132 }
133
134 close(client);
135 inputStream.close();
136 }
137
138 }
五、调用
为了方便呈现,这里设计了一个post接口方便测试
1)服务(IFileService、FileServiceImpl)
IFileService
1 package com.example.demo.api.interfaces;
2
3 import org.springframework.web.multipart.MultipartFile;
4
5 public interface IFileService {
6
7 void uploadFile(long companyId, MultipartFile file) throws Exception;
8
9 }
FileServiceImpl
package com.example.demo.domain.service;
import org.apache.commons.lang3.StringUtils;
import com.example.demo.api.interfaces.IFileService;
import com.example.demo.utils.FTPUtil;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;
import java.io.InputStream;
import java.util.Calendar;
import java.util.List;
import java.util.UUID;
@Service
public class FileServiceImpl implements IFileService {
@Value("${ftp:url:''}")
private String ftpUrl;
@Override
public String uploadFile(long companyId, MultipartFile file) throws Exception {
String fileName = getUuidFileName(file.getOriginalFilename());
//无多级目录
FTPUtil.upload("", fileName, file);
return ftpUrl + "/" + fileName;
//存到company/{companyId}/images路径下
//FTPUtil.upload("company/" + companyId + "/images", fileName, file);
//return ftpUrl + "/company/" + companyId + "/images/" + fileName;
}
private String getUuidFileName(String originalFileName){
String uuid = getUuid();
if(StringUtils.isBlank(originalFileName)){
return uuid;
}
int i = originalFileName.lastIndexOf('.');
return -1 == i ? uuid : uuid + originalFileName.substring(i);
}
private String getUuid(){
String uuid = UUID.randomUUID().toString();
return uuid.replace("-", "");
}
}
2)控制器(FileController)
PS. 这里的ApiResult是demo中封装的接口输出格式类
1 package com.example.demo.api.controller;
2
3 import com.example.demo.api.interfaces.IFileService;
4 import com.example.demo.common.base.BaseController;
5 import com.example.demo.entity.vo.ApiResult;
6 import io.swagger.annotations.Api;
7 import io.swagger.v3.oas.annotations.Operation;
8 import org.springframework.web.bind.annotation.*;
9 import org.springframework.web.multipart.MultipartFile;
10
11 import javax.annotation.Resource;
12
13 @Api(tags = "文件")
14 @RestController
15 @RequestMapping("file")
16 public class FileController {
17
18 @Resource
19 private IFileService fileService;
20
21 @Operation(summary = "上传文件")
22 @PostMapping(path = "/images/{companyId}")
23 public ApiResult uploadFile(@PathVariable long companyId, @RequestParam("file") MultipartFile file)
24 throws Exception {
25 String url = fileService.uploadFile(companyId, file);
26 return ApiResult.success(url);
27 }
28
29 }
六、测试
接着我们用postman测试post接口,其中companyId随便赋值一个数。
当FileServiceImpl使用“无多级目录”的代码时,文件将会保存在“FTP物理路径/用户名”的目录下(如果ftp完全根据我提供的资料部署,则文件保存到/Users/mbp/docker/vol/vsftpd/data/myftp)。
当FileServiceImpl使用“存到company/{companyId}/images路径下”,文件将会保存在“FTP物理路径/用户名/company/{companyId}/images”的目录下如果ftp完全根据我提供的资料部署,则文件保存到/Users/mbp/docker/vol/vsftpd/data/myftp/company/{companyId}/images)。
七、我遇到的问题
1)500 Illegal PORT command.
答:
因为我部署的ftp是被动模式,所以ftp获取客户端实例时需要设置模式(详见“四、工具类封装”的65 - 70行代码)。
2)Connection closed without indication.
答:
这是我在mac上通过docker部署时,如果容器的端口20映射笔记本的端口20、容器端口21映射笔记本的端口21,则会引起该报错(如果有大佬愿意指点,请在评论中留言)。我采取的解决方案是换笔记本的端口绑(38021、38022)。
3)上传的文件损坏
答:
在初始化客户端实例时需要设置其文件类型为二进制(详见“四、工具类封装”的第72行代码)。
PS. 很多文章的代码都没注意这个问题,文件看着是上传到ftp目录了,但实际该文件损坏。
4)ftp多级目录没有自动生成
答:
客户端实例的changeWorkingDirectory方法无法处理多级目录,所以设计循环遍历路径,有需要则创建(详见“四、工具类封装”的82 - 97行代码)。