快速搭建和訪問 FTP 伺服器

乔京飞發表於2024-11-27

隨著以 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 目錄和相關檔案,具體結構如下:

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

相關文章