記錄 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 服務器的步驟就是這麼幾個。

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

setTimeout

粗體字是 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錯誤列表 ,下面是部分截圖:

常見錯誤.png

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章