这是一篇基于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