[VB.net] 如何實現「非同步下載」檔案?(三)使用 Socket 直接和 FTP Server 建立連線下載檔案。

前文提到使用 System.Net.FtpWebRequest 類別從 FTP 取得檔案長度有些不理想的地方,主要有:
1.在某些非微軟 IIS 的 FTP Server 下載時,即便是從 Response 裡面拿到了 ContentLength,數據却還是 –1。
2.雖然可把 Request 的 Method 設為 DownloadFile,然後從 StatusDescription 找到夾在 '(' 和 'Bytes)' 之間的數字把它挖出來但有些不值得了。
3.所以又花了好幾天時間改寫了查詢檔案長度的函式,改用更底層的元件 Socket 和 Server 溝通,直接用 SIZE 命令去查詢。

前文提到使用 System.Net.FtpWebRequest 類別從 FTP 取得檔案長度有些不理想的地方,主要有:

  1. 第一次向 FTP Server 送出 Request 後等待時間過長, 以下是測試時間的報告:

    image
     
  2. 在某些非微軟 IIS 的 FTP Server 下載時,即便是從 Response 裡面拿到了 ContentLength,數據却還是 –1。
  3. 雖然可把 Request 的 Method 設為 DownloadFile,然後從 StatusDescription 找到夾在 '('  和  'Bytes)' 之間的數字把它挖出來但有些不值得了。

 

所以又花了好幾天時間改寫了查詢檔案長度的函式,改用更底層的元件 Socket 和 Server 溝通,直接用 SIZE <Filename> 命令去查詢。

  1. 這部分共有四個函式,程式碼如下:
    1. 。「取得FTP檔案大小」函式入口。
      
      
          Private Function 取得FTP檔案大小(uri As Uri) As Long
              '---解析遠端檔案名稱---
              Dim 用戶連線 As New Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp)
              Dim 遠端_IP As IPAddress = Dns.GetHostEntry(uri.Host).AddressList(0)
              Dim 遠端位址埠號 As New IPEndPoint(遠端_IP, uri.Port)
              Dim 下載檔案 As String = uri.AbsolutePath
              Dim 檔案長度 As Long = 0
              Dim 回應字串 As String = ""
              進行連線(用戶連線, 遠端位址埠號)
              送出命令並等待回應(用戶連線, "USER " & new帳號)
              送出命令並等待回應(用戶連線, "PASS " & new密碼)
              送出命令並等待回應(用戶連線, "TYPE I ")
      
              '---取回檔案長度---
              回應字串 = 送出命令並等待回應(用戶連線, "SIZE " & 下載檔案, 213)
              Select Case CInt(Mid(回應字串, 1, 3))
                  Case 213 : 檔案長度 = Int64.Parse(Mid(回應字串, 4))
                  Case 550 : MsgBox("找不到目錄或檔案!") : Return 0
                  Case Else : MsgBox("!") : Return 0
              End Select
              用戶連線.Close()
              Return 檔案長度
          End Function
      
      

       
    2. 。用 Socket 元件連線至 Server。

      
          Function 進行連線(Socket As Socket, remoteIPEP As IPEndPoint) As String
              Socket.Connect(remoteIPEP)
              Dim 陣列(255) As Byte
              Thread.Sleep(100)
              Socket.Receive(陣列)
              Return System.Text.Encoding.UTF8.GetString(陣列)
          End Function
      
      
    3. 。握手交談訊息。

      
          Public Function 送出命令並等待回應(ByVal 連線名稱 As Socket, 命令 As String, ParamArray 要等待的返回碼() As Integer) As String
              Dim 命令陣列 As Byte() = System.Text.Encoding.UTF8.GetBytes(命令 & vbCrLf)
              連線名稱.Send(命令陣列)
              Return 接收回應(連線名稱, 要等待的返回碼)
          End Function
      
      
    4. 。取回來自 Server 的回應。

      
          Function 接收回應(ByVal 連線名稱 As Socket, 要等待的返回碼() As Integer) As String
              Dim 陣列(255) As Byte
              Dim 回應 As String = ""
              Dim 結果 As String = ""
              Dim 次數 As Integer = 0
              Do
                  連線名稱.Receive(陣列)
                  回應 = System.Text.Encoding.UTF8.GetString(陣列).Replace(vbCrLf, vbCr)
                  次數 += 1
                  '---解出回應內容---
                  Dim tmp = Split(回應, vbCr)                   ' ---可能會有兩次回應連在一起,所以先用斷行符號分解看看。
                  Array.Sort(tmp) : Array.Reverse(tmp)          ' ---排序 Return Code 之後把大的放在前面。
                  For Each i In tmp                             ' ---逐行處理。
                      If Mid(i, 1, 3) Like "###" Then           ' ---若前三字元為數字。
                          If 要等待的返回碼.Length > 0 Then     ' ---呼叫端要等待特定的 Code。 
                              For Each code In 要等待的返回碼   ' ---等到了就傳回整個字串。
                                  If code = Int32.Parse(Mid(i, 1, 3)) Then 結果 = i : Exit Do
                              Next
                          Else
                              結果 = i
                              Exit Do
                          End If
                      End If
                  Next
              Loop While 次數 < 3
              Return 結果
          End Function
      
      
  2. 用新做法再測一次時間,結果大幅縮短了時間如下圖:

    image

 

到目前為止,資料的傳送還是用 WebClient 類別的 DownloadFileAsync() 方法,整體來說已經好用多了。

 

可是用 Socket 類別的程式碼已經寫了這麼多了,就算是全部用 Socket 來下載資料,剩下的工程也不大了,大致是以下幾個程序:

  1. 取得檔案 Size 後先不要 Close 命令連線,接著再下 PASV,並等候回應碼 227 然後建立資料連線
  2. 從回應字串的 '( str1,str2,str3,str4,str5,str6 ) ' 中解析出IP 和 PORT
    1. 字串的前4個數字就是 IP,把分隔字元改為小數點即可應用。 
    2. 後面2個數字是 Port 號碼,把 str5 * 256 + str6 得到的數字就是 Port Number。
  3. 再用一個 Socket,針對解出來的 IP & Port 建立資料連線。
  4. 程式碼如下:

    
        Function 建立Data連線(ByVal 原始連線 As Socket) As Socket
            Dim ret As String = ""
            Dim 遠端位址 As String = ""
            Dim 遠端埠號 As Integer = 0
            ret = 送出命令並等待回應(原始連線, "PASV", 227)
            Dim id = InStrRev(ret, ")")
            If id Then
                Dim tmp = Mid(ret, 1, id - 1) : id = InStrRev(tmp, "(") : tmp = Mid(tmp, id + 1)
                Dim 字節 = tmp.Split(",")
                遠端埠號 = Integer.Parse(字節(4)) * 256 + Byte.Parse(字節(5))
                遠端位址 = 字節(0) & "." & 字節(1) & "." & 字節(2) & "." & 字節(3)
            End If
            Dim 新建連線 As New Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp)
            Dim 遠端位址埠號 As New IPEndPoint(IPAddress.Parse(遠端位址), 遠端埠號)
            Try
                新建連線.Connect(遠端位址埠號)
            Catch ex As Exception
                Throw New IOException("無法建立 Data Connect 資料連線 !")
            End Try
            Return 新建連線
        End Function
    
    
  5. 備妥資料連線後,從命令連線送出 RETR <要下載的遠端徑名> 指令,然後立即開啟本機的 FileStream 接收資料。
  6. 把這幾個動作串起來,程式碼大約就是下面這樣(函式名稱暫用原來的)
    
    
        Private Function 直接從FTP下載檔案(uri As Uri) As Long
            '---解析遠端檔案名稱---
            Dim 命令連線 As New Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp)
            Dim 遠端_IP As IPAddress = Dns.GetHostEntry(uri.Host).AddressList(0)
            Dim 遠端位址埠號 As New IPEndPoint(遠端_IP, uri.Port)
            Dim 下載檔案 As String = uri.AbsolutePath
            Dim 檔案長度 As Long = 0
            Dim 回應字串 As String = ""
            進行連線(命令連線, 遠端位址埠號)
            送出命令並等待回應(命令連線, "USER " & new帳號)
            送出命令並等待回應(命令連線, "PASS " & new密碼)
            送出命令並等待回應(命令連線, "TYPE I ")
            '---取回檔案長度---
            回應字串 = 送出命令並等待回應(命令連線, "SIZE " & 下載檔案, 213)
            Select Case CInt(Mid(回應字串, 1, 3))
                Case 213 : 檔案長度 = Int64.Parse(Mid(回應字串, 4))
                Case 550 : MsgBox("找不到目錄或檔案!") : Return 0
                Case Else : MsgBox("!") : Return 0
            End Select
            '---建立本地檔案並接收檔案---
            Dim 串流檔案 As FileStream = New FileStream(new近端儲存位置, FileMode.Create, FileAccess.Write)
            Dim 資料連線 As Socket = 建立Data連線(命令連線)
            Dim 作業結果 As String = ""
            送出命令並等待回應(命令連線, "RETR " & 下載檔案)
            '---讀取 Binary Data 到檔案---
            Dim 已下載長度 As Long = 0
            Dim 進度百分比 As Single = 0
            Dim 陣列(2047) As Byte
            Dim len As Integer = 0
            Try
                While 串流檔案.Length < 檔案長度
                    len = 資料連線.Receive(陣列, 陣列.Length, SocketFlags.None)
                    串流檔案.Write(陣列, 0, len)
                    已下載長度 = 串流檔案.Length                  '--- 處理進度顯示---
                    進度百分比 = 已下載長度 / 檔案長度 * 100      '--- 可用 Debug.Print(串流檔案.Length) 監看---
                    ProgressBar.Value = CInt(進度百分比)
                    lbl_下載情況.Text = Format(進度百分比 / 100, "###.#0%")
                    Application.DoEvents()
                    If new使用者中斷 Then 作業結果 = "使用者中斷了作業" : Exit Try
                End While
            Catch ex As Exception
                作業結果 = ex.Message
            End Try
            '---------------------------------------------------------------
            ' 不論下載是否成功都要做這些收尾的動作:
            ' 1. 關閉資料連線
            ' 2. 關閉本地檔案
            ' 3. 向 FTP Server 送出 QUIT,不玩了。
            ' 4. 關閉命令連線
            ' 5. 根據變數(作業結果)的內容決定向呼叫端送出哪個事件。
            '---------------------------------------------------------------
            If Not IsNothing(資料連線) Then
                資料連線.Close()
                資料連線 = Nothing
            End If
            串流檔案.Flush() : 串流檔案.Close()
            回應字串 = 送出命令並等待回應(命令連線, "QUIT")
            命令連線.Close()
            If 作業結果 <> "" Then
                If FileIO.FileSystem.FileExists(new近端儲存位置) Then
                    FileIO.FileSystem.DeleteFile(new近端儲存位置)
                End If
                RaiseEvent 發生例外(Me, "下載失敗,因為 (" & 作業結果 & ") 。")
                Return -1
            Else
                RaiseEvent 作業完成(Me, new遠端檔案徑名 & "已下載成功。")
            End If
            Return 檔案長度
        End Function

     
  7. 下圖是同時下載兩個位置的檔案,可以看到兩個下載程序各自在進行中,也都能正確顯示進度圖條,而最主要的改良就是第一次連到 Server 所耗用的時間大幅縮短,幾乎都在 0.5 秒內就已拿回檔案長度了,更何況我在 connect 時還設了 100ms 的延遲。


image


因為對 FTP 的工作原理和細節並不十分清楚,所以只挑要解決的部分去完成,所以不論程式碼或是程式邏輯一定還有許多待改進處,還請高手多指正。

 

貼文先告一段落,下回再整理成下載包。

 

 專案原始碼下載[非同步下載_03_使用_Socket 直接和 FTP 連線.rar
demo_執行檔下載[非同步下載_03_demo.rar

 


ku3