通过 docker 安装 vsftpd 并在 Java 中通过 ftp api 进行上传和下载文件等操作

概述

本文将详解介绍如何通过 docker 搭建 vsftpd 服务,并在 java 中通过 ftp api 上传,下载文件等操作。

主要包括以下两个部分:

  1. 通过 通过 docker 安装 vsftpd
  2. 在 java 中通过 FTPClient 访问 vsftpd 并对 FTP 文件进行操作

通过 docker 安装 vsftpd

这里介绍在 windows 平台下根据 vsftpd 镜像搭建 vsftpd 服务。

创建数据和日志目录

1
2
D:\\docker\\vsftpd\\data
D:\\docker\\vsftpd\\log

分别用于存储数据文件和日志

拉取镜像

1
docker pull fauria/vsftpd:latest

启动镜像

1
2
3
4
5
6
7
8
9
10
11
12
docker run -d ^
--restart=always ^
--name vsftpd ^
-v D:\\docker\\vsftpd\\data:/home/vsftpd ^
-v D:\\docker\\vsftpd\\log:/var/log/vsftpd ^
-p 20:20 ^
-p 60021:21 ^
-p 21100-21110:21100-21110 ^
-e PASV_ADDRESS=192.168.0.103 ^
-e FTP_USER=root ^
-e FTP_PASS=root ^
fauria/vsftpd:latest
  • 其中:^ 用于 windows 下的命令换行

参数说明

  • -d: 后台运行
  • --restart=always: 容器随着 docker 重启会自动启动
  • --name vsftpd:容器名称
  • -v D:\\docker\\vsftpd\data:/home/vsftpd: 数据挂载目录: 宿主机目录:容器目录
  • -v D:\\docker\\vsftpd\\log:/var/log/vsftpd: 日志挂载目录
  • -p 20:20: 用于进行数据传输,宿主机端口:容器端口
  • -p 60021:21: 用于接受客户端发出的相关 FTP 命令与参数,宿主机端口:容器端口
  • -p 21100-21110:21100-21110: 在 被动模式 下的开放端口范围
  • -e PASV_ADDRESS=192.168.0.103: 在 被动模式 下的主机的地址
  • -e PASV_MIN_PORT=21100: 在 被动模式 下开放的最小端口
  • -e PASV_MAX_PORT=21110: 在 被动模式 下开放的最大端口
  • -e FTP_USER=root: ftp 用户名
  • -e FTP_PASS=root: ftp 密码
  • fauria/vsftpd:latest: 使用的镜像和版本号

FTP 可通过主动模式和被动模式与客户端机器进行连接并传输数据。由于大多数客户端机器的防火墙设置及无法获取真实 IP 等原因,这里选择被动模式搭建 FTP 服务。

在 java 中通过 FTPClient 访问 vsftpd 并对 FTP 文件进行操作

这里以上面搭建的 vsftpd 为 ftp 服务器端,通过 FTPClient 访问 vsftpd 服务,实现上传和下载文件等操作,主要包括以下内容

  1. 获取 FTPClient 对象
  2. 关闭 FTPClient 对象
  3. 从 FTPServer 下载指定文件
  4. 将本地文件上传到 FTPServer
  5. 在 FTPServer 上 复制 文件
  6. 在 FTPServer 上 剪切(或者移动) 文件
  7. 在 FTPServer 上 删除指定文件夹下文件及其子文件
  8. 在 FTPServer 上 遍历解析文件夹下所有文件

maven 依赖

  • commons-net 包含了 FTPClient api 的核心操作
  • xutils 包含了 FtpUtils 工具类,简化了对 ftp api 的操作
1
2
3
4
5
6
7
8
9
10
11
<dependency>
<groupId>commons-net</groupId>
<artifactId>commons-net</artifactId>
<version>3.6</version>
</dependency>

<dependency>
<groupId>com.ckjava</groupId>
<artifactId>xutils</artifactId>
<version>1.0.11</version>
</dependency>

工具类封装如下

  • 其中
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
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
package com.ckjava.xutils;

import com.ckjava.xutils.promise.Promise;
import org.apache.commons.net.ftp.FTPClient;
import org.apache.commons.net.ftp.FTPFile;
import org.apache.commons.net.ftp.FTPReply;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.SocketException;
import java.nio.charset.StandardCharsets;
import java.util.Arrays;
import java.util.List;
import java.util.Objects;

/**
* @author ckjava
*
*/
public class FtpUtils implements Constants {
private static final Logger logger = LoggerFactory.getLogger(FtpUtils.class);

/**
* 获取FTPClient对象
* @param ftpHost 服务器IP
* @param ftpPort 服务器端口号
* @param ftpUserName 用户名
* @param ftpPassword 密码
* @return FTPClient
*/
public static FTPClient getFTPClient(final String ftpHost, final int ftpPort, final String ftpUserName, final String ftpPassword) {
FTPClient ftpClient = null;
try {
ftpClient = new FTPClient();
// 连接FPT服务器,设置IP及端口
ftpClient.connect(ftpHost, ftpPort);
// 设置用户名和密码
ftpClient.login(ftpUserName, ftpPassword);
// 设置连接超时时间,5000毫秒
ftpClient.setConnectTimeout(50000);
// 设置中文编码集,防止中文乱码
ftpClient.setControlEncoding(StandardCharsets.UTF_8.name());
if (!FTPReply.isPositiveCompletion(ftpClient.getReplyCode())) {
logger.info("未连接到FTP,用户名或密码错误");
ftpClient.disconnect();
} else {
logger.info("FTP连接成功");
}
} catch (final SocketException e) {
logger.error("FTP的IP地址可能错误,请正确配置", e);
} catch (final IOException e) {
logger.error("FTP的端口错误,请正确配置", e);
}
return ftpClient;
}

/**
* 关闭 FTPClient 对象对象
*
* @param ftpClient FTPClient 对象
*/
public static void closeFTP(final FTPClient ftpClient) {
try {
ftpClient.logout();
} catch (final Exception e) {
logger.error("FTP关闭失败", e);
} finally {
if (ftpClient.isConnected()) {
try {
ftpClient.disconnect();
} catch (final IOException ioe) {
logger.error("FTP关闭失败", ioe);
}
}
}
}

/**
* 从 FTP 上下指定文件
* @param ftpClient FTPClient 对象
* @param ftpFilePath FTP文件路径
* @param ftpFileName 文件名
* @param downPath 本地下载保存的目录
* @return 是否下载成功
*/
public static Promise<Boolean> downloadFtpFile(final FTPClient ftpClient, final String ftpFilePath, final String ftpFileName, final String downPath) {
final Promise<Boolean> promise = Promise.withInitial(false);
try {
// 跳转到文件目录
ftpClient.changeWorkingDirectory(ftpFilePath);
// 获取目录下文件集合
ftpClient.enterLocalPassiveMode();
final FTPFile[] files = ftpClient.listFiles();
Arrays.stream(files).filter(ftpFile -> Objects.equals(ftpFile.getName(), ftpFileName)).findAny().ifPresent(ftpFile -> {

OutputStream out = null;
try {
final File downFile = new File(downPath + File.separator + ftpFile.getName());
out = new FileOutputStream(downFile);
// 绑定输出流下载文件,需要设置编码集,不然可能出现文件为空的情况
final boolean flag = ftpClient.retrieveFile(changeStringCharset(ftpFile.getName()), out);
// 下载成功删除文件,看项目需求
// ftpClient.deleteFile(new String(fileName.getBytes(StandardCharsets.UTF_8),StandardCharsets.ISO_8859_1));
out.flush();
promise.setData(flag);
logger.info(String.format("%s 下载结果:%s", ftpFileName, flag ? "成功" : "失败"));
} catch (final Exception e) {
promise.setException(e);
logger.error(String.format("下载 ftpClient 文件:%s 出现异常", ftpFileName), e);
} finally {
IOUtils.closeQuietly(out);
}
});
} catch (final Exception e) {
promise.setException(e);
logger.error(String.format("下载 ftpClient 文件:%s 出现异常", ftpFileName), e);
}
return promise;
}

/**
* FTP文件上传工具类
* @param ftpClient FTPClient 对象
* @param localFilePath 本地文件路径
* @param ftpPath fpt 文件路径
* @return Promise 对象
*/
public static Promise<Boolean> uploadFile(final FTPClient ftpClient, final String localFilePath, final String ftpPath) {
final Promise<Boolean> promise = Promise.withInitial(false);
InputStream in = null;
try {
// 设置PassiveMode传输
ftpClient.enterLocalPassiveMode();
// 设置二进制传输,使用BINARY_FILE_TYPE,ASC容易造成文件损坏
ftpClient.setFileType(FTPClient.BINARY_FILE_TYPE);
// 判断FPT目标文件夹时候存在不存在则创建
if (!ftpClient.changeWorkingDirectory(ftpPath)) {
ftpClient.makeDirectory(ftpPath);
}
// 跳转目标目录
ftpClient.changeWorkingDirectory(ftpPath);

// 上传文件
final File file = new File(localFilePath);
in = new FileInputStream(file);
final boolean flag = ftpClient.storeFile(changeStringCharset(file.getName()), in);

promise.setData(flag);
logger.info(String.format("%s 上传结果:%s", localFilePath, flag ? "成功" : "失败"));
} catch (final Exception e) {
promise.setException(e);
logger.info(String.format("%s 上传出现异常", localFilePath));
} finally {
try {
in.close();
} catch (final IOException ignored) {
}
}
return promise;
}

/**
* ftpClient 上文件复制(转存)
*
* @param ftpClient FTPClient 对象
* @param oldPath 原文件目录
* @param newPath 新保存目录
* @param fileName 文件名
* @return Promise 对象
*/
public static Promise<Boolean> copyFile(final FTPClient ftpClient, final String oldPath, final String newPath, final String fileName) {
final Promise<Boolean> promise = Promise.withInitial(false);
try {
// 跳转到文件目录
ftpClient.changeWorkingDirectory(oldPath);
//设置连接模式,不设置会获取为空
ftpClient.enterLocalPassiveMode();
// 获取目录下文件集合
final FTPFile[] files = ftpClient.listFiles();

Arrays.stream(files).filter(ftpFile -> Objects.equals(ftpFile.getName(), fileName)).findAny().ifPresent(ftpFile -> {
ByteArrayOutputStream out = null;
ByteArrayInputStream in = null;
try {
//读取文件,使用下载文件的方法把文件写入内存,绑定到out流上
out = new ByteArrayOutputStream();
ftpClient.retrieveFile(changeStringCharset(ftpFile.getName()), out);
in = new ByteArrayInputStream(out.toByteArray());
//创建新目录
ftpClient.makeDirectory(newPath);
//文件复制,先读,再写
//二进制
ftpClient.setFileType(FTPClient.BINARY_FILE_TYPE);
final boolean flag = ftpClient.storeFile(FileUtils.joinPath(SPLITER.SLASH, new String[]{ newPath, changeStringCharset(ftpFile.getName()) }), in);
out.flush();

logger.info(String.format("%s 复制结果:%s", fileName, flag ? "成功" : "失败"));
promise.setData(flag);
} catch (final Exception e) {
logger.error(String.format("复制文件出现异常,oldPath:%s, newPath:%s, fileName:%s", oldPath, newPath, fileName), e);
promise.setException(e);
} finally {
IOUtils.closeQuietly(out);
IOUtils.closeQuietly(in);
}
});
} catch (final Exception e) {
logger.error(String.format("复制文件出现异常,oldPath:%s, newPath:%s, fileName:%s", oldPath, newPath, fileName), e);
promise.setException(e);
}
return promise;
}

/**
* 实现文件的移动,这里做的是一个文件夹下的所有内容移动到新的文件夹下
*
* 如果要做指定文件移动,加个判断判断文件名
* 如果不需要移动,只是需要文件重命名,可以使用ftp.rename(oleName,newName)
*
* @param ftpClient FTPClient 对象
* @param oldFtpPath 原来的文件夹路径
* @param newFtpPath 新的文件夹路径
* @return Promise 对象
*/
public static Promise<Boolean> moveFile(final FTPClient ftpClient, final String oldFtpPath, final String newFtpPath) {
final Promise<Boolean> promise = Promise.withInitial(false);
try {
ftpClient.changeWorkingDirectory(oldFtpPath);
ftpClient.enterLocalPassiveMode();
// 新文件夹不存在则创建
if (!ftpClient.changeWorkingDirectory(newFtpPath)) {
ftpClient.makeDirectory(newFtpPath);
}
//回到原有工作目录
ftpClient.changeWorkingDirectory(oldFtpPath);
//获取文件数组
final FTPFile[] files = ftpClient.listFiles();
Arrays.stream(files).forEach(ftpFile -> {
final String ftpFileName = changeStringCharset(ftpFile.getName());
final String oldFtpFile = FileUtils.joinPath(SPLITER.SLASH, new String[]{ oldFtpPath, ftpFileName });
final String newFtpFile = FileUtils.joinPath(SPLITER.SLASH, new String[]{ newFtpPath, ftpFileName });
//转存目录
try {
final boolean flag = ftpClient.rename(oldFtpFile, newFtpFile);
logger.info(String.format("%s 移动文件结果:%s", ftpFile.getName(), flag ? "成功" : "失败"));
promise.setData(flag);
} catch (final Exception e) {
promise.setException(e);
logger.error(String.format("移动文件出现异常,oldFtpPath:%s, newFtpPath:%s", oldFtpPath, newFtpPath), e);
}
});
} catch (final Exception e) {
promise.setException(e);
logger.error(String.format("移动文件出现异常,oldFtpPath:%s, newFtpPath:%s", oldFtpPath, newFtpPath), e);
}
return promise;
}

/**
* 修改字符串的类型
*
* @param originalString UTF-8 类型字符串
* @return ISO-8859-1 类型的字符串
*/
private static String changeStringCharset(final String originalString) {
return new String(originalString.getBytes(StandardCharsets.UTF_8), StandardCharsets.ISO_8859_1);
}

/**
* 遍历解析文件夹下所有文件
* @param ftpFolderPath 需要解析的的文件夹
* @param ftpClient FTPClient对象
* @return Promise 对象
*/
public static Promise<List<String>> listFileByFolder(final FTPClient ftpClient, final String ftpFolderPath, final List<String> fileList) {
final Promise<List<String>> promise = Promise.withInitial(fileList);
try {
ftpClient.changeWorkingDirectory(changeStringCharset(ftpFolderPath));
//设置FTP连接模式
ftpClient.enterLocalPassiveMode();
//获取指定目录下文件文件对象集合
final FTPFile[] files = ftpClient.listFiles();
for (final FTPFile file : files) {
//判断为txt文件则解析
if (file.isFile()) {
fileList.add(FileUtils.joinPath(SPLITER.SLASH, new String[]{ ftpFolderPath, file.getName() }));
}
//判断为文件夹,递归
if (file.isDirectory()) {
final String path = FileUtils.joinPath(SPLITER.SLASH, new String[]{ ftpFolderPath, file.getName() });
listFileByFolder(ftpClient, path, fileList);
}
}
promise.setData(fileList);
} catch (final Exception e) {
promise.setException(e);
logger.error(String.format("%s 文件遍历出现异常", ftpFolderPath), e);
}
return promise;
}

/**
* 删除FTP上指定文件夹下文件及其子文件方法,添加了对中文目录的支持
*
* @param ftpClient FTPClient对象
* @param ftpFolder 需要删除的文件夹
* @return Promise 对象
*/
public static Promise<Boolean> deleteByFolder(final FTPClient ftpClient, final String ftpFolder) {
final Promise<Boolean> promise = Promise.withInitial(false);
try {
ftpClient.changeWorkingDirectory(changeStringCharset(ftpFolder));
ftpClient.enterLocalPassiveMode();
final FTPFile[] files = ftpClient.listFiles();
for (final FTPFile ftpFile : files) {
// 判断为文件则删除
if (ftpFile.isFile()) {
ftpClient.deleteFile(changeStringCharset(ftpFile.getName()));
}
// 判断是文件夹
if (ftpFile.isDirectory()) {
final String childPath = FileUtils.joinPath(SPLITER.SLASH, new String[]{ ftpFolder, ftpFile.getName() });
//递归删除子文件夹
deleteByFolder(ftpClient, childPath);

}
}
// 循环完成后删除文件夹
final boolean flag = ftpClient.removeDirectory(changeStringCharset(ftpFolder));
promise.setData(flag);
logger.info(String.format("%s 文件夹删除结果:%s", ftpFolder, flag ? "成功" : "失败"));
} catch (final Exception e) {
promise.setException(e);
logger.error(String.format("%s 文件夹删除出现异常", ftpFolder), e);
}
return promise;
}
}

测试代码

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
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
package com.ckjava.xutils.test;

import com.ckjava.xutils.FtpUtils;
import org.apache.commons.net.ftp.FTPClient;
import org.junit.After;
import org.junit.Before;
import org.junit.Test;

import java.util.ArrayList;

/**
* @author ckjava
* @date 2022/4/17 13:03
*/
public class TestFtpUtils extends FtpUtils {

private static final String ftpHost = "192.168.0.103";
private static final int ftpPort = 60021;
private static final String ftpUserName = "root";
private static final String ftpPassword = "root";

private FTPClient ftpClient = null;


@Before
public void beforeClass() throws Exception {
ftpClient = getFTPClient(ftpHost, ftpPort, ftpUserName, ftpPassword);
final String status = ftpClient.getStatus();
System.out.println(String.format("status:%s", status));
}

@After
public void afterClass() {
closeFTP(ftpClient);
}

@Test
public void test_uploadFile() {
final String localFilePath = "C:\\Users\\ckjava\\Downloads\\RK100.pdf";
final String ftpFilePath = "/test";
uploadFile(ftpClient, localFilePath, ftpFilePath).then(result -> {
System.out.printf("ftp upload file 结果:%s%n", result);
}).thenCatch(e -> {
System.out.printf("ftp upload file 出现异常:%s", e.getMessage());
});
}

@Test
public void test_copyFile() {
final String fileName = "RK100.pdf";
copyFile(ftpClient, "/test/", "/backup/", fileName).then(result -> {
System.out.printf("ftp copyFile file 结果:%s%n", result);
}).thenCatch(e -> {
System.out.printf("ftp copyFile file 出现异常:%s", e.getMessage());
});
}

@Test
public void test_downloadFile() {
final String ftpFilePath = "test";
final String fileName = "RK100.pdf";
final String filePath = "D:\\迅雷下载";
downloadFtpFile(ftpClient, ftpFilePath, fileName, filePath).then(result -> {
System.out.printf("ftp downloadFtpFile file 结果:%s%n", result);
}).thenCatch(e -> {
System.out.printf("ftp downloadFtpFile file 出现异常:%s", e.getMessage());
});
}

@Test
public void test_deleteByFolder() {
final String ftpFilePath = "/xxx";
deleteByFolder(ftpClient, ftpFilePath).then(result -> {
System.out.printf("ftp deleteByFolder file 结果:%s%n", result);
}).thenCatch(e -> {
System.out.printf("ftp deleteByFolder file 出现异常:%s", e.getMessage());
});
}

@Test
public void test_moveFile() {
final String oldFtpPath = "/back";
final String newFtpPath = "/xxx";
moveFile(ftpClient, oldFtpPath, newFtpPath).then(result -> {
System.out.printf("ftp downloadFtpFile file 结果:%s%n", result);
}).thenCatch(e -> {
System.out.printf("ftp downloadFtpFile file 出现异常:%s", e.getMessage());
});
}

@Test
public void test_listFileByFolder() {
final String ftpFolderPath = "/";
listFileByFolder(ftpClient, ftpFolderPath, new ArrayList<>()).then(list -> {
list.forEach(System.out::println);
});
}

}

参考

打赏

  • 微信

  • 支付宝