快速搭建和访问 FTP 服务器

随着以 minio 为代表的分布式系统的广泛应用,使用 FTP 的场景就越来越少了,目前仍然在一些简单的应用场景中使用。

本篇博客使用 fauria/vsftpd 的 docker 镜像,介绍 FTP 服务器搭建的两种方式:匿名访问方式 和 使用账号密码访问方式。然后使用 SpringBoot 程序通过代码访问操作搭建好的两种 FTP 服务器,在本篇博客的最后会提供源代码的下载。

由于国外的 dockerhub 网站无法访问,这里推荐另外一个很不错的网站:https://cloud.tencent.com/developer/article/2471124

我的虚拟机 ip 是 192.168.136.128,已经安装好了 docker 和 docker-compose


一、使用匿名访问的 FTP 搭建

在 /data 目录创建 ftp1 目录,内部创建子目录 pub 目录和相关文件,具体结构如下:

image

注意:由于匿名访问的 FTP 用户需要有文件夹的读写权限,因此这里将 pub 目录的权限设置为 777

chmod -R 777 /data/ftp1/pub

编写 docker-compose.yml 文件内容如下:

version: '3.2'
services:
ftp:
restart: always
image: fauria/vsftpd:latest
container_name: ftp
privileged: true
ports:
- "20:20"
- "21:21"
# 被动模式访问的端口
- "21100-21110:21100-21110"
volumes:
# 匿名用户访问的目录
# 注意:必须把docker外面映射的目录设置为可读可写的权限)
- ./pub:/var/ftp/pub
# 想要匿名访问的话,就映射该文件到 docker 容器中
- ./vsftpd.conf:/etc/vsftpd/vsftpd.conf
environment:
# 设置 FTP 服务器的 ip
PASV_ADDRESS: 192.168.136.128
# 下面两个环境变量,设置被动访问模式使用的最大端口和最小端口
PASV_MIN_PORT: 21100
PASV_MAX_PORT: 21110

vsftpd.conf 是我从 docker 中拷贝主要的 FTP 配置文件,其在 docker 容器中的路径为:/etc/vsftpd/vsftpd.conf

这里就不介绍如何拷贝出来了,为了实现匿名访问,直接列出修改后的 vsftpd.conf 文件,内容如下:

# Run in the foreground to keep the container running:
background=NO
# Allow anonymous FTP? (Beware - allowed by default if you comment this out).
# ====================================
# 将此配置修改为 YES ,启动匿名访问
anonymous_enable=YES
# 下面这 4 项配置是新增的内容
anon_upload_enable=YES
anon_mkdir_write_enable=YES
anon_other_write_enable=YES
anon_umask=022
# ====================================
# Uncomment this to allow local users to log in.
local_enable=YES
## Enable virtual users
guest_enable=YES
## Virtual users will use the same permissions as anonymous
virtual_use_local_privs=YES
# Uncomment this to enable any form of FTP write command.
write_enable=YES
## PAM file name
pam_service_name=vsftpd_virtual
## Home Directory for virtual users
user_sub_token=$USER
local_root=/home/vsftpd/$USER
# You may specify an explicit list of local users to chroot() to their home
# directory. If chroot_local_user is YES, then this list becomes a list of
# users to NOT chroot().
chroot_local_user=YES
# Workaround chroot check.
# See https://www.benscobie.com/fixing-500-oops-vsftpd-refusing-to-run-with-writable-root-inside-chroot/
# and http://serverfault.com/questions/362619/why-is-the-chroot-local-user-of-vsftpd-insecure
allow_writeable_chroot=YES
## Hide ids from user
hide_ids=YES
## Enable logging
xferlog_enable=YES
xferlog_file=/var/log/vsftpd/vsftpd.log
## Enable active mode
port_enable=YES
connect_from_port_20=YES
ftp_data_port=20
## Disable seccomp filter sanboxing
seccomp_sandbox=NO
### Variables set at container runtime
pasv_address=192.168.136.128
pasv_max_port=21110
pasv_min_port=21100
pasv_addr_resolve=NO
pasv_enable=YES
file_open_mode=0666
local_umask=077
xferlog_std_format=NO
reverse_lookup_enable=YES
pasv_promiscuous=NO
port_promiscuous=NO

注意:该配置文件,末尾需要留一个空行。

因为我在实际测试中发现:每次重启搭建好的 FTP 服务,vsftpd.conf 文件末尾都会新增好多行重复的配置。

如果 vsftpd.conf 配置文件末尾不留一个空行的话,vsftpd.conf 被新增好多行重复的配置后,配置文件就乱了,会导致服务无法使用。

在以上配置文件中,我修改的内容如下:

# ====================================
# 将此配置修改为 YES ,启动匿名访问
anonymous_enable=YES
# 下面这 4 项配置是新增的内容
anon_upload_enable=YES
anon_mkdir_write_enable=YES
anon_other_write_enable=YES
anon_umask=022
# ====================================

最后在 docker-compose.yml 文件所在目录,运行 docker-compose up -d 启动服务即可。

打开我的电脑,访问 ftp://192.168.136.128 即可匿名访问,如下图所示,在 pub 目录中可以任意上传修改下载文件。

image


二、使用账号密码访问的 FTP 搭建

在 /data 目录创建 ftp12目录,内部创建子目录 home 目录和相关文件,具体结构如下:

image

这里的 home 目录,我没有通过 chmod 把它设置为 777 权限。

编写 docker-compose.yml 文件内容如下:

version: '3.2'
services:
ftp:
restart: always
image: fauria/vsftpd:latest
container_name: ftp
privileged: true
ports:
- "20:20"
- "21:21"
# 被动模式访问的端口
- "21100-21110:21100-21110"
volumes:
# 普通用户访问的目录
- ./home:/home/vsftpd
environment:
# 自定义账号名称
FTP_USER: admin
# 自定义账号密码
FTP_PASS: 123456
# 设置 FTP 服务器的 ip
PASV_ADDRESS: 192.168.136.128
# 下面两个环境变量,设置被动访问模式使用的最大端口和最小端口
PASV_MIN_PORT: 21100
PASV_MAX_PORT: 21110

最后在 docker-compose.yml 文件所在目录,运行 docker-compose up -d 启动服务即可。

打开我的电脑,访问 ftp://192.168.136.128 即可弹出登录框,输入账号密码后即可,如下图所示:

image


三、使用 SpringBoot 代码访问

新建一个名称为 springboot_ftp 的项目,结构如下所示:

image

首先看一下 pom 文件引入的依赖包(最主要是引入了 commons-net 包)

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>com.jobs</groupId>
<artifactId>springboot_ftp</artifactId>
<version>1.0</version>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.4.5</version>
</parent>
<properties>
<maven.compiler.source>8</maven.compiler.source>
<maven.compiler.target>8</maven.compiler.target>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter</artifactId>
<scope>compile</scope>
</dependency>
<!--引入 commons-net 包来处理 ftp 相关操作-->
<dependency>
<groupId>commons-net</groupId>
<artifactId>commons-net</artifactId>
<version>3.11.1</version>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.18.20</version>
</dependency>
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-lang3</artifactId>
<version>3.14.0</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
</dependency>
</dependencies>
</project>

在 application.yml 中自定义了访问 FTP 服务的相关参数,如下所示:

ftp:
ip: 192.168.136.128
port: 21
# 使用账号密码,访问 FTP 服务器
username: admin
password: 123456
# 使用匿名方式,访问 FTP 服务器,配置的用户名只能是 ftp,密码可以不用配置
#username: ftp
#password:

如果搭建的是匿名访问的 FTP 服务,那么 username 填写 ftp 即可,密码不需要填写,填写了也没啥影响。

我们在 FTPConfig 类中读取 application.yml 中配置的参数,初始化 FTPClient 对象,并将其添加到 Spring 容器中。

package com.jobs.config;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.net.ftp.FTPClient;
import org.apache.commons.net.ftp.FTPReply;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Slf4j
@Configuration
public class FTPConfig {
@Value("${ftp.ip}")
private String ip;
@Value("${ftp.port}")
private Integer port;
@Value("${ftp.username}")
private String username;
@Value("${ftp.password}")
private String password;
@Bean
public FTPClient getFTPClient() {
FTPClient ftpClient = new FTPClient();
// 设置连接超时时间
ftpClient.setConnectTimeout(30 * 1000);
// 设置ftp字符集
ftpClient.setControlEncoding("utf-8");
// 设置被动模式,文件传输端口设置
ftpClient.enterLocalPassiveMode();
try {
int replyCode;
ftpClient.connect(ip, port);
ftpClient.login(username, password);
replyCode = ftpClient.getReplyCode();
if (!FTPReply.isPositiveCompletion(replyCode)) {
log.error("FTP服务器 " + ip + " 连接失败,返回状态码为:" + replyCode);
return null;
}
} catch (Exception ex) {
log.error("FTP服务器 " + ip + " 连接失败:" + ex.getMessage());
return null;
}
return ftpClient;
}
}

编写一个 FTPService 类,使用 FTPClient 提供对 FTP 服务的文件上传、下载、改名、删除、查看目录下文件列表等操作:

package com.jobs.service;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.net.ftp.FTP;
import org.apache.commons.net.ftp.FTPClient;
import org.junit.platform.commons.util.StringUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import java.io.*;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
@Slf4j
@Service
public class FTPService {
@Autowired
private FTPClient ftpClient;
/**
* 上传文件
*
* @param localFileName 本地上传的文件全路径
* @param ftpPath FTP 服务器目录全路径
* @param ftpFileName Ftp文件名称
* @return 是否成功
*/
public Boolean uploadFile(String localFileName, String ftpPath, String ftpFileName) {
boolean result = false;
if (ftpClient != null) {
try {
//设置文件传输模式为二进制
ftpClient.setFileType(FTP.BINARY_FILE_TYPE);
ftpClient.enterLocalPassiveMode(); //采用被动模式
if (StringUtils.isNotBlank(ftpPath)) {
boolean flag = ftpClient.changeWorkingDirectory(ftpPath);
if (flag == false) {
ftpClient.makeDirectory(ftpPath);
}
ftpClient.changeWorkingDirectory(ftpPath);
} else {
ftpClient.changeWorkingDirectory("/");
}
try (FileInputStream fis = new FileInputStream(localFileName)) {
//上传文件
result = ftpClient.storeFile(ftpFileName, fis);
}
} catch (Exception ex) {
log.error("FTP文件上传失败:" + ex.getMessage());
}
}
return result;
}
/**
* 修改 ftp 服务器上的一个文件名称
*
* @param ftpPath 文件所在目录全路径
* @param oldName 旧文件名
* @param newName 新文件名
* @return 是否成功
*/
public Boolean renameFile(String ftpPath, String oldName, String newName) {
boolean result = false;
if (ftpClient != null) {
try {
if (StringUtils.isNotBlank(ftpPath)) {
ftpClient.changeWorkingDirectory(ftpPath);
} else {
ftpClient.changeWorkingDirectory("/");
}
result = ftpClient.rename(oldName, newName);
} catch (Exception ex) {
log.error("FTP修改文件名称失败:" + ex.getMessage());
}
}
return result;
}
/**
* 下载文件
*
* @param ftpFilePath ftp文件全路径名称
* @param localFilePath 本地文件全路径名称
* @return 是否成功
*/
public Boolean downloadFile(String ftpFilePath, String localFilePath) {
boolean result = false;
if (ftpClient != null) {
try {
ftpClient.setFileType(FTPClient.BINARY_FILE_TYPE);
ftpClient.enterLocalPassiveMode();
//获取 FTP 文件输入流
InputStream inputStream = ftpClient.retrieveFileStream(ftpFilePath);
//获取本地文件输出流
FileOutputStream outputStream = new FileOutputStream(localFilePath);
//采用高级流进行字节复制
try (BufferedInputStream bis = new BufferedInputStream(inputStream);
BufferedOutputStream bos = new BufferedOutputStream(outputStream)) {
byte[] bArr = new byte[1024];
int len;
while ((len = bis.read(bArr)) != -1) {
bos.write(bArr, 0, len);
}
}
//下载完文件后,必须调用该方法,告诉 FTP 服务器已经完成文件下载,否则后续 FTPClient 将无法运行。
ftpClient.completePendingCommand();
result = true;
} catch (Exception ex) {
log.error("FTP文件下载失败:" + ex.getMessage());
}
}
return result;
}
/**
* 删除 FTP 服务器上的文件
*
* @param ftpFilePath ftp文件全路径名称
* @return 是否成功
*/
public Boolean deleteFile(String ftpFilePath, boolean isDirectory) {
boolean result = false;
if (ftpClient != null) {
try {
if (isDirectory) {
result = ftpClient.removeDirectory(ftpFilePath);
} else {
result = ftpClient.deleteFile(ftpFilePath);
}
} catch (Exception ex) {
log.error("FTP文件删除失败:" + ex.getMessage());
}
}
return result;
}
/**
* 获取目录下的文件列表
*
* @param directory 全路径文件夹
* @return 文件列表
*/
public List<String> listFiles(String directory) {
List<String> fileNames = new ArrayList<>();
try {
ftpClient.changeWorkingDirectory(directory);
String[] names = ftpClient.listNames();
fileNames = Arrays.asList(names);
} catch (Exception ex) {
log.error("FTP获取文件列表失败:" + ex.getMessage());
}
return fileNames;
}
}

最后写了 2 个测试类,如果你搭建的是匿名访问的 FTP 服务,使用 AnonymousFTPTest 类中的方法进行测试。

需要注意的是:匿名访问的 FTP 服务,需要在根目录下的 pub 目录中对文件进行操作。

package com.jobs;
import com.jobs.service.FTPService;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import java.util.List;
/**
* 使用匿名用户访问 FTP 服务器
* application.yml 配置的用户名为 ftp,密码可以不用配置
* 匿名用户的根目录是 /pub ,必须在 /pub 里面上传文件或创建文件夹等各种操作。
*/
@SpringBootTest
public class AnonymousFTPTest {
@Autowired
private FTPService ftpService;
//上传文件
@Test
public void test1() {
//在 FTP 服务器上,创建一个名称为【测试】的文件夹
//把本地的文件,上传到 FTP 服务器上的【测试】文件夹中,文件名称命名为【aaa.txt】
boolean result1 = ftpService.uploadFile("d:/我的测试.txt", "/pub/测试", "aaa.txt");
System.out.println(result1);
boolean result2 = ftpService.uploadFile("d:/我的测试.txt", "/pub", "bbb.txt");
System.out.println(result2);
}
//修改文件名
@Test
public void test2() {
boolean result1 = ftpService.renameFile("/pub/测试", "aaa.txt", "ccc.txt");
System.out.println(result1);
boolean result2 = ftpService.renameFile("/pub", "bbb.txt", "ddd.txt");
System.out.println(result2);
}
//查看 Ftp 服务器文件列表
@Test
public void test3() {
//查看根目录下的文件列表
//建议在 ftp 服务器上,对文件的命名都加上后缀名,对文件夹的命名不要加上后缀名,这样比较容易区分文件和文件夹。
List<String> list1 = ftpService.listFiles("/pub");
list1.forEach(f -> {
System.out.println(f);
});
System.out.println("==================================");
//查看【测试】文件夹下面的文件列表
List<String> list2 = ftpService.listFiles("/pub/测试");
list2.forEach(f -> {
System.out.println(f);
});
}
//下载文件
@Test
public void test4() {
boolean result1 = ftpService.downloadFile("/pub/测试/ccc.txt", "d:/ccc.txt");
System.out.println(result1);
boolean result2 = ftpService.downloadFile("/pub/ddd.txt", "d:/ddd.txt");
System.out.println(result2);
}
//删除文件
@Test
public void test5() {
//删除文件夹(这里不会删除成功,FTP 服务器不允许删除包含文件的文件夹)
boolean result1 = ftpService.deleteFile("/pub/测试", true);
System.out.println(result1);
//先删除文件夹中的文件,然后再删除文件夹
List<String> list2 = ftpService.listFiles("/pub/测试");
for (String f : list2) {
ftpService.deleteFile("/pub/测试/" + f, false);
}
boolean result2 = ftpService.deleteFile("/pub/测试", true);
System.out.println(result2);
//删除文件
boolean result3 = ftpService.deleteFile("/pub/ddd.txt", false);
System.out.println(result3);
}
}

如果你搭建是需要账号密码访问的 FTP 服务,使用 FTPTest 类中的方法进行测试。

package com.jobs;
import com.jobs.service.FTPService;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import java.util.List;
/**
* 使用用户名和密码,访问 FTP 服务器
*/
@SpringBootTest
public class FTPTest {
@Autowired
private FTPService ftpService;
//上传文件
@Test
public void test1() {
//在 FTP 服务器上,创建一个名称为【测试】的文件夹
//把本地的文件,上传到 FTP 服务器上的【测试】文件夹中,文件名称命名为【aaa.txt】
boolean result1 = ftpService.uploadFile("d:/我的测试.txt", "/测试", "aaa.txt");
System.out.println(result1);
boolean result2 = ftpService.uploadFile("d:/我的测试.txt", "/", "bbb.txt");
System.out.println(result2);
}
//修改文件名
@Test
public void test2() {
boolean result1 = ftpService.renameFile("/测试", "aaa.txt", "ccc.txt");
System.out.println(result1);
boolean result2 = ftpService.renameFile("/", "bbb.txt", "ddd.txt");
System.out.println(result2);
}
//查看 Ftp 服务器文件列表
@Test
public void test3() {
//查看根目录下的文件列表
//建议在 ftp 服务器上,对文件的命名都加上后缀名,对文件夹的命名不要加上后缀名,这样比较容易区分文件和文件夹。
List<String> list1 = ftpService.listFiles("/");
list1.forEach(f -> {
System.out.println(f);
});
System.out.println("==================================");
//查看【测试】文件夹下面的文件列表
List<String> list2 = ftpService.listFiles("/测试");
list2.forEach(f -> {
System.out.println(f);
});
}
//下载文件
@Test
public void test4() {
boolean result1 = ftpService.downloadFile("/测试/ccc.txt", "d:/ccc.txt");
System.out.println(result1);
boolean result2 = ftpService.downloadFile("/ddd.txt", "d:/ddd.txt");
System.out.println(result2);
}
//删除文件
@Test
public void test5() {
//删除文件夹(这里不会删除成功,FTP 服务器不允许删除包含文件的文件夹)
boolean result1 = ftpService.deleteFile("/测试", true);
System.out.println(result1);
//先删除文件夹中的文件,然后再删除文件夹
List<String> list2 = ftpService.listFiles("/测试");
for (String f : list2) {
ftpService.deleteFile("/测试/" + f, false);
}
boolean result2 = ftpService.deleteFile("/测试", true);
System.out.println(result2);
//删除文件
boolean result3 = ftpService.deleteFile("/ddd.txt", false);
System.out.println(result3);
}
}

以上代码都经过实际测试无误,具体细节可以下载源代码进行验证。

本篇博客的源代码下载地址为:https://files.cnblogs.com/files/blogs/699532/springboot_ftp.zip

posted @   乔京飞  阅读(1282)  评论(0编辑  收藏  举报
(评论功能已被禁用)
相关博文:
阅读排行:
· 被坑几百块钱后,我竟然真的恢复了删除的微信聊天记录!
· 没有Manus邀请码?试试免邀请码的MGX或者开源的OpenManus吧
· 【自荐】一款简洁、开源的在线白板工具 Drawnix
· 园子的第一款AI主题卫衣上架——"HELLO! HOW CAN I ASSIST YOU TODAY
· Docker 太简单,K8s 太复杂?w7panel 让容器管理更轻松!
点击右上角即可分享
微信分享提示