记录FTPClient 超时处理的相关问题

发布于 2022年 05月 19日 18:33

apache有个开源库: commons-net,这个开源库中包括了各种基础的网络工具类,我使用了这个开源库中的FTP工具。
但碰到一些问题,并不是说是开源库的 bug,可能锅得算在产品头上吧,各种奇怪需求。

问题

当将网络限速成1KB/S时,使用commons-net开源库中的FTPClient 上传本地文件到FTP服务器上,FTPClient源码内部是通过Socket 来实现传输的,当终端和服务器建立了连接,调用storeFile()开始上传文件时,由于网络限速问题,一直没有接收到是否传输结束的反馈,导致此时,当前线程一直卡在storeFile(),后续代码一直无法执行。
如果这个时候去FTP服务器上查看一下,会发现,新创建了一个OKB的文件,但本地文件中的数据内容就是没有上传上来。
产品要求,需要有个超时处理,比如上传工作超过了30s就当做上传失败,超时处理。但我明明调用了FTPClient的相关超时设置接口,就是没有一个会生效。
—句话简述下上述的场景问题:
网络限速时,为何 FTPClient 设置了超时时间,但文件上传过程中超时机制却一直没生效?
一气之下,干脆跟进FTPClient源码内部,看看为何设置的超时失效了,没有起作用。
所以,本篇也就是梳理下FTPClient 中相关超时接口的含义,以及如何处理上述场景中的超时功能。

源码跟进

先来讲讲对FTPClient的浅入学习过程吧,如果不感兴趣,直接跳过该节,看后续小节的结论就可以了。
ps:本篇所使用的commons-net开源库版本为3.6

使用

首先,先来看看,使用FTPClient上传文件到FTP服务器大概需要哪些步骤:

 

 

 

当然,中间省略其他的配置项,比如设置主动模式、被动模式,设置每次读取本地文件的缓冲大小,设置文件类型,设置超时等等。但大体上,使用FTPClient来上传文件到FTP服务器的步骤就是这么几个。
既然本篇主要是想理清超时为何没生效,那么也就先来看看都有哪些设置超时的接口:

粗体字是 FTPClient类中提供的方法,而FTPClient的继承关系如下:

非粗体字的方法都是SocketClient中提供的方法。
好,先清楚有这么几个设置超时的接口存在,后面再从跟进源码过程中,一个个来了解它们。

跟进

1.connect()

那么,就先看看第一步的connect() :

所以,FTPClient调用的connect()方法其实是调用父类的方法,这个过程会去创建客户端Socket,并和指定的服务端的 ip和port创建连接,这个过程中,出现了一个connectTimeout,与之对应的FTPClient 的超时接口:;

至于内部是如何创建计时器,并在超时后是如何抛出SocketTimeoutException异常的,就不跟进了,有兴趣自行去看,这里就看—下接口的注释:

注释有大概翻译了下,总之到这里,先搞清一个超时接口的作用了,虽然从方法命名上也可以看出来了:setConnectTimeout():用于设置终端和服务器建立连接这个过程的超时时间。
还有一点需要注意,当终端和服务端建立连接这个过程中,当前线程会进入阻塞状态,即常说的同步请求操作,直到连接成功或失败,后续代码才会继续进行。
当连接创建成功后,会调用_connectAction_(,看看:

这里又出现一个_timeout_ 了,看看它对应的FTPClient 的超时接口;

setDefaultTimeout()∶用于当终端与服务端创建完连接后,初步对用于传输控制命令的Socket 调用setSoTimeout()设置超时,所以,这个超时具体是何作用,取决于Socket 的setSoTimeout()。
另外,还记得 FTPClient也有这么个超时接口么:

所以,对于FTPClient而言,setDefaultTimeout()超时的工作跟setSoTimeout()是相同的,区别仅在于后者会覆盖掉前者设置的值。
2.login()
接下去看看其他步骤的方法:

 

 

 

所以,login主要是发送FTP协议的一些控制命令,因为连接已经创建成功,终端发送的FTP控制指令给FTP服务器,完成一些操作,比如登录,比如创建目录,进入某个指定路径等等。
这些步骤过程中,没看到跟超时相关的处理,所以,看看最后一步上传文件的操作:
3. storeFile

所以,创建用于传输数据的Socket 跟传输控制命令的Socket区别不是很大,当跟服务端建立连接时也都是用的FTPClient的setconnectTimeout(设置的超时时间处理。有点区别的地方在于,传输控制命令的Socket是当在与服务端建立完连接后才会去设置Socket的SoTimeout,而这个超时时间则来自于调用FTPClient 的 setDefaultTimeout(),和setSoTimeout(),后者设置的值优先。而传输数据的Socket则是在与服务端建立连接之前就设置了Socket的SoTimeout,超时时间值来自于FTPClient的setDataTimeout(。那么,setDataTimeout()也清楚一半了,设置用于传输数据的Socket 的 SoTimeout值。所以,只要能搞清楚,Socket的setSoTimeout()超时究竟指的是对哪个工作过程的超时处理,那么就能够理清楚FTPClient的这些超时接口的用途: setDefaultTimeout() , setSoTimeout() , setDataTimeout()。这个先放一边,继续看_storeFile()流程的第二步:

FTPClient 的最后两个超时接口也找到使用的地方了,那么就看看CSL内部类是如何处理这两个timeout的:

 

 

 CSL是监听copyStream()这个过程的,因为本地文件要上传到服务器,首先,需要先读取本地文件的内容,然后写入到传输数据的Socket 的输出流中,这个过程不可能是一次性完成的,肯定是每次读取一些、写一些,默认每次是读取1KB,可配置。而Socket的输出流缓冲区也不可能可以一直往里写的,它有一个大小限制。底层的具体实现其实也就是TCP的发送窗口,那么这个窗口中的数据自然需要在接收到服务器的ACK确认报文后才会清空,腾出位置以便可以继续写入。

所以,copyStream()是一个会进入阻塞的操作,因为需要取决于网络状况。而setControlKeepAliveTimeout()方法命名中虽然带有timeout 关键字,但实际上它的用途并不是用于处理传输超时工作的。它的用途,其实将方法的命名翻译下就是了:
setControlKeepAliveTimeout():用于设置传输控制命令的Socket的 alive状态,注意单位为s。
因为FTP上传文件过程中,需要用到两个Socket,一个用于传输控制命令,一个用于传输数据,那当处于传输数据过程中时,传输控制命令的Socket 会处于空闲状态,有些路由器可能监控到这个Socket连接处于空闲状态超过一定时间,会进行一些断开等操作。所以,在传输过程中,每读取一次本地文件,传输数据的 Socket每要发送一次报文给服务端时,根据setControlKeepAliveTimeout()设置的时间阈值,来让传输控制命令的Socket也发送一个无任何操作的命令NOOP,以便让路由器以为这个Socket也处于工作状态。这些就是bytesTransferred()方法中的代码干的事。
setControlKeepAliveReplyTimeout():这个只有在调用了setControlKeepAliveTimeout()方法,并传入一个大于0的值后,才会生效,用于在FTP传输数据这个过程,对传输控制命令的Socket 设置SoTimeout,这个传输过程结束后会恢复传输控制命令的Socket原本的SoTimeout配置。
那么,到这里可以稍微来小结一下:
FTPClient一共有6个用于设置超时的接口,而终端与FTP通信过程会创建两个Socket,一个用于传输控制命令,一个用于传输数据。这6个超时接口与两个Socket之间的关系:
setConnectTimeout():用于设置两个Socket与服务器建立连接这个过程的超时时间,单位 ms。
setDefaultTimeout() :用于设置传输控制命令的Socket的SoTimeout,单位 ms。
setSoTimeout():用于设置传输控制命令的Socket的SoTimeout,单位 ms,值会覆盖上个方法设置的值。setDataTimeout():被动模式下,用于设置传输数据的Socket的 SoTimeout,单位 ms,
setControlKeepAliveTimeout():用于在传输数据过程中,也可以让传输控制命令的Socket假装保持处于工作状态,防止被路由器干掉,注意单位是s。
setControlKeepAliveReplyTimeout():只有调用上个方法后,该方法才能生效,用于设置在传输数据这个过程中,暂时替换掉传输控制命令的Socket的SoTimeout,传输过程结束恢复这个Socket原本的SoTimeout。

4.SoTimeout
大部分超时接口最后设置的对象都是Socket的SoTimeout,所以,接下来,学习下这个是什么:

 

 

 

 

 

 

以上的翻译是基于我的理解,我自行的翻译,也许不那么正确,你们也可以直接看英文。
或者是看看这篇文章:关于Socket设置setSoTimeout 误用的说明,文中有一句解释:读取数据时阻塞链路的超时时间
我再基于他的基础上理解一波,我觉得他这句话中有两个重点,一是:读取,二是:阻塞。
这两个重点是理解SoTimeout 超时机制的关键,就像那篇文中所说,很多人将SoTimeout 理解成链路的超时时间,或者这一次传输过程的总超时时间,但这种理解是错误的。
第一点,SoTimeout并不是传输过程的总超时时间,不管是上传文件还是下载文件,服务端和终端肯定是要分多次报文传输的,我对SoTimeout的理解是,它是针对每一次的报文传输过程而已,而不是总的传输过程。
第二点,SoTimeout只针对从Socket输入流中读取数据的操作。什么意思,如果是终端下载FTP服务器的文件,那么服务端会往终端的Socket的输入流中写数据,如果终端接收到了这些数据,那么FTPClient就可以去这个Socket的输入流中读取数据写入到本地文件的输出流。而如果反过来,终端上传文件到FTP服务器,那么FTPClient 是读取本地文件写入终端的Socket的输出流中发送给终端,这时就不是对Socket的输入流操作了。
总之,setSoTimeout()用于设置从Socket 的输入流中读取数据时每次陷入阻塞过程的超时时间。那么,在 FTPClient中,所对应的就是,setsoTimeout()对下述方法有效:
.retrieveFile()
retrieveFilestream()相反的,下述这些方法就无效了:
storeFile(
storeFilestream()
这样就可以解释得通,开头我所提的问题了,在网络被限速之下,由于sotreFile()会陷入阻塞,并且设置的setDataTimeout()超时由于这是一个上传文件的操作,不是对Socket的输入流的读取操作,所以无效。所以,也才会出现线程进入阻塞状态,后续代码一直得不到执行,UI层迟迟接收不到上传成功与否的回调通知。
最后我的处理是,在业务层面,自己写了超时处理。

那么,在 FTPClient中,所对应的就是,setsoTimeout()对下述方法有效:
retrieveFile(
retrieveFilestream()相反的,下述这些方法就无效了:
storeFile()
.storeFilestream()
这样就可以解释得通,开头我所提的问题了,在网络被限速之下,由于sotreFile()会陷入阻塞,并且设置的setDataTimeout()超时由于这是一个上传文件的操作,不是对Socket的输入流的读取操作,所以无效。所以,也才会出现线程进入阻塞状态,后续代码一直得不到执行,UI层迟迟接收不到上传成功与否的回调通知。
最后我的处理是,在业务层面,自己写了超时处理。
注意,以上分析的场景是:FTP被动模式的上传文件的场景下,相关接口的超时处理。所以很多表述都是基于这个场景的前提下,有一些源码,如Util的copyStream()不仅在文件上传中使用,在下载FTP上的文件时也同样使用,所以对于文件上传来说,这方法就是用来读取本地文件写入传输数据的Socket的输出流;而对于下载FTP文件的场景来说,这方法的作用就是用于读取传输数据的Socket的输入流,写入到本地文件的输出流中。以此类推。

结论

总结来说,如果是对于网络开发这方面领域内的来说,这些超时接口的用途应该都是基础,但对于我们这些很少接触Socket的来说,如果单凭接口注释文档无法理解的话,那可以尝试翻阅下源码,理解下。
梳理之后,FTPClient一共有6个设置超时的接口,而不管是文件上传或下载,这过程,FTP都会创建两个Socket,一个用于传输控制命令,一个用于传输文件数据,超时接口和这两个 Socket 之间的关系如下:
.setConnectTimeout()用于设置终端Socket与FTP服务器建立连接这个过程的超时时间。
.setDefaultTimeout()用于设置终端的传输控制命令的Socket的 SoTimeout,即针对传输控制命令的Socket的输入流做读取操作时每次陷入阻塞的超时时间。
. setSoTimeout()作用跟上个方法一样,区别仅在于该方法设置的超时会覆盖掉上个方法设置的值。
.setDataTimeout()用于设置终端的传输数据的 Socket的Sotimeout,即针对传输文件数据的Socket的输入流做读取操作
时每次陷入阻塞的超时时间。
.setControlKeepAliveTimeout()用于设置当处于传输数据过程中,按指定的时间阈值定期让传输控制命令的Socket发送一个无操作命令NOOP给服务器,让它keep alive。
.setControlKeepAliveReplyTimeout():只有调用上个方法后,该方法才能生效,用于设置在传输数据这个过程中,暂时替
换掉传输控制命令的Socket 的SoTimeout,传输过程结束恢复这个Socket 原本的SoTimeout。
超时接口大概的用途明确了,那么再稍微来讲讲该怎么用:
针对使用FTPClient下载FTP文件,一般只需使用两个超时接口,一个是setConnectTimeout(),用于设置建立连接过程中的超时处理,而另一个则是setDataTimeout(),用于设置下载FTP文件过程中的超时处理。
针对使用FTPClient上传文件到FTP服务器,建立连接的超时同样需要使用setConnectTimeout(),但文件上传过程中,建议自行利用Android的Handler或其他机制实现超时处理,因为setDataTimeout()这个设置对上传的过程无效。
另外,使用setDataTimeout()时需要注意,这个超时不是指下载文件整个过程的超时处理,而是仅针对终端Socket 从输入流中,每一次可进行读取操作之前陷入阻塞的超时。
以上,是我所碰到的问题,及梳理的结论,我只以我所遇的现象来理解,因为我对网络编程,对Socket 不熟,如果有错误的地方,欢迎指证一下。

常见异常

最后附上FTPClient文件上传过程中,常见的一些异常,便于针对性的进行分析:
1.storeFile()上传文件超时,该超时时间由Linux系统规定

分析:异常的关键信息:ETIMEOUT。
可能的场景:由于网络被限速1KB/S,终端的Socket 发给服务端的报文一直收不到ACK确认报文(原因不懂),导致发送缓冲区一直处于满的状态,导致FTPClient的storeFile()一直陷入阻塞。而如果一个Socket一直处于阻塞状态,TCP的 keeplive机制通常会每隔75s 发送一次探测包,一共9次,如果都没有回应,则会抛出如上异常。
可能还有其他场景,上述场景是我所碰到的,FTPClient的setDataTimeout()设置了超时,但没生效,原因上述已经分析过了,最后过了十来分钟自己抛了超时异常,至于为什么会抛了一次,看了下篇文章里的分析,感觉对得上我这种场景。
具体原理参数:浅谈TCP/IP网络编程中socket的行为
2.retrieveFile下载文件超时

分析:该异常注意跟第一种场景的异常区分开,注意看异常栈中的第一个异常信息,这里是由于read过程的超时而抛出的异常,而这个超时就是对Socket设置了setSoTimeout(),归根到FTPClient的话,就是调用了setDataTimeout()设置了传输数据用的Socket的 SoTimeout,由于是文件下载操作,是对Socket的输入流进行的操作,所以这个超时机制可以正常运行。
2.Socket建立连接超时异常

 

 分析:这是由于Socket在创建连接时超时的异常,通常是TCP的三次握手,这个连接对应着FTPClient的connect()方法,其实关键是Socket的connect()方法,在FTPClient的stroreFile()方法内部由于需要创建用于传输的Socket,也会有这个异常出现的可能。

另外,这个超时时长的设置由FTPClient 的setConnectTimeout()决定。
3.其他 TCP错误
参考:TCP/IP错误列表,下面是部分截图:

 

推荐文章