1. 程式人生 > >記錄 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 伺服器的步驟就是這麼幾個。

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

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


大家好,我是 dasu,歡迎關注我的公眾號(dasuAndroidTv),如果你覺得本篇內容有幫助到你,可以轉載但記得要關注,要標明原文哦,謝謝支援~
dasuAndroidTv2.png