C#使用socket實現FTP、POP3、SMTP的客戶端 (一)
概述
- socket本質是程式設計介面,是對TCP/IP的封裝。
- TCP/IP是傳輸層的協議。
- FTP、POP3、SMTP都是應用層的協議,是基於TCP/IP協議的。
所以,我們使用socket實現上述幾種協議的客戶端,其實是對藉助socket對TCP/IP資料傳輸的封裝基礎,再往上封裝一層的。
(簡單說,以FTP為例,就是將FTP中的上傳或者下載這類“一次”操作,分解成“多次”的通過socket進行資料的傳輸罷了。)
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),檔案必然會失真。