這是一篇基於Socket進行網絡編程的入門文章,我對於網絡編程的學習並不夠深刻,這篇文章是對於本身知識的一個鞏固,同時但願能爲初學的朋友提供一點參考。文章大致分爲四個部分:程序的分析與設計、C#網絡編程基礎(篇外篇)、聊天程序的實現模式、程序實現。html
若是你們如今已經參加了工做,你的經理或者老闆告訴你,「小王,我須要你開發一個聊天程序」。那麼接下來該怎麼作呢?你是否是在腦子裏有個雛形,而後就直接打開VS2005開始設計窗體,編寫代碼了呢?在開始以前,咱們首先須要進行軟件的分析與設計。就拿本例來講,若是隻有這麼一句話「一個聊天程序」,恐怕如今你們對這個「聊天程序」的概念就很模糊,它能夠是像QQ那樣的很是複雜的一個程序,也能夠是很簡單的聊天程序;它可能只有在對方在線的時候才能夠進行聊天,也可能進行留言;它可能每次將消息只能發往一我的,也可能容許發往多我的。它還可能有一些高級功能,好比向對方傳送文件等。因此咱們首先須要進行分析,而不是一上手就開始作,而分析的第一步,就是搞清楚程序的功能是什麼,它可以作些什麼。在這一步,咱們的任務是瞭解程序須要作什麼,而不是如何去作。編程
瞭解程序須要作什麼,咱們能夠從兩方面入手,接下來咱們分別討論。設計模式
咱們能夠作的第一件事就是請求客戶提供更加詳細的信息。儘管你的經理或老闆是你的上司,但在這個例子中,他就是你的客戶(固然一般狀況下,客戶是公司外部委託公司開發軟件的人或單位)。當遇到上面這種狀況,咱們只有少得可憐的一條信息「一個聊天程序」,首先能夠作的,就是請求客戶提供更加確切的信息。好比,你問經理「對這個程序的功能能不能提供一些更具體的信息?」。他可能會像這樣回答:「哦,很簡單,能夠登陸聊天程序,登陸的時候可以通知其餘在線用戶,而後與在線的用戶進行對話,若是不想對話了,就註銷或者直接關閉,就這些吧。」服務器
有了上面這段話,咱們就又能夠得出下面幾個需求:網絡
常常會有這樣的狀況:可能客戶給出的需求仍然不夠細緻,或者客戶本身自己對於需求就很模糊,此時咱們須要作的就是針對用戶上面給出的信息進行提問。接下來我就看看如何對上面的需求進行提問,咱們至少能夠向經理提出如下問題:多線程
NOTE:這裏我穿插一個我在見到的一個印象比較深入的例子:客戶每每向你表達了強烈的意願他多麼多麼想擁有一個屬於本身的網站,可是,他卻沒有告訴你網站都有哪些內容、欄目,能夠作什麼。而做爲開發者,咱們顯然關心的是後者。框架
因爲這是一個範例程序,而我在爲你們講述,因此我只能再充當一下客戶的角色,來回答上面的問題:異步
好了,有了上面這些信息咱們基本上就掌握了程序須要完成的功能,那麼接下來作什麼?開始編碼了麼?上面的這些屬於業務流程,除非你對它已經很是熟悉,或者程序很是的小,那麼能夠對它進行編碼,可是實際中,咱們最好再編寫一些用例,這樣會使程序的流程更加的清楚。ide
一般一個用例對應一個功能或者叫需求,它是程序的一個執行路徑或者執行流程。編寫用例的思路是:假設你已經有了這樣一個聊天程序,那麼你應該如何使用它?咱們的使用步驟,就是一個用例。用例的特色就每次只針對程序的一個功能編寫,最後根據用例編寫代碼,最終完成程序的開發。咱們這裏的需求只有簡單的幾個:登陸,發送消息,接收消息,註銷或關閉,上面的分析是對這幾點功能的一個明確。接下來咱們首先編寫第一個用例:登陸。函數
在開始以前,咱們先明確一個概念:客戶端,服務端。由於這個程序只是在兩我的(機器)之間聊天,那麼咱們大體能夠繪出這樣一個圖來:
咱們指望用戶A和用戶B進行對話,那麼咱們就須要在它們之間創建起鏈接。儘管「用戶A」和「用戶B」的地位是對等的,但按照約定俗稱的說法:咱們將發起鏈接請求的一方稱爲客戶端(或叫本地),另外一端稱爲服務端(或叫遠程)。因此咱們的登陸過程,就是「用戶A」鏈接到「用戶B」的過程,或者說客戶端(本地)鏈接到服務端(遠程)的過程。在分析這個程序的過程當中,咱們老是將其分爲兩部分,一部分爲發起鏈接、發送消息的一方(本地),一方爲接受鏈接、接收消息的一方(遠程)。
登陸和鏈接(本地) | |
主路徑 | 可選路徑 |
1.打開應用程序,顯示登陸窗口 | |
2.輸入用戶名 | |
3.點擊「登陸」按鈕,登陸成功 | 3.「登陸」失敗
若是用戶名爲空,從新進入第2步。 |
4.顯示主窗口,顯示登陸的用戶名稱 | |
5.點擊「鏈接」,鏈接至遠程 | |
6.鏈接成功 6.1提示用戶,鏈接已經成功。 |
6.鏈接失敗 6.1 提示用戶,鏈接不成功 |
5.在用戶界面變動控件狀態 5.2鏈接爲灰色,表示已經鏈接 5.3註銷爲亮色,表示能夠註銷 5.4發送爲亮色,表示能夠發消息 |
這裏咱們的用例名稱爲登陸和鏈接,可是後面咱們又打了一個括號,寫着「本地」,它的意思是說,登陸和鏈接是客戶端,也就是發起鏈接的一方採起的動做。一樣,咱們須要寫下當客戶端鏈接至服務端時,服務端採起的動做。
登陸和鏈接(遠程) | |
主路徑 | 可選路徑 |
1-4 同客戶端 | |
5.等待鏈接 | |
6.若是有鏈接,自動在用戶界面顯示「遠程主機鏈接成功」 |
接下來咱們來看發送消息。在發送消息時,已是登陸了的,也就是「用戶A」、「用戶B」已經作好了鏈接,因此咱們如今就能夠只關注發送這一過程:
發送消息(本地) | |
主路徑 | 可選路徑 |
1.輸入消息 | |
2.點擊發送按鈕 | 2.沒有輸入消息,從新回到第1步 |
3.在用戶界面上顯示發出的消息 | 3.服務端已經斷開鏈接或者關閉
3.1在客戶端用戶界面上顯示錯誤消息 |
而後咱們看一下接收消息,此時咱們只關心接收消息這一部分。
接收消息(遠程) | |
主路徑 | 可選路徑 |
1.偵聽到客戶端發來的消息,自動顯示在用戶界面上。 |
注意到這樣一點:當遠程主機向本地返回消息時,它的用例又變爲了上面的用例「發送消息(本地)」。由於它們的角色已經互換了。
最後看一下注銷,咱們這裏研究的是當咱們在本地機器點擊「註銷」後,雙方採起的動做:
註銷(本地主動) | |
主路徑 | 可選路徑 |
1.點擊註銷按鈕,斷開與遠程的鏈接 | |
2.在用戶界面顯示已經註銷 | |
3.更改控件狀態 3.1註銷爲灰色,表示已經註銷 3.2鏈接爲亮色,表示能夠鏈接 3.3發送爲灰色,表示沒法發送 |
與此對應,服務端應該做出反應:
註銷(遠程被動) | |
主路徑 | 可選路徑 |
1.自動顯示遠程用戶已經斷開鏈接。 |
注意到一點:當遠程主動註銷時,它採起的動做爲上面的「本地主動」,本地採起的動做則爲這裏的「遠程被動」。
至此,應用程序的功能分析和用例編寫就告一段落了,經過上面這些表格,以後再繼續編寫程序變得容易了許多。另外還須要記得,用例只能爲你提供一個操做步驟的指導,在實現的過程當中,由於技術等方面的緣由,可能還會有少許的修改。若是修改量很大,能夠從新修改用例;若是修改量不大,那麼就能夠直接編碼。這是一個迭代的過程,也沒有必定的標準,總之是以高效和合適爲標準。
咱們已經很清楚地知道了程序須要作些什麼,儘管如今還不知道該如何去作。咱們甚至能夠編寫出這個程序所須要的接口,之後編寫代碼的時候,咱們只要去實現這些接口就能夠了。這也符合面向接口編程的原則。另外咱們注意到,儘管這是一個聊天程序,可是卻能夠明確地劃分爲兩部分,一部分發送消息,一部分接收消息。另外注意上面標識爲自動的語句,它們暗示這個操做須要經過事件的通知機制來完成。關於委託和事件,能夠參考這兩篇文章:
首先咱們能夠定義消息,前面咱們已經明確了消息包含三個部分:用戶名、時間、內容,因此咱們能夠定義一個結構來表示這個消息:
public struct Message {
private readonly string userName;
private readonly string content;
private readonly DateTime postDate;
public Message(string userName, string content) {
this.userName = userName;
this.content = content;
this.postDate = DateTime.Now;
}
public Message(string content) : this("System", content) { }
public string UserName {
get { return userName; }
}
public string Content {
get { return content; }
}
public DateTime PostDate {
get { return postDate; }
}
public override string ToString() {
return String.Format("{0}[{1}]:\r\n{2}\r\n", userName, postDate, content);
}
}
從上面咱們能夠看出,消息發送方主要包含這樣幾個功能:登陸、鏈接、發送消息、註銷。另外在鏈接成功或失敗時還要通知用戶界面,發送消息成功或失敗時也須要通知用戶界面,所以,咱們可讓鏈接和發送消息返回一個布爾類型的值,當它爲真時表示鏈接或發送成功,反之則爲失敗。由於登陸沒有任何的業務邏輯,僅僅是記錄控件的值並進行顯示,因此我不打算將它寫到接口中。所以咱們能夠得出它的接口大體以下:
public interface IMessageSender {
bool Connect(IPAddress ip, int port); // 鏈接到服務端
bool SendMessage(Message msg); // 發送用戶
void SignOut(); // 註銷系統
}
而對於消息接收方,從上面咱們能夠看出,它的操做全是被動的:客戶端鏈接時自動提示,客戶端鏈接丟失時顯示自動提示,偵聽到消息時自動提示。注意到上面三個詞都用了「自動」來修飾,在C#中,能夠定義委託和事件,用於當程序中某種狀況發生時,通知另一個對象。在這裏,程序便是咱們的IMessageReceiver,某種狀況就是上面的三種狀況,而另一個對象則爲咱們的用戶界面。所以,咱們如今首先須要定義三個委託:
public delegate void MessageReceivedEventHandler(string msg);
public delegate void ClientConnectedEventHandler(IPEndPoint endPoint);
public delegate void ConnectionLostEventHandler(string info);
接下來,咱們注意到接收方須要偵聽消息,所以咱們須要在接口中定義的方法是StartListen()和StopListen()方法,這兩個方法是典型的技術相關,而不是業務相關,因此從用例中是看不出來的,可能你們如今對這兩個方法是作什麼的還不清楚,沒有關係,咱們如今並不寫實現,而定義接口並不須要什麼成本,咱們寫下IMessageReceiver的接口定義:
public interface IMessageReceiver {
event MessageReceivedEventHandler MessageReceived; // 接收到發來的消息
event ConnectionLostEventHandler ClientLost; // 遠程主動斷開鏈接
event ClientConnectedEventHandler ClientConnected; // 遠程鏈接到了本地
void StartListen(); // 開始偵聽端口
void StopListen(); // 中止偵聽端口
}
我記得曾經看過有篇文章說過,最好不要在接口中定義事件,可是我忘了他的理由了,因此本文仍是將事件定義在了接口中。
而咱們的主程序是既能夠發送,又能夠接收,通常來講,若是一個類像得到其餘類的能力,以採用兩種方法:繼承和複合。由於C#中沒有多重繼承,因此咱們沒法同時繼承實現了IMessageReceiver和IMessageSender的類。那麼咱們能夠採用複合,將它們做爲類成員包含在Talker內部:
public class Talker {
private IMessageReceiver receiver;
private IMessageSender sender;
public Talker(IMessageReceiver receiver, IMessageSender sender) {
this.receiver = receiver;
this.sender = sender;
}
}
如今,咱們的程序大致框架已經完成,接下來要關注的就是如何實現它,如今讓咱們由設計走入實現,看看實現一個網絡聊天程序,咱們須要掌握的技術吧。
這部分的內容請參考 C#網絡編程 系列文章,共5個部分較爲詳細的講述了基於Socket的網絡編程的初步內容。
若是你已經看完了上面一節C#網絡編程,那麼本章徹底沒有講解的必要了,因此我只列出代碼,對個別值得注意的地方稍微地講述一下。首先須要瞭解的就是,咱們採用的是三個模式中開發起來難度較大的一種,無服務器參與的模式。還有就是咱們沒有使用廣播消息,因此須要提早知道鏈接到的遠程主機的地址和端口號。
public class MessageSender : IMessageSender {
TcpClient client;
Stream streamToServer;
// 鏈接至遠程
public bool Connect(IPAddress ip, int port) {
try {
client = new TcpClient();
client.Connect(ip, port);
streamToServer = client.GetStream(); // 獲取鏈接至遠程的流
return true;
} catch {
return false;
}
}
// 發送消息
public bool SendMessage(Message msg) {
try {
lock (streamToServer) {
byte[] buffer = Encoding.Unicode.GetBytes(msg.ToString());
streamToServer.Write(buffer, 0, buffer.Length);
return true;
}
} catch {
return false;
}
}
// 註銷
public void SignOut() {
if (streamToServer != null)
streamToServer.Dispose();
if (client != null)
client.Close();
}
}
這段代碼能夠用樸實無華來形容,因此咱們直接看下一段。
public delegate void PortNumberReadyEventHandler(int portNumber);
public class MessageReceiver : IMessageReceiver {
public event MessageReceivedEventHandler MessageReceived;
public event ConnectionLostEventHandler ClientLost;
public event ClientConnectedEventHandler ClientConnected;
// 當端口號Ok的時候調用 -- 須要告訴用戶界面使用了哪一個端口號在偵聽
// 這裏是業務上體現不出來,在實現中才能體現出來的
public event PortNumberReadyEventHandler PortNumberReady;
private Thread workerThread;
private TcpListener listener;
public MessageReceiver() {
((IMessageReceiver)this).StartListen();
}
// 開始偵聽:顯示實現接口
void IMessageReceiver.StartListen() {
ThreadStart start = new ThreadStart(ListenThreadMethod);
workerThread = new Thread(start);
workerThread.IsBackground = true;
workerThread.Start();
}
// 線程入口方法
private void ListenThreadMethod() {
IPAddress localIp = IPAddress.Parse("127.0.0.1");
listener = new TcpListener(localIp, 0);
listener.Start();
// 獲取端口號
IPEndPoint endPoint = listener.LocalEndpoint as IPEndPoint;
int portNumber = endPoint.Port;
if (PortNumberReady != null) {
PortNumberReady(portNumber); // 端口號已經OK,通知用戶界面
}
while (true) {
TcpClient remoteClient;
try {
remoteClient = listener.AcceptTcpClient();
} catch {
break;
}
if (ClientConnected != null) {
// 鏈接至本機的遠程端口
endPoint = remoteClient.Client.RemoteEndPoint as IPEndPoint;
ClientConnected(endPoint); // 通知用戶界面遠程客戶鏈接
}
Stream streamToClient = remoteClient.GetStream();
byte[] buffer = new byte[8192];
while (true) {
try {
int bytesRead = streamToClient.Read(buffer, 0, 8192);
if (bytesRead == 0) {
throw new Exception("客戶端已斷開鏈接");
}
string msg = Encoding.Unicode.GetString(buffer, 0, bytesRead);
if (MessageReceived != null) {
MessageReceived(msg); // 已經收到消息
}
} catch (Exception ex) {
if (ClientLost != null) {
ClientLost(ex.Message); // 客戶鏈接丟失
break; // 退出循環
}
}
}
}
}
// 中止偵聽端口
public void StopListen() {
try {
listener.Stop();
listener = null;
workerThread.Abort();
} catch { }
}
}
這裏須要注意的有這樣幾點:咱們StartListen()爲顯式實現接口,由於只能經過接口才能調用此方法,接口的實現類看不到此方法;這一般是對於一個接口採用兩種實現方式時使用的,但這裏我只是不但願MessageReceiver類型的客戶調用它,由於在MessageReceiver的構造函數中它已經調用了StartListen。意思是說,咱們但願這個類型一旦建立,就當即開始工做。咱們使用了兩個嵌套的while循環,這個它能夠爲多個客戶端的屢次請求服務,可是由於是同步操做,只要有一個客戶端鏈接着,咱們的後臺線程就會陷入第二個循環中沒法自拔。因此結果是:若是有一個客戶端已經鏈接上了,其它客戶端即便鏈接了也沒法對它應答。最後須要注意的就是四個事件的使用,爲了向用戶提供偵聽的端口號以進行鏈接,我又定義了一個PortNumberReadyEventHandler委託。
Talker類是最平庸的一個類,它的所有功能就是將操做委託給實際的IMessageReceiver和IMessageSender。定義這兩個接口的好處也從這裏能夠看出來:若是往後想從新實現這個程序,全部Windows窗體的代碼和Talker的代碼都不須要修改,只須要針對這兩個接口編程就能夠了。
public class Talker {
private IMessageReceiver receiver;
private IMessageSender sender;
public Talker(IMessageReceiver receiver, IMessageSender sender) {
this.receiver = receiver;
this.sender = sender;
}
public Talker() {
this.receiver = new MessageReceiver();
this.sender = new MessageSender();
}
public event MessageReceivedEventHandler MessageReceived {
add {
receiver.MessageReceived += value;
}
remove {
receiver.MessageReceived -= value;
}
}
public event ClientConnectedEventHandler ClientConnected {
add {
receiver.ClientConnected += value;
}
remove {
receiver.ClientConnected -= value;
}
}
public event ConnectionLostEventHandler ClientLost {
add {
receiver.ClientLost += value;
}
remove {
receiver.ClientLost -= value;
}
}
// 注意這個事件
public event PortNumberReadyEventHandler PortNumberReady {
add {
((MessageReceiver)receiver).PortNumberReady += value;
}
remove {
((MessageReceiver)receiver).PortNumberReady -= value;
}
}
// 鏈接遠程 - 使用主機名
public bool ConnectByHost(string hostName, int port) {
IPAddress[] ips = Dns.GetHostAddresses(hostName);
return sender.Connect(ips[0], port);
}
// 鏈接遠程 - 使用IP
public bool ConnectByIp(string ip, int port) {
IPAddress ipAddress;
try {
ipAddress = IPAddress.Parse(ip);
} catch {
return false;
}
return sender.Connect(ipAddress, port);
}
// 發送消息
public bool SendMessage(Message msg) {
return sender.SendMessage(msg);
}
// 釋放資源,中止偵聽
public void Dispose() {
try {
sender.SignOut();
receiver.StopListen();
} catch {
}
}
// 註銷
public void SignOut() {
try {
sender.SignOut();
} catch {
}
}
}
如今咱們開始設計窗體,我已經設計好了,如今能夠先進行一下預覽:
這裏須要注意的就是上面的偵聽端口,是程序接收消息時的偵聽端口,也就是IMessageReceiver所使用的。其餘的沒有什麼好說的,下來咱們直接看一下代碼,控件的命名是自解釋的,我就很少說什麼了。惟一要稍微說明下的是txtMessage指的是下面發送消息的文本框,txtContent指上面的消息記錄文本框:
public partial class PrimaryForm : Form {
private Talker talker;
private string userName;
public PrimaryForm(string name) {
InitializeComponent();
userName = lbName.Text = name;
this.talker = new Talker();
this.Text = userName + " Talking ...";
talker.ClientLost +=
new ConnectionLostEventHandler(talker_ClientLost);
talker.ClientConnected +=
new ClientConnectedEventHandler(talker_ClientConnected);
talker.MessageReceived +=
new MessageReceivedEventHandler(talker_MessageReceived);
talker.PortNumberReady +=
new PortNumberReadyEventHandler(PrimaryForm_PortNumberReady);
}
void ConnectStatus() { }
void DisconnectStatus() { }
// 端口號OK
void PrimaryForm_PortNumberReady(int portNumber) {
PortNumberReadyEventHandler del = delegate(int port) {
lbPort.Text = port.ToString();
};
lbPort.Invoke(del, portNumber);
}
// 接收到消息
void talker_MessageReceived(string msg) {
MessageReceivedEventHandler del = delegate(string m) {
txtContent.Text += m;
};
txtContent.Invoke(del, msg);
}
// 有客戶端鏈接到本機
void talker_ClientConnected(IPEndPoint endPoint) {
ClientConnectedEventHandler del = delegate(IPEndPoint end) {
IPHostEntry host = Dns.GetHostEntry(end.Address);
txtContent.Text +=
String.Format("System[{0}]:\r\n遠程主機{1}鏈接至本地。\r\n", DateTime.Now, end);
};
txtContent.Invoke(del, endPoint);
}
// 客戶端鏈接斷開
void talker_ClientLost(string info) {
ConnectionLostEventHandler del = delegate(string information) {
txtContent.Text +=
String.Format("System[{0}]:\r\n{1}\r\n", DateTime.Now, information);
};
txtContent.Invoke(del, info);
}
// 發送消息
private void btnSend_Click(object sender, EventArgs e) {
if (String.IsNullOrEmpty(txtMessage.Text)) {
MessageBox.Show("請輸入內容!");
txtMessage.Clear();
txtMessage.Focus();
return;
}
Message msg = new Message(userName, txtMessage.Text);
if (talker.SendMessage(msg)) {
txtContent.Text += msg.ToString();
txtMessage.Clear();
} else {
txtContent.Text +=
String.Format("System[{0}]:\r\n遠程主機已斷開鏈接\r\n", DateTime.Now);
DisconnectStatus();
}
}
// 點擊鏈接
private void btnConnect_Click(object sender, EventArgs e) {
string host = txtHost.Text;
string ip = txtHost.Text;
int port;
if (String.IsNullOrEmpty(txtHost.Text)) {
MessageBox.Show("主機名稱或地址不能爲空");
}
try{
port = Convert.ToInt32(txtPort.Text);
}catch{
MessageBox.Show("端口號不能爲空,且必須爲數字");
return;
}
if (talker.ConnectByHost(host, port)) {
ConnectStatus();
txtContent.Text +=
String.Format("System[{0}]:\r\n已成功鏈接至遠程\r\n", DateTime.Now);
return;
}
if(talker.ConnectByIp(ip, port)){
ConnectStatus();
txtContent.Text +=
String.Format("System[{0}]:\r\n已成功鏈接至遠程\r\n", DateTime.Now);
}else{
MessageBox.Show("遠程主機不存在,或者拒絕鏈接!");
}
txtMessage.Focus();
}
// 關閉按鈕點按
private void btnClose_Click(object sender, EventArgs e) {
try {
talker.Dispose();
Application.Exit();
} catch {
}
}
// 直接點擊右上角的叉
private void PrimaryForm_FormClosing(object sender, FormClosingEventArgs e) {
try {
talker.Dispose();
Application.Exit();
} catch {
}
}
// 點擊註銷
private void btnSignout_Click(object sender, EventArgs e) {
talker.SignOut();
DisconnectStatus();
txtContent.Text +=
String.Format("System[{0}]:\r\n已經註銷\r\n",DateTime.Now);
}
private void btnClear_Click(object sender, EventArgs e) {
txtContent.Clear();
}
}
在上面代碼中,分別經過四個方法訂閱了四個事件,以實現自動通知的機制。最後須要注意的就是SignOut()和Dispose()的區分。SignOut()只是斷開鏈接,Dispose()則是離開應用程序。
這篇文章簡單地分析、設計及實現了一個聊天程序。這個程序只是對無服務器模式實現聊天的一個嘗試。咱們分析了需求,隨後編寫了幾個用例,並對本地、遠程的概念作了定義,接着編寫了程序接口並最終實現了它。這個程序還有很嚴重的不足:它沒法實現自動上線通知,而必需要事先知道端口號並進行手動鏈接。爲了實現一個功能強大且開發容易的程序,更好的辦法是使用集中型服務器模式。
感謝閱讀,但願這篇文章能對你有所幫助。
出處:http://www.cnblogs.com/JimmyZhang/archive/2008/09/07/1286299.html