1. 程式人生 > >C#使用socket實現FTP、POP3、SMTP的客戶端 (一)

C#使用socket實現FTP、POP3、SMTP的客戶端 (一)

概述

  • socket本質是程式設計介面,是對TCP/IP的封裝。
  • TCP/IP是傳輸層的協議。
  • FTP、POP3、SMTP都是應用層的協議,是基於TCP/IP協議的。

所以,我們使用socket實現上述幾種協議的客戶端,其實是對藉助socket對TCP/IP資料傳輸的封裝基礎,再往上封裝一層的。
(簡單說,以FTP為例,就是將FTP中的上傳或者下載這類“一次”操作,分解成“多次”的通過socket進行資料的傳輸罷了。)

FTP客戶端

介面圖:

FTP客戶端圖

控制元件由以下組成:

  • 五個textbox: tb_IP, tb_port, tb_username, tb_password, tb_path
  • 三個listbox: lsb_local, lsb_server, lsb_status
  • 四個button: btn_conn, btn_setPath, btn_upload, btn_download

該FTP客戶端主要實現了建立連線、上傳、下載三個button的功能。

標頭檔案:

using System;
using System.Windows.Forms;
using System.Net.Sockets;
using System.IO;
using System.Text.RegularExpressions;

Sockets包是肯定要的,IO主要是為了使用NetworkStream類來方便socket的讀寫,RegularExpressions主要用的是它的split()函式

全域性變數:

#region  Private variable
private TcpClient cmdServer;
private TcpClient dataServer;
private NetworkStream cmdStrmWtr;
private StreamReader cmdStrmRdr;
private NetworkStream dataStrmWtr;
private StreamReader dataStrmRdr;
private String cmdData;
private byte[] szData;
private const String CRLF = "\r\n"
; #endregion

都知道,FTP協議的實現需要建立兩個連線,一個21號(通常用21號)埠傳輸命令,一個隨機埠傳輸資料。所以有兩個NetworkStream。

必須注意的是,FTP伺服器的命令埠(通常用21號)是保持連線的,資料埠只有在命令埠收到來自Client的請求時才會暫時開啟,傳輸完之後又關閉。

(不瞭解FTP底層的建議百度“使用telnet執行ftp互動”,程式碼的實現主要都是通過FTP的命令實現的。)

主要用到的FTP命令如下:

命令 描述
USER <使用者名稱> 登入FTP的使用者名稱
PASS <密碼> 登入FTP的密碼
QUIT 斷開連線
. .
PASV 進入被動模式,返回server的資料埠,等待client連線
ABOR 斷開資料埠的連線
. .
LIST 檢視伺服器檔案(從資料埠返回結果)
STOR <檔名> 請求上傳
RETR <檔名> 請求下載

(推薦一個FTPServer(迷你FTP伺服器)的工具,可以用它來快速建立FTP的伺服器端,方便做測試,簡單粗暴。有沒有毒不敢保證,反正介面簡潔,比FileZilla Server輕便一點。百度一下就有了。)

全域性函式:

#region  Private Functions

/// <summary>
/// 獲取命令埠返回結果,並記錄在lsb_status上
/// </summary>
private String getSatus()
{

    String ret = cmdStrmRdr.ReadLine();
    lsb_status.Items.Add(ret);
    lsb_status.SelectedIndex = lsb_status.Items.Count - 1;
    return ret;
}

/// <summary>
/// 進入被動模式,並初始化資料埠的輸入輸出流
/// </summary>
private void openDataPort()
{
    string retstr;
    string[] retArray;
    int dataPort;

    // Start Passive Mode 
    cmdData = "PASV" + CRLF;
    szData = System.Text.Encoding.ASCII.GetBytes(cmdData.ToCharArray());
    cmdStrmWtr.Write(szData, 0, szData.Length);
    retstr = this.getSatus();

    // Calculate data's port
    retArray = Regex.Split(retstr, ",");
    if (retArray[5][2] != ')') retstr = retArray[5].Substring(0, 3);
    else retstr = retArray[5].Substring(0, 2);
    dataPort = Convert.ToInt32(retArray[4]) * 256 + Convert.ToInt32(retstr);
    lsb_status.Items.Add("Get dataPort=" + dataPort);


    //Connect to the dataPort
    dataServer = new TcpClient(tb_IP.Text, dataPort);
    dataStrmRdr = new StreamReader(dataServer.GetStream());
    dataStrmWtr = dataServer.GetStream();
}

/// <summary>
/// 斷開資料埠的連線
/// </summary>
private void closeDataPort()
{
    dataStrmRdr.Close();
    dataStrmWtr.Close();
    this.getSatus();

    cmdData = "ABOR" + CRLF;
    szData = System.Text.Encoding.ASCII.GetBytes(cmdData.ToCharArray());
    cmdStrmWtr.Write(szData, 0, szData.Length);
    this.getSatus();

}

/// <summary>
/// 獲得/重新整理 右側的伺服器檔案列表
/// </summary>
private void freshFileBox_Right()
{

    openDataPort();

    string absFilePath;

    //List
    cmdData = "LIST" + CRLF;
    szData = System.Text.Encoding.ASCII.GetBytes(cmdData.ToCharArray());
    cmdStrmWtr.Write(szData, 0, szData.Length);
    this.getSatus();

    lsb_server.Items.Clear();
    while ((absFilePath = dataStrmRdr.ReadLine()) != null)
    {
        string[] temp = Regex.Split(absFilePath, " ");
        lsb_server.Items.Add(temp[temp.Length - 1]);
    }

    closeDataPort();
}

/// <summary>
/// 獲得/重新整理 左側的本地檔案列表
/// </summary>
private void freshFileBox_Left()
{
    lsb_local.Items.Clear();
    if (tb_path.Text == "") return;
    var files = Directory.GetFiles(tb_path.Text, "*.*");
    foreach (var file in files)
    {
        Console.WriteLine(file);
        string[] temp = Regex.Split(file, @"\\");
        lsb_local.Items.Add(temp[temp.Length - 1]);
    }
}

#endregion

重用部分的程式碼太多了,就把它們拉出來寫成了全域性函式,所以型別大多都是void,通過全域性變數傳遞結果,這樣做還是省了很多行程式碼的(雖然事實上整個程式碼看起來還是挺冗雜的)

連線按鍵(btn_conn):

#region  Button:  Connect & Disconnect

private void btn_conn_Click(object sender, EventArgs e)
{
    if (btn_conn.Text == "連線")
    {
        Cursor cr = Cursor.Current;
        Cursor.Current = Cursors.WaitCursor;
        cmdServer = new TcpClient(tb_IP.Text, Convert.ToInt32(tb_port.Text));
        lsb_status.Items.Clear();
        try
        {
            cmdStrmRdr = new StreamReader(cmdServer.GetStream());
            cmdStrmWtr = cmdServer.GetStream();
            this.getSatus();

            string retstr;

            //Login
            cmdData = "USER " + tb_username.Text + CRLF;
            szData = System.Text.Encoding.ASCII.GetBytes(cmdData.ToCharArray());
            cmdStrmWtr.Write(szData, 0, szData.Length);
            this.getSatus();

            cmdData = "PASS " + tb_password.Text + CRLF;
            szData = System.Text.Encoding.ASCII.GetBytes(cmdData.ToCharArray());
            cmdStrmWtr.Write(szData, 0, szData.Length);
            retstr = this.getSatus().Substring(0, 3);
            if (Convert.ToInt32(retstr) == 530) throw new InvalidOperationException("帳號密碼錯誤");

            this.freshFileBox_Right();

            lb_IP.Text = tb_IP.Text + ":";
            btn_conn.Text = "斷開";
            btn_upload.Enabled = true;
            btn_download.Enabled = true;
        }
        catch (InvalidOperationException err)
        {
            lsb_status.Items.Add("ERROR: " + err.Message.ToString());
        }
        finally
        {
            Cursor.Current = cr;
        }
    }
    else
    {
        Cursor cr = Cursor.Current;
        Cursor.Current = Cursors.WaitCursor;

        //Logout

        cmdData = "QUIT" + CRLF;
        szData = System.Text.Encoding.ASCII.GetBytes(cmdData.ToCharArray());
        cmdStrmWtr.Write(szData, 0, szData.Length);
        this.getSatus();


        cmdStrmWtr.Close();
        cmdStrmRdr.Close();

        lb_IP.Text = "";
        btn_conn.Text = "連線";
        btn_upload.Enabled = false;
        btn_download.Enabled = false;
        lsb_server.Items.Clear();

        Cursor.Current = cr;
    }
}

#endregion

程式碼醜歸醜……邏輯還是挺明確的。就是建立連線傳送“使用者名稱+密碼”,斷開連線就傳送“QUIT”。

設定路徑按鍵(btn_setPath)

#region  Button:  Set Path

private void btn_setPath_Click(object sender, EventArgs e)
{
    string path = string.Empty;
    FolderBrowserDialog fbd = new FolderBrowserDialog();
    if (fbd.ShowDialog() == DialogResult.OK)
    {
        path = fbd.SelectedPath;
        lsb_status.Items.Add("選中本地路徑:" + path);
    }

    tb_path.Text = path;
    freshFileBox_Left();
}

#endregion

第二個鍵,也沒啥好說的,程式碼很短,看了就懂了

上傳按鍵(btn_upload)&下載按鍵(btn_download):

#region  Button:  upload & download

/// <summary>
/// 上傳
/// </summary>
private void btn_upload_Click(object sender, EventArgs e)
{
    if (tb_path.Text == "" || lsb_local.SelectedIndex < 0)
    {
        MessageBox.Show("請選擇上傳的檔案", "ERROR");
        return;
    }

    Cursor cr = Cursor.Current;
    Cursor.Current = Cursors.WaitCursor;

    string fileName = lsb_local.Items[lsb_local.SelectedIndex].ToString();
    string filePath = tb_path.Text + "\\" + fileName;

    this.openDataPort();

    cmdData = "STOR " + fileName + CRLF;
    szData = System.Text.Encoding.ASCII.GetBytes(cmdData.ToCharArray());
    cmdStrmWtr.Write(szData, 0, szData.Length);
    this.getSatus();

    FileStream fstrm = new FileStream(filePath, FileMode.Open);
    byte[] fbytes = new byte[1030];
    int cnt = 0;
    while ((cnt = fstrm.Read(fbytes, 0, 1024)) > 0)
    {
        dataStrmWtr.Write(fbytes, 0, cnt);
    }
    fstrm.Close();

    this.closeDataPort();

    this.freshFileBox_Right();

    Cursor.Current = cr;

}

/// <summary>
/// 下載
/// </summary>
private void btn_download_Click(object sender, EventArgs e)
{

    if (tb_path.Text == "" || lsb_server.SelectedIndex < 0)
    {
        MessageBox.Show("請選擇目標檔案和下載路徑", "ERROR");
        return;
    }

    Cursor cr = Cursor.Current;
    Cursor.Current = Cursors.WaitCursor;

    string fileName = lsb_server.Items[lsb_server.SelectedIndex].ToString();
    string filePath = tb_path.Text + "\\" + fileName;

    this.openDataPort();

    cmdData = "RETR " + fileName + CRLF;
    szData = System.Text.Encoding.ASCII.GetBytes(cmdData.ToCharArray());
    cmdStrmWtr.Write(szData, 0, szData.Length);
    this.getSatus();

    FileStream fstrm = new FileStream(filePath, FileMode.OpenOrCreate);
    char[] fchars = new char[1030];
    byte[] fbytes = new byte[1030];
    int cnt = 0;
    while ((cnt = dataStrmWtr.Read(fbytes, 0, 1024)) > 0)
    {
        fstrm.Write(fbytes, 0, cnt);
    }
    fstrm.Close();

    this.closeDataPort();

    this.freshFileBox_Left();

    Cursor.Current = cr;
}

#endregion

“下載”操作相當於是伺服器端讀取目標檔案,然後把讀到的內容通過資料埠傳送給客戶端,客戶端讀到資料後就寫到本地。(跟傳真一樣)
同理,“上傳”操作將這個過程反過來了。

注意到“download”裡面竟然是用dataStrmWtr(NetworkStream類)來讀取來自伺服器的資料,而不是dataStrmRdr(StreamReader類)。
因為後者的Read()函式和ReadLine()函式讀取的是經過轉換的char[]型別陣列或者string類,而前者讀取的是未經過轉換的byte[]型別陣列。
如果是為了解析伺服器傳過來的內容,當然是直接讀使用StreamReader類來讀到socket傳來的string類。但如果是傳檔案的話,必須用byte[],讀多少byte,就通過FileStream寫多少byte到本地。否則得到的檔案相當於進行了兩次轉碼(從byte到char,再從char轉回byte),檔案必然會失真。

POP3客戶端

SMTP客戶端