FTP 客户端对接开发 Java 篇
开始动身编写实现文件上传到 ftp 服务主机功能之前,本着不重复造轮子的想法,先海搜一个趁手的 jar 包先。
翻了翻一些博客,找到了一个,名字是 commons-net
,那么,这个 jar 包都有什么功能呢?我很好奇,找到了 官方 的介绍,除了支持我们本次关注的 ftp
协议之外呢,还有邮件相关的 IMAP
、 POP3
,如果想本地就能控制服务器,要开始一个 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 技术交流群。

上一篇: ElasticSearch 安装配置
下一篇: 彻底找到 Tomcat 启动速度慢的元凶
绑定邮箱获取回复消息
由于您还没有绑定你的真实邮箱,如果其他用户或者作者回复了您的评论,将不能在第一时间通知您!
发布评论