FTP 客户端对接开发 Java 篇

发布于 2024-05-05 04:20:50 字数 11540 浏览 21 评论 0

开始动身编写实现文件上传到 ftp 服务主机功能之前,本着不重复造轮子的想法,先海搜一个趁手的 jar 包先。

翻了翻一些博客,找到了一个,名字是 commons-net,那么,这个 jar 包都有什么功能呢?我很好奇,找到了 官方 的介绍,除了支持我们本次关注的 ftp 协议之外呢,还有邮件相关的 IMAPPOP3 ,如果想本地就能控制服务器,要开始一个 telnet 会话,这个也是支持的。

Supported protocols include:
- FTP/FTPS
- FTP over HTTP (experimental)
- NNTP
- SMTP(S)
- POP3(S)
- IMAP(S)
- Telnet
- TFTP
- Finger
- Whois
- rexec/rcmd/rlogin
- Time (rdate) and Daytime
- Echo
- Discard
- NTP/SNTP

maven 官网 查询最新的版本,在 pom.xml 追加最新版本的依赖,到我们计划应用的项目代码中:

<dependency>
    <groupId>commons-net</groupId>
    <artifactId>commons-net</artifactId>
    <version>3.6</version>
</dependency>

之前我已经搭建好了 ftp 服务器(这个期间也颇费周折),后面有机会可以专门聊聊这个事。废话不多说。

首先我们要确定的是选择 ftp 主动和被动模式,让我们来剖析这两个模式的应用场景:

PORT(主动模式)

主动模式工作的原理: FTP 客户端连接到 FTP 服务器的 21 端口,发送用户名和密码登录,登录成功后要 list 列表或者读取数据时,客户端随机开放一个端口(1024 以上),发送 PORT 命令到 FTP 服务器,告诉服务器客户端采用主动模式并开放端口;FTP 服务器收到 PORT 主动模式命令和端口号后,通过服务器的 20 端口和客户端开放的端口连接,发送数据。

PASV(被动模式)

PASV 是 Passive 的缩写,被动模式的工作原理:FTP 客户端连接到 FTP 服务器的 21 端口,发送用户名和密码登录,登录成功后要 list 列表或者读取数据时,发送 PASV 命令到 FTP 服务器, 服务器在本地随机开放一个端口(1024 以上),然后把开放的端口告诉客户端, 客户端再连接到服务器开放的端口进行数据传输。

主动模式和被动模式的不同可以简单概述为:

  • 主动模式传送数据时是 服务器 连接到 客户端 的端口
  • 被动模式传送数据是 客户端 连接到 服务器 的端口

主动模式需要客户端必须开放端口给服务器,很多客户端都是在防火墙内,开放端口给 FTP 服务器访问比较困难。 主动模式下,客户端的 FTP 软件设置主动模式开放的端口段,在客户端的防火墙开放对应的端口段。

被动模式只需要服务器端开放端口给客户端连接就行了。FTP 服务器一般都支持主动和被动模式,连接采用何种模式是有 FTP 客户端软件决定。我们这里使用的就是被动模式。在调试这个的过程中,先是从局域网访问开始调试:

编写工具类代码如下:

public class FtpUtil {
    public final static Logger logger = LoggerFactory.getLogger(FtpUtil.class);

    public static FTPClient connectFtpServer() throws IOException {
        FTPClient ftpClient = new FTPClient();
        ftpClient.setConnectTimeout(1000 * 30);//设置连接超时时间
        ftpClient.setControlEncoding("utf-8");//设置 ftp 字符集
        ftpClient.enterLocalPassiveMode();//设置被动模式,文件传输端口设置
        try {
            ftpClient.connect("192.168.1.152", 10021); // 局域网测试地址和端口号,端口号在服务端配置文件 vsftpd.conf 中有设定:listen_port=10021
            ftpClient.login("testlogin", "testPassword");
            int replyCode = ftpClient.getReplyCode();
            if (!FTPReply.isPositiveCompletion(replyCode)) {
                logger.error("connect ftp {} failed");
                ftpClient.disconnect();
                return null;
            }
            logger.info("replyCode", replyCode);
        } catch (IOException e) {
            logger.error("connect fail", e.getCause());
            return null;
        }
        return ftpClient;
    }

    /**
     * @param inputStream 待上传文件的输入流
     * @param originName  文件保存时的名字
     */
    public static void uploadFile(InputStream inputStream, String originName) throws IOException {
        FTPClient ftpClient = connectFtpServer();
        if (ftpClient == null) {
            return;
        }
        try {
            ftpClient.changeWorkingDirectory("/");//进入到文件保存的目录
            FTPFile[] ftpFiles = ftpClient.listFiles();
            for (FTPFile ftpFile : ftpFiles) {
                System.out.println(ftpFile.getName());
            }
            Boolean isSuccess = ftpClient.storeFile(originName, inputStream);//保存文件
            if (!isSuccess) {
                logger.error("上传失败");
            } else {
                logger.info("上传成功");
            }
            ftpClient.logout();
        } catch (IOException e) {
            e.printStackTrace();
            logger.error("上传失败!");
        } finally {
            if (ftpClient.isConnected()) {
                try {
                    ftpClient.disconnect();
                } catch (IOException e) {
                    logger.error("disconnect fail ------->>>{}", e.getCause());
                }
            }
        }
    }
}

指向如下代码,列出 ftp 服务器给 testlogin 用户分配的文件夹的根路径的文件,并且上传一个 txt 文件:

@Test
public void test() throws IOException {
      FtpUtil.connectFtpServer();
    List<String> tmpStrList = Lists.newLinkedList();
    tmpStrList.add("111");
    tmpStrList.add("222");
    tmpStrList.add("333");
    StringBuffer buf = new StringBuffer();
    for (String s : tmpStrList) {
        buf.append(s + "\r\n");
    }
    ByteArrayInputStream inputStream = new ByteArrayInputStream(buf.toString().getBytes("UTF-8"));
    FtpUtil.uploadFile(inputStream, "test3.txt");
}

ftp 客户端和服务器在同一个局域网的情况下,如上代码可以正常执行。

然后我调整客户端的代码,通过 ECS 服务器,将端口映射到局域网的 ftp 服务器上。然后就失败了:

ftpClient.connect("test.bianxh.top", 10021); // ECS 的 10021 端口映射到了本地局域网 ftp 服务器的 10021 端口

修改之后,网络拓扑变化如下:从 FTP.java 中的 void __getReply(boolean reportReply) ​ 方法可以取到到服务器的返回码:

public class FTP extends SocketClient {
    private void __getReply(boolean reportReply) throws IOException {
        // ...
        // 调试_replyLines 变量,可以看到返回码是:500 Illegal PORT command.
        _replyLines.add(line);
        // ...
    }
}

查阅文档,有解释说 500 的含义:无效命令。没办法,继续研究和调试代码,

// 发现 FTPClient 的__initDefaults 在如下一行代码执行时被调用,在此方法中设置了 ftp 采用主动模式。
ftpClient.connect("test.bianxh.top", 10021); 
// 上面连接的使用设置了主动模式,那加上一行代码,在 login 登录前设置客户端为被动模式
ftpClient.enterLocalPassiveMode();
ftpClient.login("testlogin", "testPassword");

继续研究 FTPClient.java 和调试,发现如下的入参中带的参数中说明了服务端给的 HOST 地址有问题:

void _parsePassiveModeReply(String reply) {
    // 入参 reply 的值:227 Entering Passive Mode (127,0,0,1,117,50).
}

那接下来如何要解决这个问题呢?本来我想写个类继承 FTPClient,然后 override _parsePassiveModeReply 方法,但想了想,还是先研究一下代码先,由此开启了一个发现:EPSV 模式(Extended Port/Pasv mode)。

有兴趣的同学也可以看看这篇文章: https://www.cnblogs.com/isrc/p/3229000.html

FTPClient 类的如下方法实现,可以看出来,从服务器的响应中,可以取到 Host 和 port:

protected void _parseExtendedPassiveModeReply(String reply)
    throws MalformedServerReplyException
    {
        // ...
        // in EPSV mode, the passive host address is implicit
        __passiveHost = getRemoteAddress().getHostAddress(); // 取到 Host,对应的是 ESC 服务器的 ipv4 地址
        __passivePort = port; // 取到 port:30005,此端口号在 ftp 服务端配置文件 vsftpd.conf 中 pasv_min_port 和 pasv_max_port 的定义,服务端随机返回
    }

尤其是如下的英文注释,也说明了 EPSV 能解决我遇到的问题(猜测阿里云 ECS 服务器针对独立 IP 做了 NAT 方案),上面的代码也说明,在 EPSV 模式下,被动主机地址是隐含的。

如下的英文我做了简单翻译:

使用带 NAT 的 IPv4 时,EPSV 具有使用更稀有配置的优势。 例如。 如果 FTP 服务器具有静态 PASV 地址(外部网络)并且客户端来自另一个内部网络。在这种情况下,PASV 命令之后的数据连接将失败,而 EPSV 将通过仅接受端口使客户端成功。

protected Socket _openDataConnection_(String command, String arg) {
    // ...
            // We must be in PASSIVE_LOCAL_DATA_CONNECTION_MODE

            // Try EPSV command first on IPv6 - and IPv4 if enabled.
            // When using IPv4 with NAT it has the advantage
            // to work with more rare configurations.
            // E.g. if FTP server has a static PASV address (external network)
            // and the client is coming from another internal network.
            // In that case the data connection after PASV command would fail,
            // while EPSV would make the client succeed by taking just the port.
            boolean attemptEPSV = isUseEPSVwithIPv4() || isInet6Address;
            if (attemptEPSV && epsv() == FTPReply.ENTERING_EPSV_MODE)
            {
                _parseExtendedPassiveModeReply(_replyLines.get(0));
            }
            else
            {
                if (isInet6Address) {
                    return null; // Must use EPSV for IPV6
                }
                // If EPSV failed on IPV4, revert to PASV
                if (pasv() != FTPReply.ENTERING_PASSIVE_MODE) {
                    return null;
                }
                _parsePassiveModeReply(_replyLines.get(0));
            }
}

修改调用代码后,再次运行:

public static FTPClient connectFtpServer() throws IOException {
        FTPClient ftpClient = new FTPClient();
        //...
        ftpClient.setUseEPSVwithIPv4(true); // 这次加入了 EPSV 的设定
        try {
            ftpClient.connect("test.bianxh.top", 10021);
            ftpClient.enterLocalPassiveMode(); // 在 ftp 连接建立之后,手动设置被动模式
            ftpClient.login("testlogin", "testPassword");
            // ...
        } 
        // ...
        return ftpClient;
    }

Soket 连接实现类可以看到 ECS 服务器的 host 拿到了:

class DualStackPlainSocketImpl extends AbstractPlainSocketImpl {
    // ...
    void socketConnect(InetAddress address, int port, int timeout)
        throws IOException {
        // 入参:
        // address(ECS 服务器独立 ip 地址终于出来了):/47.105.138.145
        // port:30005
    }
}

再次试试,成功了!

看看 Log:

列出文件:test
16:39:08.182 [main] INFO top.bianxh.util.FtpUtil - 上传成功 test3.txt

最后我们看下调试结果(包含 LIST 列出根路径文件,不包含上传文件)。

题外

好久没用 graphviz 了,对这篇文章提到的 ftp 主动模式的交互,我编写了两个版本的画图代码,感兴趣的可以研究一下哈:

digraph test {
    label=主动模式
    graph [rankdir=TD]
    node [fontname="Microsoft YaHei" shape=record]
    subgraph cluster_client {
        label="FTP 客户端"
        A [label="登录 FTP 服务器"]
        B [label="登录成功"]
        C [label="随机开放端口"]
        D [label="PORT 命令,\n 读取数据"]
        E [label="随机端口"]
        B->C->D
        C->E
    }
    subgraph cluster_server {
        label="FTP 服务器端"
        L [label="21 端口"]
        M [label="20 端口"]
        L -> M [color=green]
    }
    A->L[label="用户名和密码"]
    L->B
    D->L[label=”随机端口号“]
    M->E
}

效果如下:

由于 ftp 客户端布局比较乱,换了种写法,如下:

digraph G { 
    rankdir=LR 
    node [shape=plaintext] 
    
    subgraph cluster_client {
        label="FTP 客户端"
        a [ label=<
        <table border="1">
          <tr>
            <td PORT="c1">登录 FTP 服务器</td>
          </tr>
          <tr>
            <td height="100" PORT="c2">登录成功</td>
          </tr>
          <tr>
            <td height="100" PORT="c3">随机开放端口</td>
          </tr>
          <tr>
            <td height="100" PORT="c4">PORT 命令,读取数据</td>
          </tr>
        </table>
        > ]
        a:c2 -> a:c3
        a:c3 -> a:c4
    }
        a:c4 -> b:s1[label="随机端口号"]

    subgraph cluster_server {
    
            label="FTP 服务器"
        b [ label=<
          <table border="1" >
            <tr >
              <td height="100" PORT="s1">PORT21</td>
            </tr>
            <tr>
              <td height="100" PORT="s2">PORT20</td>
            </tr>
          </table>

          > ]
          b:s1->b:s2[label="连接到随机端口发送数据"]
      }
 a:c1 -> b:s1 [label="用户名和密码" color ="steelblue"]
 b:s1 -> a:c2 
  b:s2 -> a:c3
 }

如果你对这篇内容有疑问,欢迎到本站社区发帖提问 参与讨论,获取更多帮助,或者扫码二维码加入 Web 技术交流群。

扫码二维码加入Web技术交流群

发布评论

需要 登录 才能够评论, 你可以免费 注册 一个本站的账号。
列表为空,暂无数据

关于作者

微暖i

暂无简介

文章
评论
28 人气
更多

推荐作者

櫻之舞

文章 0 评论 0

弥枳

文章 0 评论 0

m2429

文章 0 评论 0

野却迷人

文章 0 评论 0

我怀念的。

文章 0 评论 0

    我们使用 Cookies 和其他技术来定制您的体验包括您的登录状态等。通过阅读我们的 隐私政策 了解更多相关信息。 单击 接受 或继续使用网站,即表示您同意使用 Cookies 和您的相关数据。
    原文