C#网络编程:3异步传输字符串
在上一篇中,我们由简到繁,提到了服务端的四种方式:服务一个客户端的一个请求、服务一个客户端的多个请求、服务多个客户端的一个请求、服务多个客户端的多个请求。我们说到可以将里层的while循环交给一个新建的线程去让它来完成。除了这种方式以外,我们还可以使用一种更好的方式――使用线程池中的线程来完成。我们可以使用BeginRead()、BeginWrite()等异步方法,同时让这BeginRead()方法和它的回调方法形成一个类似于while的无限循环:首先在第一层循环中,接收到一个客户端后,调用BeginRead(),然后为该方法提供一个读取完成后的回调方法,然后在回调方法中对收到的字符进行处理,随后在回调方法中接着调用BeginRead()方法,并传入回调方法本身。
由于程序实现功能和上一篇完全相同,我就不再细述了。而关于异步调用方法更多详细内容,可以参见 C#中的委托和事件(续)。
1.服务端的实现
当程序越来越复杂的时候,就需要越来越高的抽象,所以从现在起我们不再把所有的代码全部都扔进Main()里,这次我创建了一个RemoteClient类,它对于服务端获取到的TcpClient进行了一个包装:
- public class RemoteClient {
- private TcpClient client;
- private NetworkStream streamToClient;
- private const int BufferSize = 8192;
- private byte[] buffer;
- private RequestHandler handler;
- public RemoteClient(TcpClient client) {
- this.client = client;
- // 打印连接到的客户端信息
- Console.WriteLine("\nClient Connected!{0} <-- {1}",
- client.Client.LocalEndPoint, client.Client.RemoteEndPoint);
- // 获得流
- streamToClient = client.GetStream();
- buffer = new byte[BufferSize];
- // 设置RequestHandler
- handler = new RequestHandler();
- // 在构造函数中就开始准备读取
- AsyncCallback callBack = new AsyncCallback(ReadComplete);
- streamToClient.BeginRead(buffer, 0, BufferSize, callBack, null);
- }
- // 再读取完成时进行回调
- private void ReadComplete(IAsyncResult ar) {
- int bytesRead = 0;
- try {
- lock (streamToClient) {
- bytesRead = streamToClient.EndRead(ar);
- Console.WriteLine("Reading data, {0} bytes ...", bytesRead);
- }
- if (bytesRead == 0) throw new Exception("读取到0字节");
- string msg = Encoding.Unicode.GetString(buffer, 0, bytesRead);
- Array.Clear(buffer,0,buffer.Length); // 清空缓存,避免脏读
- string[] msgArray = handler.GetActualString(msg); // 获取实际的字符串
- // 遍历获得到的字符串
- foreach (string m in msgArray) {
- Console.WriteLine("Received: {0}", m);
- string back = m.ToUpper();
- // 将得到的字符串改为大写并重新发送
- byte[] temp = Encoding.Unicode.GetBytes(back);
- streamToClient.Write(temp, 0, temp.Length);
- streamToClient.Flush();
- Console.WriteLine("Sent: {0}", back);
- }
- // 再次调用BeginRead(),完成时调用自身,形成无限循环
- lock (streamToClient) {
- AsyncCallback callBack = new AsyncCallback(ReadComplete);
- streamToClient.BeginRead(buffer, 0, BufferSize, callBack, null);
- }
- } catch(Exception ex) {
- if(streamToClient!=null)
- streamToClient.Dispose();
- client.Close();
- Console.WriteLine(ex.Message); // 捕获异常时退出程序
- }
- }
- }
随后,我们在主程序中仅仅创建TcpListener类型实例,由于RemoteClient类在构造函数中已经完成了初始化的工作,所以我们在下面的while循环中我们甚至不需要调用任何方法:
- class Server {
- static void Main(string[] args) {
- Console.WriteLine("Server is running ... ");
- IPAddress ip = new IPAddress(new byte[] { 127, 0, 0, 1 });
- TcpListener listener = new TcpListener(ip, 8500);
- listener.Start(); // 开始侦听
- Console.WriteLine("Start Listening ...");
- while (true) {
- // 获取一个连接,同步方法,在此处中断
- TcpClient client = listener.AcceptTcpClient();
- RemoteClient wapper = new RemoteClient(client);
- }
- }
- }
好了,服务端的实现现在就完成了,接下来我们再看一下客户端的实现:
2.客户端的实现
与服务端类似,我们首先对TcpClient进行一个简单的包装,使它的使用更加方便一些,因为它是服务端的客户,所以我们将类的名称命名为ServerClient:
- public class ServerClient {
- private const int BufferSize = 8192;
- private byte[] buffer;
- private TcpClient client;
- private NetworkStream streamToServer;
- private string msg = "Welcome to TraceFact.Net!";
- public ServerClient() {
- try {
- client = new TcpClient();
- client.Connect("localhost", 8500); // 与服务器连接
- } catch (Exception ex) {
- Console.WriteLine(ex.Message);
- return;
- }
- buffer = new byte[BufferSize];
- // 打印连接到的服务端信息
- Console.WriteLine("Server Connected!{0} --> {1}",
- client.Client.LocalEndPoint, client.Client.RemoteEndPoint);
- streamToServer = client.GetStream();
- }
- // 连续发送三条消息到服务端
- public void SendMessage(string msg) {
- msg = String.Format("[length={0}]{1}", msg.Length, msg);
- for (int i = 0; i <= 2; i++) {
- byte[] temp = Encoding.Unicode.GetBytes(msg); // 获得缓存
- try {
- streamToServer.Write(temp, 0, temp.Length); // 发往服务器
- Console.WriteLine("Sent: {0}", msg);
- } catch (Exception ex) {
- Console.WriteLine(ex.Message);
- break;
- }
- }
- lock (streamToServer) {
- AsyncCallback callBack = new AsyncCallback(ReadComplete);
- streamToServer.BeginRead(buffer, 0, BufferSize, callBack, null);
- }
- }
- public void SendMessage() {
- SendMessage(this.msg);
- }
- // 读取完成时的回调方法
- private void ReadComplete(IAsyncResult ar) {
- int bytesRead;
- try {
- lock (streamToServer) {
- bytesRead = streamToServer.EndRead(ar);
- }
- if (bytesRead == 0) throw new Exception("读取到0字节");
- string msg = Encoding.Unicode.GetString(buffer, 0, bytesRead);
- Console.WriteLine("Received: {0}", msg);
- Array.Clear(buffer, 0, buffer.Length); // 清空缓存,避免脏读
- lock (streamToServer) {
- AsyncCallback callBack = new AsyncCallback(ReadComplete);
- streamToServer.BeginRead(buffer, 0, BufferSize, callBack, null);
- }
- } catch (Exception ex) {
- if(streamToServer!=null)
- streamToServer.Dispose();
- client.Close();
- Console.WriteLine(ex.Message);
- }
- }
- }
在上面的SendMessage()方法中,我们让它连续发送了三条同样的消息,这么仅仅是为了测试,因为异步操作同样会出现上面说过的:服务器将客户端的请求拆开了的情况。最后我们在Main()方法中创建这个类型的实例,然后调用SendMessage()方法进行测试:
- class Client {
- static void Main(string[] args) {
- ConsoleKey key;
- ServerClient client = new ServerClient();
- client.SendMessage();
- Console.WriteLine("\n\n输入\"Q\"键退出。");
- do {
- key = Console.ReadKey(true).Key;
- } while (key != ConsoleKey.Q);
- }
- }
是不是感觉很清爽?因为良好的代码重构,使得程序在复杂程度提高的情况下依然可以在一定程度上保持良好的阅读性。
3.程序测试
最后一步,我们先运行服务端,接着连续运行两个客户端,看看它们的输出分别是什么:
大家可以看到,在服务端,我们可以连接多个客户端,同时为它们服务;除此以外,由接收的字节数发现,两个客户端均有两个请求被服务端合并成了一条请求,因为我们在其中加入了特殊的协议,所以在服务端可以对这种情况进行良好的处理。
在客户端,我们没有采取类似的处理,所以当客户端收到应答时,仍然会发生请求合并的情况。对于这种情况,我想大家已经知道该如何处理了,就不再多费口舌了。
使用这种定义协议的方式有它的优点,但缺点也很明显,如果客户知道了这个协议,有意地输入[length=xxx],但是后面的长度却不匹配,此时程序就会出错。可选的解决办法是对“[”和“]”进行编码,当客户端有意输入这两个字符时,我们将它替换成“\[”和“\]”或者别的字符,在读取后再将它还原。
关于这个范例就到此结束了,剩下的两个范例都将采用异步传输的方式,并且会加入更多的协议内容。下一篇我们将介绍如何向服务端发送或接收文件。
这篇文章我们将前进一大步,使用异步的方式来对服务端编程,以使它成为一个真正意义上的服务器:可以为多个客户端的多次请求服务。但是开始之前,我们需要解决上一节中遗留的一个问题。
消息发送时的问题
这个问题就是:客户端分两次向流中写入数据(比如字符串)时,我们主观上将这两次写入视为两次请求;然而服务端有可能将这两次合起来视为一条请求,这在两个请求间隔时间比较短的情况下尤其如此。同样,也有可能客户端发出一条请求,但是服务端将其视为两条请求处理。下面列出了可能的情况,假设我们在客户端连续发送两条“Welcome to Tracefact.net!”,则数据到达服务端时可能有这样三种情况:
NOTE:在这里我们假设采用ASCII编码方式,因为此时上面的一个方框正好代表一个字节,而字符串到达末尾后为持续的0(因为byte是值类型,且最小为0)。
上面的第一种情况是最理想的情况,此时两条消息被视为两个独立请求由服务端完整地接收。第二种情况的示意图如下,此时一条消息被当作两条消息接收了:
而对于第三种情况,则是两条消息被合并成了一条接收:
如果你下载了上一篇文章所附带的源码,那么将Client2.cs进行一下修改,不通过用户输入,而是使用一个for循环连续的发送三个请求过去,这样会使请求的间隔时间更短,下面是关键代码:
- string msg = "Welcome to TraceFact.Net!";
- for (int i = 0; i <= 2; i++) {
- byte[] buffer = Encoding.Unicode.GetBytes(msg); // 获得缓存
- try {
- streamToServer.Write(buffer, 0, buffer.Length); // 发往服务器
- Console.WriteLine("Sent: {0}", msg);
- } catch (Exception ex) {
- Console.WriteLine(ex.Message);
- break;
- }
- }
运行服务端,然后再运行这个客户端,你可能会看到这样的结果:
可以看到,尽管上面将消息分成了三条单独发送,但是服务端却将后两条合并成了一条。对于这些情况,我们可以这样处理:就好像HTTP协议一样,在实际的请求和应答内容之前包含了HTTP头,其中是一些与请求相关的信息。我们也可以订立自己的协议,来解决这个问题,比如说,对于上面的情况,我们就可以定义这样一个协议:
[length=XXX]:其中xxx是实际发送的字符串长度(注意不是字节数组buffer的长度),那么对于上面的请求,则我们发送的数据为:“[length=25]Welcome to TraceFact.Net!”。而服务端接收字符串之后,首先读取这个“元数据”的内容,然后再根据“元数据”内容来读取实际的数据,它可能有下面这样两种情况:
NOTE:我觉得这里借用“元数据”这个术语还算比较恰当,因为“元数据”就是用来描述数据的数据。
• “[“”]”中括号是完整的,可以读取到length的字节数。然后根据这个数值与后面的字符串长度相比,如果相等,则说明发来了一条完整信息;如果多了,那么说明接收的字节数多了,取出合适的长度,并将剩余的进行缓存;如果少了,说明接收的不够,那么将收到的进行一个缓存,等待下次请求,然后将两条合并。
• “[”“]”中括号本身就不完整,此时读不到length的值,因为中括号里的内容被截断了,那么将读到的数据进行缓存,等待读取下次发送来的数据,然后将两次合并之后再按上面的方式进行处理。
接下来我们来看下如何来进行实际的操作,实际上,这个问题已经不属于C#网络编程的内容了,而完全是对字符串的处理。所以我们不再编写服务端/客户端代码,直接编写处理这几种情况的方法:
- public class RequestHandler {
- private string temp = string.Empty;
- public string[] GetActualString(string input) {
- return GetActualString(input, null);
- }
- private string[] GetActualString(string input, List<string> outputList) {
- if (outputList == null)
- outputList = new List<string>();
- if (!String.IsNullOrEmpty(temp))
- input = temp + input;
- string output = "";
- string pattern = @"(?<=^\[length=)(\d+)(?=\])";
- int length;
- if (Regex.IsMatch(input, pattern)) {
- Match m = Regex.Match(input, pattern);
- // 获取消息字符串实际应有的长度
- length = Convert.ToInt32(m.Groups[0].Value);
- // 获取需要进行截取的位置
- int startIndex = input.IndexOf(']') + 1;
- // 获取从此位置开始后所有字符的长度
- output = input.Substring(startIndex);
- if (output.Length == length) {
- // 如果output的长度与消息字符串的应有长度相等
- // 说明刚好是完整的一条信息
- outputList.Add(output);
- temp = "";
- } else if (output.Length < length) {
- // 如果之后的长度小于应有的长度,
- // 说明没有发完整,则应将整条信息,包括元数据,全部缓存
- // 与下一条数据合并起来再进行处理
- temp = input;
- // 此时程序应该退出,因为需要等待下一条数据到来才能继续处理
- } else if (output.Length > length) {
- // 如果之后的长度大于应有的长度,
- // 说明消息发完整了,但是有多余的数据
- // 多余的数据可能是截断消息,也可能是多条完整消息
- // 截取字符串
- output = output.Substring(0, length);
- outputList.Add(output);
- temp = "";
- // 缩短input的长度
- input = input.Substring(startIndex + length);
- // 递归调用
- GetActualString(input, outputList);
- }
- } else { // 说明“[”,“]”就不完整
- temp = input;
- }
- return outputList.ToArray();
- }
- }
这个方法接收一个满足协议格式要求的输入字符串,然后返回一个数组,这是因为如果出现多次请求合并成一个发送过来的情况,那么就将它们全部返回。随后简单起见,我在这个类中添加了一个静态的Test()方法和PrintOutput()帮助方法,进行了一个简单的测试,注意我直接输入了length=13,这个是我提前计算好的。
- public static void Test() {
- RequestHandler handler = new RequestHandler();
- string input;
- // 第一种情况测试 - 一条消息完整发送
- input = "[length=13]明天中秋,祝大家节日快乐!";
- handler.PrintOutput(input);
- // 第二种情况测试 - 两条完整消息一次发送
- input = "明天中秋,祝大家节日快乐!";
- input = String.Format
- ("[length=13]{0}[length=13]{0}", input);
- handler.PrintOutput(input);
- // 第三种情况测试A - 两条消息不完整发送
- input = "[length=13]明天中秋,祝大家节日快乐![length=13]明天中秋";
- handler.PrintOutput(input);
- input = ",祝大家节日快乐!";
- handler.PrintOutput(input);
- // 第三种情况测试B - 两条消息不完整发送
- input = "[length=13]明天中秋,祝大家";
- handler.PrintOutput(input);
- input = "节日快乐![length=13]明天中秋,祝大家节日快乐!";
- handler.PrintOutput(input);
- // 第四种情况测试 - 元数据不完整
- input = "[leng";
- handler.PrintOutput(input); // 不会有输出
- input = "th=13]明天中秋,祝大家节日快乐!";
- handler.PrintOutput(input);
- }
- // 用于测试输出
- private void PrintOutput(string input) {
- Console.WriteLine(input);
- string[] outputArray = GetActualString(input);
- foreach (string output in outputArray) {
- Console.WriteLine(output);
- }
- Console.WriteLine();
- }
运行上面的程序,可以得到如下的输出:
OK,从上面的输出可以看到,这个方法能够满足我们的要求。对于这篇文章最开始提出的问题,可以很轻松地通过加入这个方法来解决,这里就不再演示了,但在本文所附带的源代码含有修改过的程序。在这里花费了很长的时间,接下来让我们回到正题,看下如何使用异步方式完成上一篇中的程序吧。
C#编写简单的聊天程序(2009-04-07 12:16:11)
引言
这是一篇基于Socket进行网络编程的入门文章,我对于网络编程的学习并不够深入,这篇文章是对于自己知识的一个巩固,同时希望能为初学的朋友提供一点参考。文章大体分为四个部分:程序的分析与设计、C#网络编程基础(篇外篇)、聊天程序的实现模式、程序实现。
程序的分析与设计
1.明确程序功能
如果大家现在已经参加了工作,你的经理或者老板告诉你,“小王,我需要你开发一个聊天程序”。那么接下来该怎么做呢?你是不是在脑子里有个雏形,然后就直接打开VS2005开始设计窗体,编写代码了呢?在开始之前,我们首先需要进行软件的分析与设计。就拿本例来说,如果只有这么一句话“一个聊天程序”,恐怕现在大家对这个“聊天程序”的概念就很模糊,它可以是像QQ那样的非常复杂的一个程序,也可以是很简单的聊天程序;它可能只有在对方在线的时候才可以进行聊天,也可能进行留言;它可能每次将消息只能发往一个人,也可能允许发往多个人。它还可能有一些高级功能,比如向对方传送文件等。所以我们首先需要进行分析,而不是一上手就开始做,而分析的第一步,就是搞清楚程序的功能是什么,它能够做些什么。在这一步,我们的任务是了解程序需要做什么,而不是如何去做。
了解程序需要做什么,我们可以从两方面入手,接下来我们分别讨论。
1.1请求客户提供更详细信息
我们可以做的第一件事就是请求客户提供更加详细的信息。尽管你的经理或老板是你的上司,但在这个例子中,他就是你的客户(当然通常情况下,客户是公司外部委托公司开发软件的人或单位)。当遇到上面这种情况,我们只有少得可怜的一条信息“一个聊天程序”,首先可以做的,就是请求客户提供更加确切的信息。比如,你问经理“对这个程序的功能能不能提供一些更具体的信息?”。他可能会像这样回答:“哦,很简单,可以登录聊天程序,登录的时候能够通知其他在线用户,然后与在线的用户进行对话,如果不想对话了,就注销或者直接关闭,就这些吧。”
有了上面这段话,我们就又可以得出下面几个需求:
1. 程序可以进行登录。
2. 登录后可以通知其他在线用户。
3. 可以与其他用户进行对话。
4. 可以注销或者关闭。
1.2对于用户需求进行提问,并进行总结
经常会有这样的情况:可能客户给出的需求仍然不够细致,或者客户自己本身对于需求就很模糊,此时我们需要做的就是针对用户上面给出的信息进行提问。接下来我就看看如何对上面的需求进行提问,我们至少可以向经理提出以下问题:
NOTE:这里我穿插一个我在见到的一个印象比较深刻的例子:客户往往向你表达了强烈的意愿他多么多么想拥有一个属于自己的网站,但是,他却没有告诉你网站都有哪些内容、栏目,可以做什么。而作为开发者,我们显然关心的是后者。
1. 登录时需要提供哪些内容?需不需要提供密码?
2. 允许多少人同时在线聊天?
3. 与在线用户聊天时,可以将一条消息发给一个用户,还是可以一次将消息发给多个用户?
4. 聊天时发送的消息包括哪些内容?
5. 注销和关闭有什么区别?
6. 注销和关闭对对方需不需要给对方提示?
由于这是一个范例程序,而我在为大家讲述,所以我只能再充当一下客户的角色,来回答上面的问题:
1. 登录时只需要提供用户名称就可以了,不需要输入密码。
2. 允许两个人在线聊天。(这里我们只讲述这种简单情况,允许多人聊天需要使用多线程)
3. 因为只有两个人,那么自然是只能发给一个用户了。
4. 聊天发送的消息包括:用户名称、发送时间还有正文。
5. 注销并不关闭程序,只是离开了对话,可以再次进行连接。关闭则是退出整个应用程序。
6. 注销和关闭均需要给对方提示。
好了,有了上面这些信息我们基本上就掌握了程序需要完成的功能,那么接下来做什么?开始编码了么?上面的这些属于业务流程,除非你对它已经非常熟悉,或者程序非常的小,那么可以对它进行编码,但是实际中,我们最好再编写一些用例,这样会使程序的流程更加的清楚。
1.3编写用例
通常一个用例对应一个功能或者叫需求,它是程序的一个执行路径或者执行流程。编写用例的思路是:假设你已经有了这样一个聊天程序,那么你应该如何使用它?我们的使用步骤,就是一个用例。用例的特点就每次只针对程序的一个功能编写,最后根据用例编写代码,最终完成程序的开发。我们这里的需求只有简单的几个:登录,发送消息,接收消息,注销或关闭,上面的分析是对这几点功能的一个明确。接下来我们首先编写第一个用例:登录。
在开始之前,我们先明确一个概念:客户端,服务端。因为这个程序只是在两个人(机器)之间聊天,那么我们大致可以绘出这样一个图来:
我们期望用户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.自动显示远程用户已经断开连接。
注意到一点:当远程主动注销时,它采取的动作为上面的“本地主动”,本地采取的动作则为这里的“远程被动”。
至此,应用程序的功能分析和用例编写就告一段落了,通过上面这些表格,之后再继续编写程序变得容易了许多。另外还需要记得,用例只能为你提供一个操作步骤的指导,在实现的过程中,因为技术等方面的原因,可能还会有少量的修改。如果修改量很大,可以重新修改用例;如果修改量不大,那么就可以直接编码。这是一个迭代的过程,也没有一定的标准,总之是以高效和合适为标准。
2.分析与设计
我们已经很清楚地知道了程序需要做些什么,尽管现在还不知道该如何去做。我们甚至可以编写出这个程序所需要的接口,以后编写代码的时候,我们只要去实现这些接口就可以了。这也符合面向接口编程的原则。另外我们注意到,尽管这是一个聊天程序,但是却可以明确地划分为两部分,一部分发送消息,一部分接收消息。另外注意上面标识为自动的语句,它们暗示这个操作需要通过事件的通知机制来完成。关于委托和事件,可以参考这两篇文章:
• C#中的委托和事件 - 委托和事件的入门文章,同时捎带讲述了Observer设计模式和.NET的事件模型
• C#中的委托和事件(续) - 委托和事件更深入的一些问题,包括异常、超时的处理,以及使用委托来异步调用方法。
2.1消息Message
首先我们可以定义消息,前面我们已经明确了消息包含三个部分:用户名、时间、内容,所以我们可以定义一个结构来表示这个消息:
- 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);
- }
- }
2.2消息发送方IMessageSender
从上面我们可以看出,消息发送方主要包含这样几个功能:登录、连接、发送消息、注销。另外在连接成功或失败时还要通知用户界面,发送消息成功或失败时也需要通知用户界面,因此,我们可以让连接和发送消息返回一个布尔类型的值,当它为真时表示连接或发送成功,反之则为失败。因为登录没有任何的业务逻辑,仅仅是记录控件的值并进行显示,所以我不打算将它写到接口中。因此我们可以得出它的接口大致如下:
- public interface IMessageSender {
- bool Connect(IPAddress ip, int port); // 连接到服务端
- bool SendMessage(Message msg); // 发送用户
- void SignOut(); // 注销系统
- }
2.3消息接收方IMessageReceiver
而对于消息接收方,从上面我们可以看出,它的操作全是被动的:客户端连接时自动提示,客户端连接丢失时显示自动提示,侦听到消息时自动提示。注意到上面三个词都用了“自动”来修饰,在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(); // 停止侦听端口
- }
我记得曾经看过有篇文章说过,最好不要在接口中定义事件,但是我忘了他的理由了,所以本文还是将事件定义在了接口中。
2.4主程序Talker
而我们的主程序是既可以发送,又可以接收,一般来说,如果一个类像获得其他类的能力,以采用两种方法:继承和复合。因为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;
- }
- }
现在,我们的程序大体框架已经完成,接下来要关注的就是如何实现它,现在让我们由设计走入实现,看看实现一个网络聊天程序,我们需要掌握的技术吧。
4.设计窗体,编写窗体事件代码
现在我们开始设计窗体,我已经设计好了,现在可以先进行一下预览:
这里需要注意的就是上面的侦听端口,是程序接收消息时的侦听端口,也就是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()则是离开应用程序。
总结
这篇文章简单地分析、设计及实现了一个聊天程序。这个程序只是对无服务器模式实现聊天的一个尝试。我们分析了需求,随后编写了几个用例,并对本地、远程的概念做了定义,接着编写了程序接口并最终实现了它。这个程序还有很严重的不足:它无法实现自动上线通知,而必须要事先知道端口号并进行手动连接。为了实现一个功能强大且开发容易的程序,更好的办法是使用集中型服务器模式。
感谢阅读,希望这篇文章能对你有所帮助。
编写程序代码
如果你已经看完了上面一节C#网络编程,那么本章完全没有讲解的必要了,所以我只列出代码,对个别值得注意的地方稍微地讲述一下。首先需要了解的就是,我们采用的是三个模式中开发起来难度较大的一种,无服务器参与的模式。还有就是我们没有使用广播消息,所以需要提前知道连接到的远程主机的地址和端口号。
1.实现IMessageSender接口
- 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();
- }
- }
这段代码可以用朴实无华来形容,所以我们直接看下一段。
2.实现IMessageReceiver接口
- 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委托。
3.实现Talker类
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 {
- }
- }
- }