FTP客户端对接开发 - yiyixiaozhi/readingNotes GitHub Wiki

[TOC]

FTP客户端对接开发-Java篇

以前抽空部署了ftp服务到本地一台主机上,然后通过fillazilla公网访问可以运行。但是近期想让程序自动上传文件时死活走不通,折腾许久后终于解决,在此总结一下此番技术之旅的心路历程。

开始动身编写实现文件上传到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主动和被动模式,让我们来剖析这两个模式的应用场景:

(1) PORT(主动模式)

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

img

(2) PASV(被动模式)

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

img

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

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

被动模式传送数据是“客户端”连接到“服务器”的端口。

主动模式需要客户端必须开放端口给服务器,很多客户端都是在防火墙内,开放端口给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列出根路径文件,不包含上传文件),FTP指令及服务端反馈详情如下:

文中有两张图出处:https://my.oschina.net/binny/blog/17469,感谢作者Binny

题外

好久没用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
 }
⚠️ **GitHub.com Fallback** ⚠️