當前位置:
首頁 > 最新 > C#編寫簡單的聊天程序

C#編寫簡單的聊天程序

C#編寫簡單的聊天程序

1. 引言

這是一篇基於Socket進行網路編程的入門文章,我對於網路編程的學習並不夠深入,這篇文章是對於自己知識的一個鞏固,同時希望能為初學的朋友提供一點參考。文章大體分為四個部分:程序的分析與設計、C#網路編程基礎(篇外篇)、聊天程序的實現模式、程序實現。


如果大家現在已經參加了工作,你的經理或者老闆告訴你,「小王,我需要你開發一個聊天程序」。那麼接下來該怎麼做呢?你是不是在腦子裡有個雛形,然後就直接打開VS2005開始設計窗體,編寫代碼了呢?在開始之前,我們首先需要進行軟體的分析與設計。就拿本例來說,如果只有這麼一句話「一個聊天程序」,恐怕現在大家對這個「聊天程序」的概念就很模糊,它可以是像QQ那樣的非常複雜的一個程序,也可以是很簡單的聊天程序;它可能只有在對方在線的時候才可以進行聊天,也可能進行留言;它可能每次將消息只能發往一個人,也可能允許發往多個人。它還可能有一些高級功能,比如向對方傳送文件等。所以我們首先需要進行分析,而不是一上手就開始做,而分析的第一步,就是搞清楚程序的功能是什麼,它能夠做些什麼。在這一步,我們的任務是了解程序需要做什麼,而不是如何去做。

了解程序需要做什麼,我們可以從兩方面入手,接下來我們分別討論。

a. 請求客戶提供更詳細信息

我們可以做的第一件事就是請求客戶提供更加詳細的信息。儘管你的經理或老闆是你的上司,但在這個例子中,他就是你的客戶(當然通常情況下,客戶是公司外部委託公司開發軟體的人或單位)。當遇到上面這種情況,我們只有少得可憐的一條信息「一個聊天程序」,首先可以做的,就是請求客戶提供更加確切的信息。比如,你問經理「對這個程序的功能能不能提供一些更具體的信息?」。他可能會像這樣回答:「哦,很簡單,可以登錄聊天程序,登錄的時候能夠通知其他在線用戶,然後與在線的用戶進行對話,如果不想對話了,就註銷或者直接關閉,就這些吧。」

有了上面這段話,我們就又可以得出下面幾個需求:

程序可以進行登錄。

登錄後可以通知其他在線用戶。

可以與其他用戶進行對話。

可以註銷或者關閉。

b. 對於用戶需求進行提問,並進行總結

經常會有這樣的情況:可能客戶給出的需求仍然不夠細緻,或者客戶自己本身對於需求就很模糊,此時我們需要做的就是針對用戶上面給出的信息進行提問。接下來我就看看如何對上面的需求進行提問,我們至少可以向經理提出以下問題:

注意:這裡我穿插一個我在見到的一個印象比較深刻的例子:客戶往往向你表達了強烈的意願他多麼多麼想擁有一個屬於自己的網站,但是,他卻沒有告訴你網站都有哪些內容、欄目,可以做什麼。而作為開發者,我們顯然關心的是後者。

登錄時需要提供哪些內容?需不需要提供密碼?

允許多少人同時在線聊天?

與在線用戶聊天時,可以將一條消息發給一個用戶,還是可以一次將消息發給多個用戶?

聊天時發送的消息包括哪些內容?

註銷和關閉有什麼區別?

註銷和關閉對對方需不需要給對方提示?

由於這是一個范常式序,而我在為大家講述,所以我只能再充當一下客戶的角色,來回答上面的問題:

登錄時只需要提供用戶名稱就可以了,不需要輸入密碼。

允許兩個人在線聊天。(這裡我們只講述這種簡單情況,允許多人聊天需要使用多線程)

因為只有兩個人,那麼自然是只能發給一個用戶了。

聊天發送的消息包括:用戶名稱、發送時間還有正文。

註銷並不關閉程序,只是離開了對話,可以再次進行連接。關閉則是退出整個應用程序。

註銷和關閉均需要給對方提示。

好了,有了上面這些信息我們基本上就掌握了程序需要完成的功能,那麼接下來做什麼?開始編碼了么?上面的這些屬於業務流程,除非你對它已經非常熟悉,或者程序非常的小,那麼可以對它進行編碼,但是實際中,我們最好再編寫一些用例,這樣會使程序的流程更加的清楚。

c. 編寫用例

通常一個用例對應一個功能或者叫需求,它是程序的一個執行路徑或者執行流程。編寫用例的思路是:假設你已經有了這樣一個聊天程序,那麼你應該如何使用它?我們的使用步驟,就是一個用例。用例的特點就每次只針對程序的一個功能編寫,最後根據用例編寫代碼,最終完成程序的開發。我們這裡的需求只有簡單的幾個:登錄,發送消息,接收消息,註銷或關閉,上面的分析是對這幾點功能的一個明確。接下來我們首先編寫第一個用例:登錄。

在開始之前,我們先明確一個概念:客戶端,服務端。因為這個程序只是在兩個人(機器)之間聊天,那麼我們大致可以繪出這樣一個圖來:

我們期望用戶A和用戶B進行對話,那麼我們就需要在它們之間建立起連接。儘管「用戶A」和「用戶B」的地位是對等的,但按照約定俗稱的說法:我們將發起連接請求的一方稱為客戶端(或叫本地),另一端稱為服務端(或叫遠程)。所以我們的登錄過程,就是「用戶A」連接到「用戶B」的過程,或者說客戶端(本地)連接到服務端(遠程)的過程。在分析這個程序的過程中,我們總是將其分為兩部分,一部分為發起連接、發送消息的一方(本地),一方為接受連接、接收消息的一方(遠程)。

這裡我們的用例名稱為登錄和連接,但是後面我們又打了一個括弧,寫著「本地」,它的意思是說,登錄和連接是客戶端,也就是發起連接的一方採取的動作。同樣,我們需要寫下當客戶端連接至服務端時,服務端採取的動作。

可選路徑

接下來我們來看發送消息。在發送消息時,已經是登錄了的,也就是「用戶A」、「用戶B」已經做好了連接,所以我們現在就可以只關注發送這一過程:

然後我們看一下接收消息,此時我們只關心接收消息這一部分。

注意到這樣一點:當遠程主機向本地返回消息時,它的用例又變為了上面的用例「發送消息(本地)」。因為它們的角色已經互換了。

最後看一下註銷,我們這裡研究的是當我們在本地機器點擊「註銷」後,雙方採取的動作:

與此對應,服務端應該作出反應:

注意到一點:當遠程主動註銷時,它採取的動作為上面的「本地主動」,本地採取的動作則為這裡的「遠程被動」。

至此,應用程序的功能分析和用例編寫就告一段落了,通過上面這些表格,之後再繼續編寫程序變得容易了許多。另外還需要記得,用例只能為你提供一個操作步驟的指導,在實現的過程中,因為技術等方面的原因,可能還會有少量的修改。如果修改量很大,可以重新修改用例;如果修改量不大,那麼就可以直接編碼。這是一個迭代的過程,也沒有一定的標準,總之是以高效和合適為標準。


我們已經很清楚地知道了程序需要做些什麼,儘管現在還不知道該如何去做。我們甚至可以編寫出這個程序所需要的介面,以後編寫代碼的時候,我們只要去實現這些介面就可以了。這也符合面向介面編程的原則。另外我們注意到,儘管這是一個聊天程序,但是卻可以明確地劃分為兩部分,一部分發送消息,一部分接收消息。另外注意上面標識為自動的語句,它們暗示這個操作需要通過事件的通知機制來完成。關於委託和事件,可以參考這兩篇文章:

C#中的委託和事件 - Part.1 - 委託和事件的入門文章,同時捎帶講述了Observer設計模式和.NET的事件模型

C#中的委託和事件 - Part.2 - 委託和事件更深入的一些問題,包括異常、超時的處理,以及使用委託來非同步調用方法。

a. 消息Message

首先我們可以定義消息,前面我們已經明確了消息包含三個部分:用戶名、時間、內容,所以我們可以定義一個結構來表示這個消息:

publicstructMessage{privatereadonlystringuserName;privatereadonlystringcontent;privatereadonlyDateTime postDate;publicMessage(stringuserName,stringcontent){this.userName = userName;this.content = content;this.postDate = DateTime.Now; }publicMessage(stringcontent) :this("System", content){ }publicstringUserName{get{returnuserName;}}publicstringContent{get{returncontent; } }publicDateTime PostDate{get{returnpostDate; } }publicoverridestringToString(){returnString.Format("[]:

", userName, postDate, content); }}b. 消息發送方IMessageSender

從上面我們可以看出,消息發送方主要包含這樣幾個功能:登錄連接發送消息註銷。另外在連接成功或失敗時還要通知用戶界面,發送消息成功或失敗時也需要通知用戶界面,因此,我們可以讓連接和發送消息返回一個布爾類型的值,當它為真時表示連接或發送成功,反之則為失敗。因為登錄沒有任何的業務邏輯,僅僅是記錄控制項的值並進行顯示,所以我不打算將它寫到介面中。因此我們可以得出它的介面大致如下:

publicinterfaceIMessageSender{boolConnect(IPAddress ip,intport);// 連接到服務端boolSendMessage(Message msg);// 發送用戶voidSignOut();// 註銷系統}c. 消息接收方IMessageReceiver

而對於消息接收方,從上面我們可以看出,它的操作全是被動的:客戶端連接時自動提示,客戶端連接丟失時顯示自動提示,偵聽到消息時自動提示。注意到上面三個詞都用了「自動」來修飾,在C#中,可以定義委託和事件,用於當程序中某種情況發生時,通知另外一個對象。在這裡,程序即是我們的IMessageReceiver,某種情況就是上面的三種情況,而另外一個對象則為我們的用戶界面。因此,我們現在首先需要定義三個委託:

publicdelegatevoidMessageReceivedEventHandler(stringmsg);publicdelegatevoidClientConnectedEventHandler(IPEndPoint endPoint);

publicdelegatevoidConnectionLostEventHandler(stringinfo);

接下來,我們注意到接收方需要偵聽消息,因此我們需要在介面中定義的方法是StartListen()和StopListen()方法,這兩個方法是典型的技術相關,而不是業務相關,所以從用例中是看不出來的,可能大家現在對這兩個方法是做什麼的還不清楚,沒有關係,我們現在並不寫實現,而定義介面並不需要什麼成本,我們寫下IMessageReceiver的介面定義:

publicinterfaceIMessageReceiver

{

eventMessageReceivedEventHandler MessageReceived;

// 接收到發來的消息eventConnectionLostEventHandler ClientLost;

// 遠程主動斷開連接eventClientConnectedEventHandler ClientConnected;

// 遠程連接到了本地voidStartListen();// 開始偵聽埠voidStopListen();// 停止偵聽埠

}

我記得曾經看過有篇文章說過,最好不要在介面中定義事件,但是我忘了他的理由了,所以本文還是將事件定義在了介面中。

d. 主程序Talker

而我們的主程序是既可以發送,又可以接收,一般來說,如果一個類像獲得其他類的能力,以採用兩種方法:繼承和複合。因為C#中沒有多重繼承,所以我們無法同時繼承實現了IMessageReceiver和IMessageSender的類。那麼我們可以採用複合,將它們作為類成員包含在Talker內部:

publicclassTalker

{

privateIMessageReceiver receiver;

privateIMessageSender sender;

publicTalker(IMessageReceiver receiver, IMessageSender sender)

{

this.receiver = receiver;

this.sender = sender; }}

現在,我們的程序大體框架已經完成,接下來要關注的就是如何實現它,現在讓我們由設計走入實現,看看實現一個網路聊天程序,我們需要掌握的技術吧。


這部分的內容請參考 C#網路編程 系列文章,共5個部分較為詳細的講述了基於Socket的網路編程的初步內容。


如果你已經看完了上面一節C#網路編程,那麼本章完全沒有講解的必要了,所以我只列出代碼,對個別值得注意的地方稍微地講述一下。首先需要了解的就是,我們採用的是三個模式中開發起來難度較大的一種,無伺服器參與的模式。還有就是我們沒有使用廣播消息,所以需要提前知道連接到的遠程主機的地址和埠號。

publicclassMessageSender:IMessageSender

{ TcpClient client; Stream streamToServer;// 連接至遠程publicboolConnect(IPAddress ip,intport)

{

try{ client =newTcpClient(); client.Connect(ip, port); streamToServer = client.GetStream();

// 獲取連接至遠程的流returntrue; }

catch

{

returnfalse; } }// 發送消息publicboolSendMessage(Message msg)

{

try

{

lock(streamToServer)

{

byte[] buffer = Encoding.Unicode.GetBytes(msg.ToString()); streamToServer.Write(buffer,, buffer.Length);returntrue; } }

catch

{returnfalse; } }// 註銷publicvoidSignOut()

{

if(streamToServer !=null) streamToServer.Dispose();

if(client !=null) client.Close(); }}

這段代碼可以用樸實無華來形容,所以我們直接看下一段。


publicdelegatevoidPortNumberReadyEventHandler(intportNumber);

publicclassMessageReceiver:IMessageReceiver

{

publiceventMessageReceivedEventHandler MessageReceived;

publiceventConnectionLostEventHandler ClientLost;

publiceventClientConnectedEventHandler ClientConnected;

// 當埠號Ok的時候調用 -- 需要告訴用戶界面使用了哪個埠號在偵聽// 這裡是業務上體現不出來,在實現中才能體現出來的publiceventPortNumberReadyEventHandler PortNumberReady;privateThread workerThread;

privateTcpListener listener;

publicMessageReceiver()

{ ((IMessageReceiver)this).StartListen(); }// 開始偵聽:顯示實現介面voidIMessageReceiver.StartListen()

{ ThreadStart start =newThreadStart(ListenThreadMethod); workerThread =newThread(start); workerThread.IsBackground =true; workerThread.Start(); }// 線程入口方法privatevoidListenThreadMethod()

{ IPAddress localIp = IPAddress.Parse("127.0.0.1"); listener =newTcpListener(localIp,); listener.Start();// 獲取埠號IPEndPoint endPoint = listener.LocalEndpointasIPEndPoint;intportNumber = endPoint.Port;

if(PortNumberReady !=null)

{ PortNumberReady(portNumber);

// 埠號已經OK,通知用戶界面}

while(true)

{ TcpClient remoteClient;

try

{ remoteClient = listener.AcceptTcpClient(); }

catch

{

break; }

if(ClientConnected !=null)

{

// 連接至本機的遠程埠endPoint = remoteClient.Client.RemoteEndPointasIPEndPoint; ClientConnected(endPoint);

// 通知用戶界面遠程客戶連接} Stream streamToClient = remoteClient.GetStream();byte[] buffer =newbyte[8192];

while(true)

{

try

{

intbytesRead = streamToClient.Read(buffer,,8192);if(bytesRead ==) {thrownewException("客戶端已斷開連接");

}

stringmsg = Encoding.Unicode.GetString(buffer,, bytesRead);if(MessageReceived !=null)

{ MessageReceived(msg);

// 已經收到消息} }catch(Exception ex) {if(ClientLost !=null) { ClientLost(ex.Message);

// 客戶連接丟失break;

// 退出循環} } } } }// 停止偵聽埠publicvoidStopListen()

{

try

{ listener.Stop(); listener =null; workerThread.Abort(); }catch{ } }}/P>

這裡需要注意的有這樣幾點:我們StartListen()為顯式實現介面,因為只能通過介面才能調用此方法,介面的實現類看不到此方法;這通常是對於一個介面採用兩種實現方式時使用的,但這裡我只是不希望MessageReceiver類型的客戶調用它,因為在MessageReceiver的構造函數中它已經調用了StartListen。意思是說,我們希望這個類型一旦創建,就立即開始工作。我們使用了兩個嵌套的while循環,這個它可以為多個客戶端的多次請求服務,但是因為是同步操作,只要有一個客戶端連接著,我們的後台線程就會陷入第二個循環中無法自拔。所以結果是:如果有一個客戶端已經連接上了,其它客戶端即使連接了也無法對它應答。最後需要注意的就是四個事件的使用,為了向用戶提供偵聽的埠號以進行連接,我又定義了一個PortNumberReadyEventHandler委託。


Talker類是最平庸的一個類,它的全部功能就是將操作委託給實際的IMessageReceiver和IMessageSender。定義這兩個介面的好處也從這裡可以看出來:如果日後想重新實現這個程序,所有Windows窗體的代碼和Talker的代碼都不需要修改,只需要針對這兩個介面編程就可以了。


現在我們開始設計窗體,我已經設計好了,現在可以先進行一下預覽:

這裡需要注意的就是上面的偵聽埠,是程序接收消息時的偵聽埠,也就是IMessageReceiver所使用的。其他的沒有什麼好說的,下來我們直接看一下代碼,控制項的命名是自解釋的,我就不多說什麼了。唯一要稍微說明下的是txtMessage指的是下面發送消息的文本框,txtContent指上面的消息記錄文本框:

publicpartialclassPrimaryForm:Form

{

privateTalker talker;

privatestringuserName;

publicPrimaryForm(stringname)

{ InitializeComponent(); userName = lbName.Text = name;

this.talker =newTalker();

this.Text = userName +" Talking ..."; talker.ClientLost +=newConnectionLostEventHandler(talker_ClientLost); talker.ClientConnected +=newClientConnectedEventHandler(talker_ClientConnected); talker.MessageReceived +=newMessageReceivedEventHandler(talker_MessageReceived); talker.PortNumberReady +=newPortNumberReadyEventHandler(PrimaryForm_PortNumberReady); }

voidConnectStatus() { }

voidDisconnectStatus() { }// 埠號OKvoidPrimaryForm_PortNumberReady(intportNumber) { PortNumberReadyEventHandler del =delegate(intport) { lbPort.Text = port.ToString(); }; lbPort.Invoke(del, portNumber); }// 接收到消息voidtalker_MessageReceived(stringmsg)

{ MessageReceivedEventHandler del =delegate(stringm)

{ txtContent.Text += m; }; txtContent.Invoke(del, msg); }// 有客戶端連接到本機voidtalker_ClientConnected(IPEndPoint endPoint)

{ ClientConnectedEventHandler del =delegate(IPEndPoint end)

{ IPHostEntry host = Dns.GetHostEntry(end.Address); txtContent.Text += String.Format("System[]:
遠程主機連接至本地。
", DateTime.Now, end); }; txtContent.Invoke(del, endPoint); }// 客戶端連接斷開voidtalker_ClientLost(stringinfo)

{ ConnectionLostEventHandler del =delegate(stringinformation)

{ txtContent.Text += String.Format("System[]:

", DateTime.Now, information); }; txtContent.Invoke(del, info); }// 發送消息privatevoidbtnSend_Click(objectsender, EventArgs e)

{

if(String.IsNullOrEmpty(txtMessage.Text))

{ MessageBox.Show("請輸入內容!"); txtMessage.Clear(); txtMessage.Focus();

return; } Message msg =newMessage(userName, txtMessage.Text);

if(talker.SendMessage(msg))

{ txtContent.Text += msg.ToString(); txtMessage.Clear(); }

else{ txtContent.Text += String.Format("System[]:
遠程主機已斷開連接
", DateTime.Now); DisconnectStatus(); } }// 點擊連接privatevoidbtnConnect_Click(objectsender, EventArgs e)

{

stringhost = txtHost.Text;

stringip = txtHost.Text;

intport;

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[]:
已成功連接至遠程
", DateTime.Now);

return; }if(talker.ConnectByIp(ip, port))

{ ConnectStatus(); txtContent.Text += String.Format("System[]:
已成功連接至遠程
", DateTime.Now); }else{ MessageBox.Show("遠程主機不存在,或者拒絕連接!"); } txtMessage.Focus(); }// 關閉按鈕點按privatevoidbtnClose_Click(objectsender, EventArgs e)

{

{

try{ talker.Dispose(); Application.Exit(); }catch{ } }// 點擊註銷privatevoidbtnSignout_Click(objectsender, EventArgs e)

{ talker.SignOut(); DisconnectStatus(); txtContent.Text += String.Format("System[]:
已經註銷
",DateTime.Now); }

privatevoidbtnClear_Click(objectsender, EventArgs e)

{ txtContent.Clear(); }}

在上面代碼中,分別通過四個方法訂閱了四個事件,以實現自動通知的機制。最後需要注意的就是SignOut()和Dispose()的區分。SignOut()只是斷開連接,Dispose()則是離開應用程序。


這篇文章簡單地分析、設計及實現了一個聊天程序。這個程序只是對無伺服器模式實現聊天的一個嘗試。我們分析了需求,隨後編寫了幾個用例,並對本地、遠程的概念做了定義,接著編寫了程序介面並最終實現了它。這個程序還有很嚴重的不足:它無法實現自動上線通知,而必須要事先知道埠號並進行手動連接。為了實現一個功能強大且開發容易的程序,更好的辦法是使用集中型伺服器模式。

感謝閱讀,我為了代碼整潔犧牲了空間,若有不便,向我反饋,希望這篇文章能給你帶來幫助!


喜歡這篇文章嗎?立刻分享出去讓更多人知道吧!

本站內容充實豐富,博大精深,小編精選每日熱門資訊,隨時更新,點擊「搶先收到最新資訊」瀏覽吧!


請您繼續閱讀更多來自 別人的編程日常 的精彩文章:

TAG:別人的編程日常 |