1. 概述

本文将介绍如何借助 Apache Commons Net 库与外部 FTP 服务器进行交互。该库提供了完整的 FTP 协议支持,适合在实际项目中处理文件上传、下载和目录操作等场景。

我们还会使用 MockFtpServer 来编写可重复执行的集成测试,避免依赖真实 FTP 环境带来的不稳定因素。

2. 环境搭建

与外部服务交互时,编写集成测试是标配操作。虽然现在通常用 Docker 起一个真实 FTP 容器,但 FTP 协议(尤其是被动模式)对端口映射要求较高,在 CI 环境下容易踩坑 ❌。

因此我们选择 MockFtpServer —— 一个纯 Java 实现的轻量级 FTP 模拟服务器,专为单元/集成测试设计,支持动态端口绑定,完美适配自动化测试流程 ✅。

Maven 依赖如下:

<dependency>
    <groupId>commons-net</groupId>
    <artifactId>commons-net</artifactId>
    <version>3.6</version>
</dependency>
<dependency> 
    <groupId>org.mockftpserver</groupId> 
    <artifactId>MockFtpServer</artifactId> 
    <version>2.7.1</version> 
    <scope>test</scope> 
</dependency>

⚠️ 建议始终使用最新稳定版本,可在 Maven CentralMockFtpServer 查看。

3. JDK 内置 FTP 支持分析

你可能不知道,JDK 某些版本中其实自带了基础的 FTP 支持,位于 sun.net.www.protocol.ftp.FtpURLConnection。不过这类内部 API 属于非公开接口,不推荐直接调用。

更规范的做法是通过标准 java.net.URL 接口间接使用:

@Test
public void givenRemoteFile_whenDownloading_thenItIsOnTheLocalFilesystem() throws IOException {
    String ftpUrl = String.format(
      "ftp://user:password@localhost:%d/foobar.txt", fakeFtpServer.getServerControlPort());

    URLConnection urlConnection = new URL(ftpUrl).openConnection();
    InputStream inputStream = urlConnection.getInputStream();
    Files.copy(inputStream, new File("downloaded_buz.txt").toPath());
    inputStream.close();

    assertThat(new File("downloaded_buz.txt")).exists();

    new File("downloaded_buz.txt").delete(); // cleanup
}

看起来挺简洁?但问题来了:这个原生方案 ❌ 不支持列出文件 ❌ 无法设置超时 ❌ 不支持主动/被动模式切换 —— 功能太弱鸡,仅适用于最简单的下载场景。

所以我们还是老老实实用 Apache Commons Net 吧,功能全、文档多、社区稳。

4. 连接 FTP 服务器

首先封装一个 FtpClient 类,作为对 Apache Commons Net 的高层抽象:

class FtpClient {

    private String server;
    private int port;
    private String user;
    private String password;
    private FTPClient ftp;

    // 构造函数略

    void open() throws IOException {
        ftp = new FTPClient();

        ftp.addProtocolCommandListener(new PrintCommandListener(new PrintWriter(System.out)));

        ftp.connect(server, port);
        int reply = ftp.getReplyCode();
        if (!FTPReply.isPositiveCompletion(reply)) {
            ftp.disconnect();
            throw new IOException("Exception in connecting to FTP Server");
        }

        ftp.login(user, password);
    }

    void close() throws IOException {
        ftp.disconnect();
    }
}

关键点说明:

  • PrintCommandListener:打印底层通信日志,调试时非常有用(类似 telnet 连接 FTP 的输出)
  • ✅ 必须检查 getReplyCode() 返回值,判断连接是否真正成功
  • ✅ 登录后建议设置文件传输模式(如二进制),本文示例省略,生产环境务必加上

集成测试初始化

使用 JUnit 的 @Before@After 统一管理资源生命周期:

public class FtpClientIntegrationTest {

    private FakeFtpServer fakeFtpServer;
    private FtpClient ftpClient;

    @Before
    public void setup() throws IOException {
        fakeFtpServer = new FakeFtpServer();
        fakeFtpServer.addUserAccount(new UserAccount("user", "password", "/data"));

        FileSystem fileSystem = new UnixFakeFileSystem();
        fileSystem.add(new DirectoryEntry("/data"));
        fileSystem.add(new FileEntry("/data/foobar.txt", "abcdef 1234567890"));
        fakeFtpServer.setFileSystem(fileSystem);
        fakeFtpServer.setServerControlPort(0); // 自动分配空闲端口

        fakeFtpServer.start();

        ftpClient = new FtpClient("localhost", fakeFtpServer.getServerControlPort(), "user", "password");
        ftpClient.open();
    }

    @After
    public void teardown() throws IOException {
        ftpClient.close();
        fakeFtpServer.stop();
    }
}

⚠️ 注意:setServerControlPort(0) 表示由系统自动分配端口,因此创建 FtpClient 时必须通过 getServerControlPort() 获取实际端口号,否则会连不上。

5. 列出远程文件

先写测试,走 TDD 流程:

@Test
public void givenRemoteFile_whenListingRemoteFiles_thenItIsContainedInList() throws IOException {
    Collection<String> files = ftpClient.listFiles("");
    assertThat(files).contains("foobar.txt");
}

实现也很简单,把 FTPFile[] 转成 List<String> 提高可用性:

Collection<String> listFiles(String path) throws IOException {
    FTPFile[] files = ftp.listFiles(path);
    return Arrays.stream(files)
      .map(FTPFile::getName)
      .collect(Collectors.toList());
}

💡 提示:如果需要文件大小、修改时间等元信息,直接返回 FTPFile[] 或封装成 DTO 更合适。

6. 下载文件

定义接口:指定远程路径和本地目标文件。

测试用例:

@Test
public void givenRemoteFile_whenDownloading_thenItIsOnTheLocalFilesystem() throws IOException {
    ftpClient.downloadFile("/foobar.txt", "downloaded_buz.txt");
    assertThat(new File("downloaded_buz.txt")).exists();
    new File("downloaded_buz.txt").delete(); // cleanup
}

实现利用 retrieveFile(String, OutputStream) 直接写入文件流:

void downloadFile(String source, String destination) throws IOException {
    FileOutputStream out = new FileOutputStream(destination);
    ftp.retrieveFile(source, out);
    out.close(); // 实际项目建议 try-with-resources
}

✅ 小技巧:可以用 Files.newOutputStream() 替代 FileOutputStream,代码更现代。

7. 上传文件

MockFtpServer 提供了 getFileSystem().exists() 方法,可用于验证文件是否上传成功:

@Test
public void givenLocalFile_whenUploadingIt_thenItExistsOnRemoteLocation() 
  throws URISyntaxException, IOException {
  
    File file = new File(getClass().getClassLoader().getResource("baz.txt").toURI());
    ftpClient.putFileToPath(file, "/buz.txt");
    assertThat(fakeFtpServer.getFileSystem().exists("/buz.txt")).isTrue();
}

上传使用 storeFile(String, InputStream) 接口:

void putFileToPath(File file, String path) throws IOException {
    ftp.storeFile(path, new FileInputStream(file));
}

⚠️ 注意事项:

  • 上传大文件时建议设置缓冲区或分块传输
  • 生产环境务必用 try-with-resources 管理流,防止资源泄露
  • 建议上传前先检查目标路径权限和空间

8. 总结

通过 Apache Commons Net,Java 程序可以轻松实现对 FTP 服务器的完整操作,包括连接管理、文件列表、上传下载等核心功能。

搭配 MockFtpServer 后,还能写出稳定可靠的集成测试,无需依赖外部环境,CI/CD 友好 ✅。

示例代码已托管至 GitHub:https://github.com/eugenp/tutorials/tree/master/libraries-apache-commons-2


原始标题:Implementing a FTP-Client in Java | Baeldung