随着以 minio 为代表的分布式系统的广泛应用,使用 FTP 的场景就越来越少了,目前仍然在一些简单的应用场景中使用。
本篇博客使用 fauria/vsftpd 的 docker 镜像,介绍 FTP 服务器搭建的两种方式:匿名访问方式 和 使用账号密码访问方式。然后使用 SpringBoot 程序通过代码访问操作搭建好的两种 FTP 服务器,在本篇博客的最后会提供源代码的下载。
由于国外的 dockerhub 网站无法访问,这里推荐另外一个很不错的网站:https://docker.fxxk.dedyn.io
我的虚拟机 ip 是 192.168.136.128,已经安装好了 docker 和 docker-compose
一、使用匿名访问的 FTP 搭建
在 /data 目录创建 ftp1 目录,内部创建子目录 pub 目录和相关文件,具体结构如下:
注意:由于匿名访问的 FTP 用户需要有文件夹的读写权限,因此这里将 pub 目录的权限设置为 777
chmod -R 777 /data/ftp1/pub
编写 docker-compose.yml 文件内容如下:
version: '3.2'
services:ftp:restart: alwaysimage: fauria/vsftpd:latestcontainer_name: ftpprivileged: trueports:- "20:20"- "21:21"# 被动模式访问的端口- "21100-21110:21100-21110"volumes:# 匿名用户访问的目录# 注意:必须把docker外面映射的目录设置为可读可写的权限)- ./pub:/var/ftp/pub# 想要匿名访问的话,就映射该文件到 docker 容器中- ./vsftpd.conf:/etc/vsftpd/vsftpd.confenvironment:# 设置 FTP 服务器的 ipPASV_ADDRESS: 192.168.136.128# 下面两个环境变量,设置被动访问模式使用的最大端口和最小端口PASV_MIN_PORT: 21100PASV_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 目录中可以任意上传修改下载文件。
二、使用账号密码访问的 FTP 搭建
在 /data 目录创建 ftp12目录,内部创建子目录 home 目录和相关文件,具体结构如下:
这里的 home 目录,我没有通过 chmod 把它设置为 777 权限。
编写 docker-compose.yml 文件内容如下:
version: '3.2'
services:ftp:restart: alwaysimage: fauria/vsftpd:latestcontainer_name: ftpprivileged: trueports:- "20:20"- "21:21"# 被动模式访问的端口- "21100-21110:21100-21110"volumes:# 普通用户访问的目录- ./home:/home/vsftpdenvironment:# 自定义账号名称FTP_USER: admin# 自定义账号密码FTP_PASS: 123456# 设置 FTP 服务器的 ipPASV_ADDRESS: 192.168.136.128# 下面两个环境变量,设置被动访问模式使用的最大端口和最小端口PASV_MIN_PORT: 21100PASV_MAX_PORT: 21110
最后在 docker-compose.yml 文件所在目录,运行 docker-compose up -d
启动服务即可。
打开我的电脑,访问 ftp://192.168.136.128
即可弹出登录框,输入账号密码后即可,如下图所示:
三、使用 SpringBoot 代码访问
新建一个名称为 springboot_ftp 的项目,结构如下所示:
首先看一下 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.0http://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.128port: 21# 使用账号密码,访问 FTP 服务器username: adminpassword: 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;@Beanpublic 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 {@Autowiredprivate 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 {@Autowiredprivate FTPService ftpService;//上传文件@Testpublic 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);}//修改文件名@Testpublic 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 服务器文件列表@Testpublic 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);});}//下载文件@Testpublic 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);}//删除文件@Testpublic 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 {@Autowiredprivate FTPService ftpService;//上传文件@Testpublic 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);}//修改文件名@Testpublic 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 服务器文件列表@Testpublic 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);});}//下载文件@Testpublic 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);}//删除文件@Testpublic 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