天天看點

記錄 FTPClient 逾時處理的相關問題問題源碼跟進結論常見異常

記錄 FTPClient 逾時處理的相關問題問題源碼跟進結論常見異常

apache 有個開源庫:commons-net,這個開源庫中包括了各種基礎的網絡工具類,我使用了這個開源庫中的 FTP 工具。

但碰到一些問題,并不是說是開源庫的 bug,可能鍋得算在産品頭上吧,各種奇怪需求。

問題

當将網絡限速成 1KB/S 時,使用 commons-net 開源庫中的 FTPClient 上傳本地檔案到 FTP 伺服器上,FTPClient 源碼内部是通過 Socket 來實作傳輸的,當終端和伺服器建立了連接配接,調用 

storeFile()

 開始上傳檔案時,由于網絡限速問題,一直沒有接收到是否傳輸結束的回報,導緻此時,目前線程一直卡在 

storeFile()

,後續代碼一直無法執行。

如果這個時候去 FTP 伺服器上檢視一下,會發現,新建立了一個 0KB 的檔案,但本地檔案中的資料内容就是沒有上傳上來。

産品要求,需要有個逾時處理,比如上傳工作超過了 30s 就當做上傳失敗,逾時處理。但我明明調用了 FTPClient 的相關逾時設定接口,就是沒有一個會生效。

一句話簡述下上述的場景問題:

網絡限速時,為何 FTPClient 設定了逾時時間,但檔案上傳過程中逾時機制卻一直沒生效?

一氣之下,幹脆跟進 FTPClient 源碼内部,看看為何設定的逾時失效了,沒有起作用。

是以,本篇也就是梳理下 FTPClient 中相關逾時接口的含義,以及如何處理上述場景中的逾時功能。

源碼跟進

先來講講對 FTPClient 的淺入學習過程吧,如果不感興趣,直接跳過該節,看後續小節的結論就可以了。

ps:本篇所使用的 commons-net 開源庫版本為 3.6

使用

首先,先來看看,使用 FTPClient 上傳檔案到 FTP 伺服器大概需要哪些步驟:

//1.與 FTP 伺服器建立連接配接
ftpClient.connect(hostUrl, port);
//2.登入
ftpClient.login(username, password);
//3.進入到指定的上傳目錄中
ftpClient.makeDirectory(remotePath);
ftpClient.changeWorkingDirectory(remotePath);
//4.開始上傳檔案到FTP
ftpClient.storeFile(file.getName(), fis);      

當然,中間省略其他的配置項,比如設定主動模式、被動模式,設定每次讀取本地檔案的緩沖大小,設定檔案類型,設定逾時等等。但大體上,使用 FTPClient 來上傳檔案到 FTP 伺服器的步驟就是這麼幾個。

既然本篇主要是想理清逾時為何沒生效,那麼也就先來看看都有哪些設定逾時的接口:

粗體字是 FTPClient 類中提供的方法,而 FTPClient 的繼承關系如下:

FTPClient extends FTP extends SocketClient      

非粗體字的方法都是 SocketClient 中提供的方法。

好,先清楚有這麼幾個設定逾時的接口存在,後面再從跟進源碼過程中,一個個來了解它們。

跟進

1. connect()

那麼,就先看看第一步的 

connect()

//SocketClient#connect()
public void connect(String hostname, int port) throws SocketException, IOException {
    _hostname_ = hostname;
    _connect(InetAddress.getByName(hostname), port, null, -1);
}

//SocketClient#_connect()
private void _connect(InetAddress host, int port, InetAddress localAddr, int localPort) throws SocketException, IOException {
    //1.建立socket
    _socket_ = _socketFactory_.createSocket();
    //2.設定發送視窗和接收視窗的緩沖大小
    if (receiveBufferSize != -1) {
        _socket_.setReceiveBufferSize(receiveBufferSize);
    }
    if (sendBufferSize != -1) {
        _socket_.setSendBufferSize(sendBufferSize);
    }
    //3.socket(套接字:ip 和 port 組成)
    if (localAddr != null) {
        _socket_.bind(new InetSocketAddress(localAddr, localPort));
    }
    //4.連接配接,這裡出現 connectTimeout 了
    _socket_.connect(new InetSocketAddress(host, port), connectTimeout);
    _connectAction_();
}      

是以, FTPClient 調用的 

connect()

 方法其實是調用父類的方法,這個過程會去建立用戶端 Socket,并和指定的服務端的 ip 和 port 建立連接配接,這個過程中,出現了一個 connectTimeout,與之對應的 FTPClient 的逾時接口:

//SocketClient#setConnectTimeout()
public void setConnectTimeout(int connectTimeout) {
    this.connectTimeout = connectTimeout;
}      

至于内部是如何建立計時器,并在逾時後是如何抛出 SocketTimeoutException 異常的,就不跟進了,有興趣自行去看,這裡就看一下接口的注釋:

/**
     * Connects this socket to the server with a specified timeout value.
     * A timeout of zero is interpreted as an infinite timeout. The connection
     * will then block until established or an error occurs.
     * (用該 socket 與服務端建立連接配接,并設定一個指定的逾時時間,如果逾時時間是0,表示逾時時間為無窮大,
     *  建立連接配接這個過程會進入阻塞狀态,直到連接配接建立成功,或者發生某個異常錯誤)
     * @param   endpoint the {@code SocketAddress}
     * @param   timeout  the timeout value to be used in milliseconds.
     * @throws  IOException if an error occurs during the connection
     * @throws  SocketTimeoutException if timeout expires before connecting
     * @throws  java.nio.channels.IllegalBlockingModeException
     *          if this socket has an associated channel,
     *          and the channel is in non-blocking mode
     * @throws  IllegalArgumentException if endpoint is null or is a
     *          SocketAddress subclass not supported by this socket
     * @since 1.4
     * @spec JSR-51
     */
public void connect(SocketAddress endpoint, int timeout) throws IOException {
}      

注釋有大概翻譯了下,總之到這裡,先搞清一個逾時接口的作用了,雖然從方法命名上也可以看出來了:

setConnectTimeout()

: 用于設定終端和伺服器建立連接配接這個過程的逾時時間。

還有一點需要注意,當終端和服務端建立連接配接這個過程中,目前線程會進入阻塞狀态,即常說的同步請求操作,直到連接配接成功或失敗,後續代碼才會繼續進行。

當連接配接建立成功後,會調用 

_connectAction_()

,看看:

//SocketClient#_connectAction_()
protected void _connectAction_() throws IOException {
    _socket_.setSoTimeout(_timeout_);
    //...
}      

這裡又出現一個 _timeout_ 了,看看它對應的 FTPClient 的逾時接口:

//SocketClient#setDefaultTimeout()
public void setDefaultTimeout(int timeout){
    _timeout_ = timeout;
}      

setDefaultTimeout()

 :用于當終端與服務端建立完連接配接後,初步對用于傳輸控制指令的 Socket 調用 

setSoTimeout()

 設定逾時,是以,這個逾時具體是何作用,取決于 Socket 的 

setSoTimeout()

另外,還記得 FTPClient 也有這麼個逾時接口麼:

//SocketClient#setSoTimeout()
public void setSoTimeout(int timeout) throws SocketException {
    _socket_.setSoTimeout(timeout);
}      

是以,對于 FTPClient 而言,

setDefaultTimeout()

 逾時的工作跟 

setSoTimeout()

 是相同的,差別僅在于後者會覆寫掉前者設定的值。

2. login()

接下去看看其他步驟的方法:

//FTPClient#login()
public boolean login(String username, String password) throws IOException {
    //...
    user(username);
    //...
    return FTPReply.isPositiveCompletion(pass(password));
}

//FTP#user()
public int user(String username) throws IOException {
    return sendCommand(FTPCmd.USER, username);
}

//FTP#pass()
public int pass(String password) throws IOException {
    return sendCommand(FTPCmd.PASS, password);
}      

是以,login 主要是發送 FTP 協定的一些控制指令,因為連接配接已經建立成功,終端發送的 FTP 控制指令給 FTP 伺服器,完成一些操作,比如登入,比如建立目錄,進入某個指定路徑等等。

這些步驟過程中,沒看到跟逾時相關的處理,是以,看看最後一步上傳檔案的操作:

3. storeFile

//FTPClient#storeFile()
public boolean storeFile(String remote, InputStream local) throws IOException {
    return __storeFile(FTPCmd.STOR, remote, local);
}

//FTPClient#__storeFile()
private boolean __storeFile(FTPCmd command, String remote, InputStream local) throws IOException {
    return _storeFile(command.getCommand(), remote, local);
}

//FTPClient#_storeFile()
protected boolean _storeFile(String command, String remote, InputStream local) throws IOException {
    //1. 建立并連接配接用于傳輸 FTP 資料的 Socket
    Socket socket = _openDataConnection_(command, remote);
    //...
    //2. 設定傳輸監聽,這裡出現了一個timeout
    CSL csl = null;
    if (__controlKeepAliveTimeout > 0) {
        csl = new CSL(this, __controlKeepAliveTimeout, __controlKeepAliveReplyTimeout);
    }

    // Treat everything else as binary for now
    try {
        //3.開始發送本地資料到FTP伺服器
        Util.copyStream(local, output, getBufferSize(), CopyStreamEvent.UNKNOWN_STREAM_SIZE, __mergeListeners(csl), false);
    }
    //...
}      

我們在學習 FTP 協定的端口時,還記得麼,通常 20 端口是資料端口,21 端口是控制端口,當然這并不固定。但總體上,整個過程分兩步:一是先建立用于傳輸控制指令的連接配接,二是再建立用于傳輸資料的連接配接。

是以,當調用 

_storeFile()

 上傳檔案時,會再通過 

_openDataConnection_()

 建立一個用于傳輸資料的 Socket,并與服務端連接配接,連接配接成功後,就會通過 Util 的 

copyStream()

 将本地檔案 copy 到用于傳輸資料的這個 Socket 的 OutputStream 輸出流上,此時,Socket 底層會自動去按照 TCP 協定往發送視窗中寫資料來發給伺服器。

這個步驟涉及到很多逾時處理的地方,是以就來看看,首先是 

_openDataConnection_()

 :

//FTPClient#_openDataConnection_()
protected Socket _openDataConnection_(String command, String arg) throws IOException {
    //...
    Socket socket;
    //...
    //1. 根據被動模式或主動模式建立不同的 Socket 配置
    if (__dataConnectionMode == ACTIVE_LOCAL_DATA_CONNECTION_MODE) {
        //...
    } else { // We must be in PASSIVE_LOCAL_DATA_CONNECTION_MODE
        //...
        //2. 我項目中使用的是被動模式,是以我隻看這個分支了
        //3. 建立用于傳輸資料的 Socket
        socket = _socketFactory_.createSocket();
        //...
        //4. 對這個傳輸資料的 Socket 設定了 SoTimeout 逾時
        if (__dataTimeout >= 0) {
            socket.setSoTimeout(__dataTimeout);
        }

        //5. 跟服務端建立連接配接,指定逾時處理
        socket.connect(new InetSocketAddress(__passiveHost, __passivePort), connectTimeout);
        //...        
    }

    //...
    return socket;
}      

是以,建立用于傳輸資料的 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#_storeFile()
protected boolean _storeFile(String command, String remote, InputStream local) throws IOException {
    //...
    //2. 設定傳輸監聽
    CSL csl = null;
    if (__controlKeepAliveTimeout > 0) {
        csl = new CSL(this, __controlKeepAliveTimeout, __controlKeepAliveReplyTimeout);
    }
    // Treat everything else as binary for now
    try {
        //3.開始發送本地資料到FTP伺服器
        Util.copyStream(local, output, getBufferSize(), CopyStreamEvent.UNKNOWN_STREAM_SIZE, __mergeListeners(csl), false);
    }
}

//FTPClient#setControlKeepAliveTimeout()
public void setControlKeepAliveTimeout(long controlIdle){
    __controlKeepAliveTimeout = controlIdle * 1000;
}
//FTPClient#setControlKeepAliveReplyTimeout()
public void setControlKeepAliveReplyTimeout(int timeout) {
    __controlKeepAliveReplyTimeout = timeout;
}      

FTPClient 的最後兩個逾時接口也找到使用的地方了,那麼就看看 CSL 内部類是如何處理這兩個 timeout 的:

//FTPClient$CSL
private static class CSL implements CopyStreamListener {
    CSL(FTPClient parent, long idleTime, int maxWait) throws SocketException {
        this.idle = idleTime;
        //...
        parent.setSoTimeout(maxWait);
    }
    
    //每次讀取檔案的過程,都讓傳輸控制指令的 Socket 發送一個無任何操作的 NOOP 指令,以便讓這個 Socket keep alive
    @Override
    public void bytesTransferred(long totalBytesTransferred,
        int bytesTransferred, long streamSize) {
        long now = System.currentTimeMillis();
        if ((now - time) > idle) {
            try {
                parent.__noop();
            } catch (SocketTimeoutException e) {
                notAcked++;
            } catch (IOException e) {
                // Ignored
            }
            time = now;
        }
    }
}      

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()
   /**
     *  Enable/disable {@link SocketOptions#SO_TIMEOUT SO_TIMEOUT}
     *  with the specified timeout, in milliseconds. With this option set
     *  to a non-zero timeout, a read() call on the InputStream associated with
     *  this Socket will block for only this amount of time.  If the timeout
     *  expires, a <B>java.net.SocketTimeoutException</B> is raised, though the
     *  Socket is still valid. The option <B>must</B> be enabled
     *  prior to entering the blocking operation to have effect. The
     *  timeout must be {@code > 0}.
     *  A timeout of zero is interpreted as an infinite timeout.
     *  (設定一個逾時時間,用來當這個 Socket 調用了 read() 從 InputStream 輸入流中
     *    讀取資料的過程中,如果線程進入了阻塞狀态,那麼這次阻塞的過程耗費的時間如果
     *    超過了設定的逾時時間,就會抛出一個 SocketTimeoutException 異常,但隻是将
     *    線程從讀資料這個過程中斷掉,并不影響 Socket 的後續使用。
     *    如果逾時時間為0,表示無限長。)
     *  (注意,并不是讀取輸入流的整個過程的逾時時間,而僅僅是每一次進入阻塞等待輸入流中
     *    有資料可讀的逾時時間)
     * @param timeout the specified timeout, in milliseconds.
     * @exception SocketException if there is an error
     * in the underlying protocol, such as a TCP error.
     * @since   JDK 1.1
     * @see #getSoTimeout()
     */
public synchronized void setSoTimeout(int timeout) throws SocketException {
    //...
}

//SocketOptions#SO_TIMEOUT
   /** Set a timeout on blocking Socket operations:
     * (設定一個逾時時間,用于處理一些會陷入阻塞的 Socket 操作的逾時處理,比如:)
     * <PRE>
     * ServerSocket.accept();
     * SocketInputStream.read();
     * DatagramSocket.receive();
     * </PRE>
     *
     * <P> The option must be set prior to entering a blocking
     * operation to take effect.  If the timeout expires and the
     * operation would continue to block,
     * <B>java.io.InterruptedIOException</B> is raised.  The Socket is
     * not closed in this case.
     * (設定這個逾時的操作必須要在 Socket 那些會陷入阻塞的操作之前才能生效,
     *   當逾時時間到了,而目前還處于阻塞狀态,那麼會抛出一個異常,但此時 Socket 并沒有被關閉)
     *
     * <P> Valid for all sockets: SocketImpl, DatagramSocketImpl
     *
     * @see Socket#setSoTimeout
     * @see ServerSocket#setSoTimeout
     * @see DatagramSocket#setSoTimeout
     */
@Native public final static int SO_TIMEOUT = 0x1006;      

以上的翻譯是基于我的了解,我自行的翻譯,也許不那麼正确,你們也可以直接看英文。

或者是看看這篇文章:關于 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 層遲遲接收不到上傳成功與否的回調通知。

最後我的處理是,在業務層面,自己寫了逾時處理。

注意,以上分析的場景是: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 系統規定

org.apache.commons.net.io.CopyStreamException: IOException caught while copying.
        at org.apache.commons.net.io.Util.copyStream(Util.java:136)
        at org.apache.commons.net.ftp.FTPClient._storeFile(FTPClient.java:675)
        at org.apache.commons.net.ftp.FTPClient.__storeFile(FTPClient.java:639)
        at org.apache.commons.net.ftp.FTPClient.storeFile(FTPClient.java:2030)
        at com.chinanetcenter.component.log.FtpUploadTask.run(FtpUploadTask.java:121)
Caused by: java.net.SocketException: sendto failed: ETIMEDOUT (Connection timed out)
        at libcore.io.IoBridge.maybeThrowAfterSendto(IoBridge.java:546)
        at libcore.io.IoBridge.sendto(IoBridge.java:515)
        at java.net.PlainSocketImpl.write(PlainSocketImpl.java:504)
        at java.net.PlainSocketImpl.access$100(PlainSocketImpl.java:37)
        at java.net.PlainSocketImpl$PlainSocketOutputStream.write(PlainSocketImpl.java:266)
        at java.io.BufferedOutputStream.write(BufferedOutputStream.java:174)
        at      

分析:異常的關鍵資訊:ETIMEOUT。

可能的場景:由于網絡被限速 1KB/S,終端的 Socket 發給服務端的封包一直收不到 ACK 确認封包(原因不懂),導緻發送緩沖區一直處于滿的狀态,導緻 FTPClient 的 

storeFile()

 一直陷入阻塞。而如果一個 Socket 一直處于阻塞狀态,TCP 的 keeplive 機制通常會每隔 75s 發送一次探測包,一共 9 次,如果都沒有回應,則會抛出如上異常。

可能還有其他場景,上述場景是我所碰到的,FTPClient 的 

setDataTimeout()

 設定了逾時,但沒生效,原因上述已經分析過了,最後過了十來分鐘自己抛了逾時異常,至于為什麼會抛了一次,看了下篇文章裡的分析,感覺對得上我這種場景。

具體原理參數:淺談TCP/IP網絡程式設計中socket的行為

2. retrieveFile 下載下傳檔案逾時

org.apache.commons.net.io.CopyStreamException: IOException caught while copying.
        at org.apache.commons.net.io.Util.copyStream(Util.java:136)
        at org.apache.commons.net.ftp.FTPClient._retrieveFile(FTPClient.java:1920)
        at org.apache.commons.net.ftp.FTPClient.retrieveFile(FTPClient.java:1885)
        at com.chinanetcenter.component.log.FtpUploadTask.run(FtpUploadTask.java:143)
Caused by: java.net.SocketTimeoutException
        at java.net.PlainSocketImpl.read(PlainSocketImpl.java:488)
        at java.net.PlainSocketImpl.access$000(PlainSocketImpl.java:37)
        at java.net.PlainSocketImpl$PlainSocketInputStream.read(PlainSocketImpl.java:237)
        at java.io.InputStream.read(InputStream.java:162)
        at java.io.BufferedInputStream.fillbuf(BufferedInputStream.java:149)
        at java.io.BufferedInputStream.read(BufferedInputStream.java:234)
        at java.io.PushbackInputStream.read(PushbackInputStream.java:146)      

分析:該異常注意跟第一種場景的異常區分開,注意看異常棧中的第一個異常資訊,這裡是由于 read 過程的逾時而抛出的異常,而這個逾時就是對 Socket 設定了 

setSoTimeout()

,歸根到 FTPClient 的話,就是調用了 

setDataTimeout()

 設定了傳輸資料用的 Socket 的 SoTimeout,由于是檔案下載下傳操作,是對 Socket 的輸入流進行的操作,是以這個逾時機制可以正常運作。

2. Socket 建立連接配接逾時異常

java.net.SocketTimeoutException: failed to connect to /123.103.23.202 (port 2121) after 500ms
        at libcore.io.IoBridge.connectErrno(IoBridge.java:169)
        at libcore.io.IoBridge.connect(IoBridge.java:122)
        at java.net.PlainSocketImpl.connect(PlainSocketImpl.java:183)
        at java.net.PlainSocketImpl.connect(PlainSocketImpl.java:456)
        at java.net.Socket.connect(Socket.java:882)
        at org.apache.commons.net.SocketClient._connect(SocketClient.java:243)
        at org.apache.commons.net.SocketClient.connect(SocketClient.java:202)
        at com.chinanetcenter.component.log.FtpUploadTask.run(FtpUploadTask.java:93)      

分析:這是由于 Socket 在建立連接配接時逾時的異常,通常是 TCP 的三次握手,這個連接配接對應着 FTPClient 的 

connect()

 方法,其實關鍵是 Socket 的 

connect()

 方法,在 FTPClient 的 

stroreFile()

 方法内部由于需要建立用于傳輸的 Socket,也會有這個異常出現的可能。

另外,這個逾時時長的設定由 FTPClient 的 

setConnectTimeout()

 決定。

3. 其他 TCP 錯誤

參考:TCP/IP錯誤清單 ,下面是部分截圖:

apache 有個開源庫:commons-net,這個開源庫中包括了各種基礎的網絡工具類,我使用了這個開源庫中的 FTP 工具。

但碰到一些問題,并不是說是開源庫的 bug,可能鍋得算在産品頭上吧,各種奇怪需求。

問題

當将網絡限速成 1KB/S 時,使用 commons-net 開源庫中的 FTPClient 上傳本地檔案到 FTP 伺服器上,FTPClient 源碼内部是通過 Socket 來實作傳輸的,當終端和伺服器建立了連接配接,調用 

storeFile()

 開始上傳檔案時,由于網絡限速問題,一直沒有接收到是否傳輸結束的回報,導緻此時,目前線程一直卡在 

storeFile()

,後續代碼一直無法執行。

如果這個時候去 FTP 伺服器上檢視一下,會發現,新建立了一個 0KB 的檔案,但本地檔案中的資料内容就是沒有上傳上來。

産品要求,需要有個逾時處理,比如上傳工作超過了 30s 就當做上傳失敗,逾時處理。但我明明調用了 FTPClient 的相關逾時設定接口,就是沒有一個會生效。

一句話簡述下上述的場景問題:

網絡限速時,為何 FTPClient 設定了逾時時間,但檔案上傳過程中逾時機制卻一直沒生效?

一氣之下,幹脆跟進 FTPClient 源碼内部,看看為何設定的逾時失效了,沒有起作用。

是以,本篇也就是梳理下 FTPClient 中相關逾時接口的含義,以及如何處理上述場景中的逾時功能。

源碼跟進

先來講講對 FTPClient 的淺入學習過程吧,如果不感興趣,直接跳過該節,看後續小節的結論就可以了。

ps:本篇所使用的 commons-net 開源庫版本為 3.6

使用

首先,先來看看,使用 FTPClient 上傳檔案到 FTP 伺服器大概需要哪些步驟:

//1.與 FTP 伺服器建立連接配接
ftpClient.connect(hostUrl, port);
//2.登入
ftpClient.login(username, password);
//3.進入到指定的上傳目錄中
ftpClient.makeDirectory(remotePath);
ftpClient.changeWorkingDirectory(remotePath);
//4.開始上傳檔案到FTP
ftpClient.storeFile(file.getName(), fis);      

當然,中間省略其他的配置項,比如設定主動模式、被動模式,設定每次讀取本地檔案的緩沖大小,設定檔案類型,設定逾時等等。但大體上,使用 FTPClient 來上傳檔案到 FTP 伺服器的步驟就是這麼幾個。

既然本篇主要是想理清逾時為何沒生效,那麼也就先來看看都有哪些設定逾時的接口:

粗體字是 FTPClient 類中提供的方法,而 FTPClient 的繼承關系如下:

FTPClient extends FTP extends SocketClient      

非粗體字的方法都是 SocketClient 中提供的方法。

好,先清楚有這麼幾個設定逾時的接口存在,後面再從跟進源碼過程中,一個個來了解它們。

跟進

1. connect()

那麼,就先看看第一步的 

connect()

//SocketClient#connect()
public void connect(String hostname, int port) throws SocketException, IOException {
    _hostname_ = hostname;
    _connect(InetAddress.getByName(hostname), port, null, -1);
}

//SocketClient#_connect()
private void _connect(InetAddress host, int port, InetAddress localAddr, int localPort) throws SocketException, IOException {
    //1.建立socket
    _socket_ = _socketFactory_.createSocket();
    //2.設定發送視窗和接收視窗的緩沖大小
    if (receiveBufferSize != -1) {
        _socket_.setReceiveBufferSize(receiveBufferSize);
    }
    if (sendBufferSize != -1) {
        _socket_.setSendBufferSize(sendBufferSize);
    }
    //3.socket(套接字:ip 和 port 組成)
    if (localAddr != null) {
        _socket_.bind(new InetSocketAddress(localAddr, localPort));
    }
    //4.連接配接,這裡出現 connectTimeout 了
    _socket_.connect(new InetSocketAddress(host, port), connectTimeout);
    _connectAction_();
}      

是以, FTPClient 調用的 

connect()

 方法其實是調用父類的方法,這個過程會去建立用戶端 Socket,并和指定的服務端的 ip 和 port 建立連接配接,這個過程中,出現了一個 connectTimeout,與之對應的 FTPClient 的逾時接口:

//SocketClient#setConnectTimeout()
public void setConnectTimeout(int connectTimeout) {
    this.connectTimeout = connectTimeout;
}      

至于内部是如何建立計時器,并在逾時後是如何抛出 SocketTimeoutException 異常的,就不跟進了,有興趣自行去看,這裡就看一下接口的注釋:

/**
     * Connects this socket to the server with a specified timeout value.
     * A timeout of zero is interpreted as an infinite timeout. The connection
     * will then block until established or an error occurs.
     * (用該 socket 與服務端建立連接配接,并設定一個指定的逾時時間,如果逾時時間是0,表示逾時時間為無窮大,
     *  建立連接配接這個過程會進入阻塞狀态,直到連接配接建立成功,或者發生某個異常錯誤)
     * @param   endpoint the {@code SocketAddress}
     * @param   timeout  the timeout value to be used in milliseconds.
     * @throws  IOException if an error occurs during the connection
     * @throws  SocketTimeoutException if timeout expires before connecting
     * @throws  java.nio.channels.IllegalBlockingModeException
     *          if this socket has an associated channel,
     *          and the channel is in non-blocking mode
     * @throws  IllegalArgumentException if endpoint is null or is a
     *          SocketAddress subclass not supported by this socket
     * @since 1.4
     * @spec JSR-51
     */
public void connect(SocketAddress endpoint, int timeout) throws IOException {
}      

注釋有大概翻譯了下,總之到這裡,先搞清一個逾時接口的作用了,雖然從方法命名上也可以看出來了:

setConnectTimeout()

: 用于設定終端和伺服器建立連接配接這個過程的逾時時間。

還有一點需要注意,當終端和服務端建立連接配接這個過程中,目前線程會進入阻塞狀态,即常說的同步請求操作,直到連接配接成功或失敗,後續代碼才會繼續進行。

當連接配接建立成功後,會調用 

_connectAction_()

,看看:

//SocketClient#_connectAction_()
protected void _connectAction_() throws IOException {
    _socket_.setSoTimeout(_timeout_);
    //...
}      

這裡又出現一個 _timeout_ 了,看看它對應的 FTPClient 的逾時接口:

//SocketClient#setDefaultTimeout()
public void setDefaultTimeout(int timeout){
    _timeout_ = timeout;
}      

setDefaultTimeout()

 :用于當終端與服務端建立完連接配接後,初步對用于傳輸控制指令的 Socket 調用 

setSoTimeout()

 設定逾時,是以,這個逾時具體是何作用,取決于 Socket 的 

setSoTimeout()

另外,還記得 FTPClient 也有這麼個逾時接口麼:

//SocketClient#setSoTimeout()
public void setSoTimeout(int timeout) throws SocketException {
    _socket_.setSoTimeout(timeout);
}      

是以,對于 FTPClient 而言,

setDefaultTimeout()

 逾時的工作跟 

setSoTimeout()

 是相同的,差別僅在于後者會覆寫掉前者設定的值。

2. login()

接下去看看其他步驟的方法:

//FTPClient#login()
public boolean login(String username, String password) throws IOException {
    //...
    user(username);
    //...
    return FTPReply.isPositiveCompletion(pass(password));
}

//FTP#user()
public int user(String username) throws IOException {
    return sendCommand(FTPCmd.USER, username);
}

//FTP#pass()
public int pass(String password) throws IOException {
    return sendCommand(FTPCmd.PASS, password);
}      

是以,login 主要是發送 FTP 協定的一些控制指令,因為連接配接已經建立成功,終端發送的 FTP 控制指令給 FTP 伺服器,完成一些操作,比如登入,比如建立目錄,進入某個指定路徑等等。

這些步驟過程中,沒看到跟逾時相關的處理,是以,看看最後一步上傳檔案的操作:

3. storeFile

//FTPClient#storeFile()
public boolean storeFile(String remote, InputStream local) throws IOException {
    return __storeFile(FTPCmd.STOR, remote, local);
}

//FTPClient#__storeFile()
private boolean __storeFile(FTPCmd command, String remote, InputStream local) throws IOException {
    return _storeFile(command.getCommand(), remote, local);
}

//FTPClient#_storeFile()
protected boolean _storeFile(String command, String remote, InputStream local) throws IOException {
    //1. 建立并連接配接用于傳輸 FTP 資料的 Socket
    Socket socket = _openDataConnection_(command, remote);
    //...
    //2. 設定傳輸監聽,這裡出現了一個timeout
    CSL csl = null;
    if (__controlKeepAliveTimeout > 0) {
        csl = new CSL(this, __controlKeepAliveTimeout, __controlKeepAliveReplyTimeout);
    }

    // Treat everything else as binary for now
    try {
        //3.開始發送本地資料到FTP伺服器
        Util.copyStream(local, output, getBufferSize(), CopyStreamEvent.UNKNOWN_STREAM_SIZE, __mergeListeners(csl), false);
    }
    //...
}      

我們在學習 FTP 協定的端口時,還記得麼,通常 20 端口是資料端口,21 端口是控制端口,當然這并不固定。但總體上,整個過程分兩步:一是先建立用于傳輸控制指令的連接配接,二是再建立用于傳輸資料的連接配接。

是以,當調用 

_storeFile()

 上傳檔案時,會再通過 

_openDataConnection_()

 建立一個用于傳輸資料的 Socket,并與服務端連接配接,連接配接成功後,就會通過 Util 的 

copyStream()

 将本地檔案 copy 到用于傳輸資料的這個 Socket 的 OutputStream 輸出流上,此時,Socket 底層會自動去按照 TCP 協定往發送視窗中寫資料來發給伺服器。

這個步驟涉及到很多逾時處理的地方,是以就來看看,首先是 

_openDataConnection_()

 :

//FTPClient#_openDataConnection_()
protected Socket _openDataConnection_(String command, String arg) throws IOException {
    //...
    Socket socket;
    //...
    //1. 根據被動模式或主動模式建立不同的 Socket 配置
    if (__dataConnectionMode == ACTIVE_LOCAL_DATA_CONNECTION_MODE) {
        //...
    } else { // We must be in PASSIVE_LOCAL_DATA_CONNECTION_MODE
        //...
        //2. 我項目中使用的是被動模式,是以我隻看這個分支了
        //3. 建立用于傳輸資料的 Socket
        socket = _socketFactory_.createSocket();
        //...
        //4. 對這個傳輸資料的 Socket 設定了 SoTimeout 逾時
        if (__dataTimeout >= 0) {
            socket.setSoTimeout(__dataTimeout);
        }

        //5. 跟服務端建立連接配接,指定逾時處理
        socket.connect(new InetSocketAddress(__passiveHost, __passivePort), connectTimeout);
        //...        
    }

    //...
    return socket;
}      

是以,建立用于傳輸資料的 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#_storeFile()
protected boolean _storeFile(String command, String remote, InputStream local) throws IOException {
    //...
    //2. 設定傳輸監聽
    CSL csl = null;
    if (__controlKeepAliveTimeout > 0) {
        csl = new CSL(this, __controlKeepAliveTimeout, __controlKeepAliveReplyTimeout);
    }
    // Treat everything else as binary for now
    try {
        //3.開始發送本地資料到FTP伺服器
        Util.copyStream(local, output, getBufferSize(), CopyStreamEvent.UNKNOWN_STREAM_SIZE, __mergeListeners(csl), false);
    }
}

//FTPClient#setControlKeepAliveTimeout()
public void setControlKeepAliveTimeout(long controlIdle){
    __controlKeepAliveTimeout = controlIdle * 1000;
}
//FTPClient#setControlKeepAliveReplyTimeout()
public void setControlKeepAliveReplyTimeout(int timeout) {
    __controlKeepAliveReplyTimeout = timeout;
}      

FTPClient 的最後兩個逾時接口也找到使用的地方了,那麼就看看 CSL 内部類是如何處理這兩個 timeout 的:

//FTPClient$CSL
private static class CSL implements CopyStreamListener {
    CSL(FTPClient parent, long idleTime, int maxWait) throws SocketException {
        this.idle = idleTime;
        //...
        parent.setSoTimeout(maxWait);
    }
    
    //每次讀取檔案的過程,都讓傳輸控制指令的 Socket 發送一個無任何操作的 NOOP 指令,以便讓這個 Socket keep alive
    @Override
    public void bytesTransferred(long totalBytesTransferred,
        int bytesTransferred, long streamSize) {
        long now = System.currentTimeMillis();
        if ((now - time) > idle) {
            try {
                parent.__noop();
            } catch (SocketTimeoutException e) {
                notAcked++;
            } catch (IOException e) {
                // Ignored
            }
            time = now;
        }
    }
}      

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()
   /**
     *  Enable/disable {@link SocketOptions#SO_TIMEOUT SO_TIMEOUT}
     *  with the specified timeout, in milliseconds. With this option set
     *  to a non-zero timeout, a read() call on the InputStream associated with
     *  this Socket will block for only this amount of time.  If the timeout
     *  expires, a <B>java.net.SocketTimeoutException</B> is raised, though the
     *  Socket is still valid. The option <B>must</B> be enabled
     *  prior to entering the blocking operation to have effect. The
     *  timeout must be {@code > 0}.
     *  A timeout of zero is interpreted as an infinite timeout.
     *  (設定一個逾時時間,用來當這個 Socket 調用了 read() 從 InputStream 輸入流中
     *    讀取資料的過程中,如果線程進入了阻塞狀态,那麼這次阻塞的過程耗費的時間如果
     *    超過了設定的逾時時間,就會抛出一個 SocketTimeoutException 異常,但隻是将
     *    線程從讀資料這個過程中斷掉,并不影響 Socket 的後續使用。
     *    如果逾時時間為0,表示無限長。)
     *  (注意,并不是讀取輸入流的整個過程的逾時時間,而僅僅是每一次進入阻塞等待輸入流中
     *    有資料可讀的逾時時間)
     * @param timeout the specified timeout, in milliseconds.
     * @exception SocketException if there is an error
     * in the underlying protocol, such as a TCP error.
     * @since   JDK 1.1
     * @see #getSoTimeout()
     */
public synchronized void setSoTimeout(int timeout) throws SocketException {
    //...
}

//SocketOptions#SO_TIMEOUT
   /** Set a timeout on blocking Socket operations:
     * (設定一個逾時時間,用于處理一些會陷入阻塞的 Socket 操作的逾時處理,比如:)
     * <PRE>
     * ServerSocket.accept();
     * SocketInputStream.read();
     * DatagramSocket.receive();
     * </PRE>
     *
     * <P> The option must be set prior to entering a blocking
     * operation to take effect.  If the timeout expires and the
     * operation would continue to block,
     * <B>java.io.InterruptedIOException</B> is raised.  The Socket is
     * not closed in this case.
     * (設定這個逾時的操作必須要在 Socket 那些會陷入阻塞的操作之前才能生效,
     *   當逾時時間到了,而目前還處于阻塞狀态,那麼會抛出一個異常,但此時 Socket 并沒有被關閉)
     *
     * <P> Valid for all sockets: SocketImpl, DatagramSocketImpl
     *
     * @see Socket#setSoTimeout
     * @see ServerSocket#setSoTimeout
     * @see DatagramSocket#setSoTimeout
     */
@Native public final static int SO_TIMEOUT = 0x1006;      

以上的翻譯是基于我的了解,我自行的翻譯,也許不那麼正确,你們也可以直接看英文。

或者是看看這篇文章:關于 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 層遲遲接收不到上傳成功與否的回調通知。

最後我的處理是,在業務層面,自己寫了逾時處理。

注意,以上分析的場景是: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 系統規定

org.apache.commons.net.io.CopyStreamException: IOException caught while copying.
        at org.apache.commons.net.io.Util.copyStream(Util.java:136)
        at org.apache.commons.net.ftp.FTPClient._storeFile(FTPClient.java:675)
        at org.apache.commons.net.ftp.FTPClient.__storeFile(FTPClient.java:639)
        at org.apache.commons.net.ftp.FTPClient.storeFile(FTPClient.java:2030)
        at com.chinanetcenter.component.log.FtpUploadTask.run(FtpUploadTask.java:121)
Caused by: java.net.SocketException: sendto failed: ETIMEDOUT (Connection timed out)
        at libcore.io.IoBridge.maybeThrowAfterSendto(IoBridge.java:546)
        at libcore.io.IoBridge.sendto(IoBridge.java:515)
        at java.net.PlainSocketImpl.write(PlainSocketImpl.java:504)
        at java.net.PlainSocketImpl.access$100(PlainSocketImpl.java:37)
        at java.net.PlainSocketImpl$PlainSocketOutputStream.write(PlainSocketImpl.java:266)
        at java.io.BufferedOutputStream.write(BufferedOutputStream.java:174)
        at      

分析:異常的關鍵資訊:ETIMEOUT。

可能的場景:由于網絡被限速 1KB/S,終端的 Socket 發給服務端的封包一直收不到 ACK 确認封包(原因不懂),導緻發送緩沖區一直處于滿的狀态,導緻 FTPClient 的 

storeFile()

 一直陷入阻塞。而如果一個 Socket 一直處于阻塞狀态,TCP 的 keeplive 機制通常會每隔 75s 發送一次探測包,一共 9 次,如果都沒有回應,則會抛出如上異常。

可能還有其他場景,上述場景是我所碰到的,FTPClient 的 

setDataTimeout()

 設定了逾時,但沒生效,原因上述已經分析過了,最後過了十來分鐘自己抛了逾時異常,至于為什麼會抛了一次,看了下篇文章裡的分析,感覺對得上我這種場景。

具體原理參數:淺談TCP/IP網絡程式設計中socket的行為

2. retrieveFile 下載下傳檔案逾時

org.apache.commons.net.io.CopyStreamException: IOException caught while copying.
        at org.apache.commons.net.io.Util.copyStream(Util.java:136)
        at org.apache.commons.net.ftp.FTPClient._retrieveFile(FTPClient.java:1920)
        at org.apache.commons.net.ftp.FTPClient.retrieveFile(FTPClient.java:1885)
        at com.chinanetcenter.component.log.FtpUploadTask.run(FtpUploadTask.java:143)
Caused by: java.net.SocketTimeoutException
        at java.net.PlainSocketImpl.read(PlainSocketImpl.java:488)
        at java.net.PlainSocketImpl.access$000(PlainSocketImpl.java:37)
        at java.net.PlainSocketImpl$PlainSocketInputStream.read(PlainSocketImpl.java:237)
        at java.io.InputStream.read(InputStream.java:162)
        at java.io.BufferedInputStream.fillbuf(BufferedInputStream.java:149)
        at java.io.BufferedInputStream.read(BufferedInputStream.java:234)
        at java.io.PushbackInputStream.read(PushbackInputStream.java:146)      

分析:該異常注意跟第一種場景的異常區分開,注意看異常棧中的第一個異常資訊,這裡是由于 read 過程的逾時而抛出的異常,而這個逾時就是對 Socket 設定了 

setSoTimeout()

,歸根到 FTPClient 的話,就是調用了 

setDataTimeout()

 設定了傳輸資料用的 Socket 的 SoTimeout,由于是檔案下載下傳操作,是對 Socket 的輸入流進行的操作,是以這個逾時機制可以正常運作。

2. Socket 建立連接配接逾時異常

java.net.SocketTimeoutException: failed to connect to /123.103.23.202 (port 2121) after 500ms
        at libcore.io.IoBridge.connectErrno(IoBridge.java:169)
        at libcore.io.IoBridge.connect(IoBridge.java:122)
        at java.net.PlainSocketImpl.connect(PlainSocketImpl.java:183)
        at java.net.PlainSocketImpl.connect(PlainSocketImpl.java:456)
        at java.net.Socket.connect(Socket.java:882)
        at org.apache.commons.net.SocketClient._connect(SocketClient.java:243)
        at org.apache.commons.net.SocketClient.connect(SocketClient.java:202)
        at com.chinanetcenter.component.log.FtpUploadTask.run(FtpUploadTask.java:93)      

分析:這是由于 Socket 在建立連接配接時逾時的異常,通常是 TCP 的三次握手,這個連接配接對應着 FTPClient 的 

connect()

 方法,其實關鍵是 Socket 的 

connect()

 方法,在 FTPClient 的 

stroreFile()

 方法内部由于需要建立用于傳輸的 Socket,也會有這個異常出現的可能。

另外,這個逾時時長的設定由 FTPClient 的 

setConnectTimeout()

 決定。

3. 其他 TCP 錯誤

參考:TCP/IP錯誤清單 ,下面是部分截圖: