C# 实现基本的套接字TCP通信
在C#中,实现了很多对Windows API的封装,在套接字通信方面也是如此。下面是一个TCP网络通信的简单实例,因为自己初学C#,记下来
学习
使用名字空间:
using System.NET;
using System.Net.Sockets;
服务器端:
五个步骤:
(1) 创建一个TcpListener类的对象,也叫“侦听器”,通过它,服务器能监听到客户的请求。
下面是TcpListener构造函数的3种实现:
TcpListener(Int32) 在指定端口号进行监听,现在比较少用
TcpListener(IPEndPoint) 绑定端点(包括IP和端口号)
TcpListener(IPAddress, Int32) 绑定IP和端口号
IPAddress类有一个方法Parse(),可以将IP 地址字符串转换为IPAddress 实例。
TcpListener类封装了套接字的创建(socket)和绑定(bind),简化了编程的输入
(2) 调用TcpListener类的Start()方法。该方法可以使TcpListenerduixiang开始监听连接请求。
(3) 方法AcceptSock()可以接收来自客户端的连接请求。该方法返回一个Socket类的对象,该对象是实现与客户端通信的套接字;
(4) 调用Sockets类的Receive和Send方法来通信;
(5) 使用Socket对象的Close()方法关闭连接;
使用TcpListener类的Stop()方法关闭侦听器。
客户端:
五个步骤:
(1) 创建一个TcpClient类的对象
TcpClient() 初始化 TcpClient 类的新实例。
TcpClient(AddressFamily) 使用指定的族初始化 TcpClient 类的新实例。
TcpClient(IPEndPoint) 初始化 TcpClient 类的新实例,并将其绑定到指定的本地终结点。
TcpClient(String, Int32) 初始化 TcpClient 类的新实例并连接到指定主机上的指定端口。
(2) 使用TcpClient类的Connect方法建立与服务器的连接。
疑惑:这里的Connect方法同样可以实现与指定IP和端口的服务器连接,那TcpClient类的构建函数中就没必要指定IP和端口了
(实际上,在下面的例子中,创建TcpClient对象时,并没有传参数)
(3) 使用TcpClient的GetStream()方法来得到一个用于发送和接收数据的NetworkStream对象。
NetworkStream的WriteByte和Write方法分别能够用于向服务器输出单字节或一组字节;ReadByte和Read方法分别能从服务器读取单字节或
一组字节;
public override void Write(
byte[] buffer,
int offset,
int size
)
参数解析:
buffer:类型 Byte 的数组,该数组包含要写入 NetworkStream 的数据。
offset:buffer 中开始写入数据的位置,一般取0;
size:要写入 NetworkStream 的字节数,取值为buffer.Length-offset。
(当offset=0时,size=buffer.Length)
Read()方法类似,不单做解释;
(4) 使用NetworkStream对象的WriteByte,Write,ReadByte,Read方法与服务器进行通信;
(5) 调用NetworkStream的Close方法来关闭连接;接着调用TcpClient类的Close()方法来终止TCP连接。
注意事项:
无论是Socket类的Receive()或Send()方法,还是NetworkStream的Read()或Write()方法,都是直接读写Byte[]类型的变量,而我们习惯使
用string类型的变量
使用如下转换:
字节数组到字符串:
Encoding.ASCII.GetString()
public virtual string GetString(
byte[] bytes
)
输入byte[]类型变量,输出string类型变量;
字符串到字节数组:
Encoding.ASCII.GetBytes()
public virtual byte[] GetBytes(
string s
)
输入string类型变量,输出byte[]类型变量;
代码实现:
实现功能:客户端向服务器发送信息,服务器在控制台显示接收到的信息;
服务器端:
[csharp] view plain copy
namespace tcplistenerTest
{
class Program
{
static void Main(string[] args)
{
string srvIP = "127.0.0.1";
IPAddress srvAddr = IPAddress.Parse(srvIP);
int nPort = 2030;
//创建并绑定套接字
TcpListener srvLstner = new TcpListener(srvAddr, nPort);
//开始监听
try
{
srvLstner.Start(5);
}
catch (SocketException e)
{
Console.WriteLine(e.Message);
srvLstner.Stop();
Console.ReadLine();
return;
}
//接受客户端连接请求
while (true)
{
//接收连接请求
Socket remoteSock = srvLstner.AcceptSocket();
//显示客户端发送过来的消息
Byte[] recvBuf = new Byte[128];
try
{
remoteSock.Receive(recvBuf);
}
catch (SocketException e)
{
Console.WriteLine(e.Message);
break;
}
string recvMsg = Encoding.ASCII.GetString(recvBuf);
Console.WriteLine(recvMsg);
remoteSock.Close();
}
srvLstner.Stop();
}
}
}
客户端:
[csharp] view plain copy
namespace TcpClientTest
{
class Program
{
static void Main(string[] args)
{
TcpClient tcpClient = new TcpClient();
IPAddress srvAddr = IPAddress.Parse("127.0.0.1");
int nPort = 2030;
//连接到服务器
try
{
tcpClient.Connect(srvAddr, nPort);
}
catch (SocketException e)
{
Console.WriteLine(e.Message);
tcpClient.Close();
Console.ReadLine();
return;
}
NetworkStream sockStream = tcpClient.GetStream();
string sendMsg = "hello, srver! This is client A";
Byte[] sendBuf = Encoding.ASCII.GetBytes(sendMsg);
try
{
sockStream.Write(sendBuf, 0, sendBuf.Length);
}
catch (SocketException e)
{
Console.WriteLine(e.Message);
sockStream.Close();
tcpClient.Close();
Console.ReadLine();
return;
}
Console.WriteLine("成功向服务器发送消息:{0}\n", sendMsg);
sockStream.Close();
tcpClient.Close();
Console.ReadLine();
}
}
}
========
C#网络编程初步之TCP
http://blog.csdn.net/mymonkey110/article/details/6841347
阅读背景:本文针对有C#的初学者而写的,主要讲解如何利用C#进行网络编程。如果你已经有一些网络编程的经验(只需要懂得网络编程
的基本常识即可),并且理解C#的基本语法,那么这篇文章可以很快地带你进入C#网络编程的世界。如果你的基础不好,也不要紧,我相信
这篇文章也会有你需要的内容。
网络编程基础复习:
图1. TCP编程基本模型
相信很多人看到图1应该不会陌生,这是一个利用TCP进行通信的经典模型图。我想大家都应该把这张图记在心中。在此我就不讲述
上图中每个API的意思了,百度一下,你就知道。我想说的是,难道你不觉得这么编程很累吗? 我们需要去调用每个API函数,然后每个判断
返回值是多少,如果你忘记了哪个API的参数形式还得去查MSDN,这种时间花费是巨大的,尤其当你做应用层的快速开发时。
图2是利用UDP通信时的编程基本模型,这个模型较为简单,但是应用极为广泛,相比TCP而言,我本人觉得利用UDP通信是一门更为
高深的技术,因为它是无连接的,换言之,它的效率与灵活度就更高些。
图2. UDP编程基本模型
在此我补充一点,关于何时利用TCP通信、何时利用UDP通信的问题。他们的特性其实已经决定了他们的适用范围。在进行大数据量
、持续连接时,我们使用TCP,例如FTP协议;而在进行小规模数据、突发性高的通信时,我们使用UDP,例如聊天程序。但是,这并不是绝
对的事情。例如流媒体通信,它是大数量、持续的通信,但是使用的是UDP协议,为什么呢?——因为我们不关心丢失的帧,人的肉眼是无
法识别出少量的帧丢失的。那么使用UDP通信就可以大幅度提高效率,降低网络负载。
C#之TCP编程
如何创建一个套接字?
我们先来看看利用Winsock2是如何建立一个套接字的:
首先,我们要加载套接字库,然后再建立套接字。大致代码如下:
WORD wVersion=MAKEWORD(2,2);
WSADATA wsaData;
if(WSAStartup(wVersion,&wsaData))
{
WSACleanup();
returnFALSE;
}
m_sock=WSASocket(AF_INET,SOCK_DGRAM,IPPROTO_UDP,NULL,0,0);
if(m_sock==INVALID_SOCKET)
{
MessageBox("创建套接字失败!");
return FALSE;
}
难道你不觉得利用Winsock2创建一个套接字很费劲吗?如果你在Linux环境中变成倒是可以省掉加载套接字的部分,但是却只能反
复的调用API,这样也是很费时的事情。那我们再看看看利用C#是如何帮你简化工作的。这里我会介绍TCPClient类。
以上是从MSDN上截取的一段话,可见我们利用TCPClient还处理与TCP通信相关的操作。TCPClient有四个构造函数,每个构造函数
的用法是有不同的。这里我补充一个知识,那就是端地址在C#中描述。我们知道,我们用一个IP地址和一个端口号就可以表示一个端地址。
在C#中我们利用IPEndPoint类来表示一个端地址,本人经常利用如下的构造函数来创建一个IPEndPoint类。
IPEndPoint localEP = new IPEndPoint(IPAddress.Parse("127.0.0.1"),6666);
这样来表示一个端地址是不是比创建一个struct sockaddr_in的结构体来的快呢?
如何绑定一个端地址?
我们已经创建了一个端地址,也构造了套接字(TCPClient类),那么如何将二者绑定起来呢?也许你已经发现了,在建立TCPClient
的时候我们其实就可以绑定端地址了。如果你使用的TCPClient tcp_Client=new TCPClient()的构造函数来创建的TCPClient,那么系统会认
为你没有人为的制定端地址,而会自动帮你制定端地址,在创建客户端的TCPClient时我们常常这样做,因为我们不关心客户端的端地址。
如果是服务器监听呢?在服务器监听时我们会使用例外一个类,叫做TCPListener,接下来我会讲到。我们可以利用TCPClient(IPEndPoint)
来构造一个绑定到固定端地址的TCPClient类。例如:
TcpClient tcp_Client = new TcpClient(localEP);
如何监听套接字?
到现在为此我们还没讨论如何监听一个套接字。在传统的socket编程中,我们创建一个套接字,然后把它绑定到一个端地址,而后
调用Listen()来监听套接字。而在C#中,我们利用TCPListener来帮我们完成这些工作。让我们先来看看如何在C#监听套接字。
IPEndPointlocalEP = new IPEndPoint(IPAddress.Parse("127.0.0.1"),6666);
TcpListenerListener = new TcpListener(localEP);
Listener.Start(10);
我们首先创建需要绑定的端地址,而后创建监听类,并利用其构造函数将其绑定到端地址,然后调用Start(int number)方法来真
正实施监听。这与我们传统的socket编程不同。以前我们都是先创建一个socket,然后再创建一个sockaddr_in的结构体。我想你应该开始
感受到了C#的优势了,它帮我们省去了很多低级、繁琐的工作,让我们能够真正专注于我们的软件架构和设计思想。
如何接受客户端连接?
接听套接字后面自然就是接受TCP连接了。我们利用下面一句话来完成此工作:
TcpClient remoteClient =Listener.AcceptTcpClient();
类似于accept函数来返回一个socket,利用TCPListener类的AcceptTcpClient方法我们可以得到一个与客户端建立了连接的
TCPClient类,而由TCPClient类来处理以后与客户端的通信工作。我想你应该开始理解为什么会存在TCPClient和TCPListener两个类了。这
两个类的存在有着更加明细的区分,让监听和后续的通信真正分开,让程序员也更加容易理解和使用了。
这里我还得补充一点:监听是一个非阻塞的操作(Listener.Start()),而接受连接是一个阻塞操作(Listener.AcceptTcpClient)。
说了这么多,还不如来个实例来的明确。接下来,我会通过一个简单的控制台聊天程序来如何使用这些。先贴代码吧!
服务器端:
<span style="font-family:'Microsoft YaHei';font-size:18px;">using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Net;
using System.Net.Sockets;
namespace Demo
{
class Program
{
static void Main(string[]args)
{
byte[]SendBuf = Encoding.UTF8.GetBytes("Hello,Client!"); //发给客户端的消息;
IPEndPointlocalEP = new IPEndPoint(IPAddress.Parse("127.0.0.1"),6666); //本地端地址
TcpListenerListener = new TcpListener(localEP); //建立监听类,并绑定到指定的端地址
Listener.Start(10); //开始监听
Console.WriteLine("Server is listening...");
TcpClientremoteClient = Listener.AcceptTcpClient(); //等待连接(阻塞)
Console.WriteLine("Client:{0} connected!",remoteClient.Client.RemoteEndPoint.ToString()) ; //打印客户端连
接信息;
remoteClient.Client.Send(SendBuf); //发送欢迎信息;
remoteClient.Close(); //关闭连接;
}
}
}
客户端:
<span style="font-family:'Microsoft YaHei';font-size:18px;">using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Net;
using System.Net.Sockets;
namespace Demo_Client
{
class Program
{
static void Main(string[] args)
{
byte[] RecvBuf=new byte[1024]; //申请接收缓存;
int RecvBytes = 0; //接收字节数;
string recvmsg=null; //接收消息;
IPEndPoint remoteEP = new IPEndPoint(IPAddress.Parse("127.0.0.1"), 6666); //远程服务器端地址;
TcpClient remoteServer = new TcpClient(); //创建TCPClient类来与服务器通信;
remoteServer.Connect(remoteEP); //调用connect方法连接远端服务器;
Console.WriteLine("I'm using {0}.", remoteServer.Client.LocalEndPoint); //打印自己使用的端地址;
RecvBytes=remoteServer.Client.Receive(RecvBuf); //接受服务器发送过来的消息;
recvmsg=Encoding.UTF8.GetString(RecvBuf,0,RecvBytes); //将接受到的字节码转化为string类型;
Console.WriteLine("Server says:{0}.", recvmsg); //打印欢迎信息;
}
}
}
在C#网络编程中,我们要用到两个名空间,分别是System.Net和System.Net.Socket。可能有人会有这样的疑惑,干嘛要申请一个
Byte数组。我们知道,在传统socket编程中,我们都是用char*来发送或者接受消息的,其实char*和Byte[]是同源的。他们都是一个Byte,
而使用Byte[]能更易于人们理解和转化为其他类型。我们知道网络间传输的字节流,而Byte[]刚好符合了这个思想。如果对以上类的用法不
理解或者不熟悉的话,建议查看MSDN,上面讲解的很详细。
========
c# socket套接字
C#是微软随着VS.net新推出的一门语言。它作为一门新兴的语言,有着C++的强健,又有着VB等的RAD特性。而且,微软推出C#主要的目
的是为了对抗Sun公司的Java。大家都知道Java语言的强大功能,尤其在网络编程方面。于是,C#在网络编程方面也自然不甘落后于人。本
文就向大家介绍一下C#下实现套接字(Sockets)编程的一些基本知识,以期能使大家对此有个大致了解。首先,我向大家介绍一下套接字
的概念。
套接字基本概念:
套接字是通信的基石,是支持TCP/IP协议的网络通信的基本操作单元。可以将套接字看作不同主机间的进程进行双向通信的端点,它
构成了单个主机内及整个网络间的编程界面。套接字存在于通信域中,通信域是为了处理一般的线程通过套接字通信而引进的一种抽象概念
。套接字通常和同一个域中的套接字交换数据(数据交换也可能穿越域的界限,但这时一定要执行某种解释程序)。各种进程使用这个相同
的域互相之间用Internet协议簇来进行通信。
套接字可以根据通信性质分类,这种性质对于用户是可见的。应用程序一般仅在同一类的套接字间进行通信。不过只要底层的通信协
议允许,不同类型的套接字间也照样可以通信。套接字有两种不同的类型:流套接字和数据报套接字。
套接字工作原理:
要通过互联网进行通信,你至少需要一对套接字,其中一个运行于客户机端,我们称之为ClientSocket,另一个运行于服务器端,我
们称之为ServerSocket。
根据连接启动的方式以及本地套接字要连接的目标,套接字之间的连接过程可以分为三个步骤:服务器监听,客户端请求,连接确认
。
所谓服务器监听,是服务器端套接字并不定位具体的客户端套接字,而是处于等待连接的状态,实时监控网络状态。
所谓客户端请求,是指由客户端的套接字提出连接请求,要连接的目标是服务器端的套接字。为此,客户端的套接字必须首先描述它
要连接的服务器的套接字,指出服务器端套接字的地址和端口号,然后就向服务器端套接字提出连接请求。
所谓连接确认,是指当服务器端套接字监听到或者说接收到客户端套接字的连接请求,它就响应客户端套接字的请求,建立一个新的
线程,把服务器端套接字的描述发给客户端,一旦客户端确认了此描述,连接就建立好了。而服务器端套接字继续处于监听状态,继续接收
其他客户端套接字的连接请求。
C#中的套接字编程实例:
通过向大家简单的介绍套接字的基本概念和实现套接字编程的基本原理,我想大家对套接字编程已有了初步的了解。不过,上面介绍
的仅仅是基本概念和原理,要真正运用还是需要一定的工作的。对基本概念和原理的真正理解的最好方法莫过于自己动手做一个实例,下面
我就向大家介绍一个很好的用C#实现套接字编程的实例――聊天室程序。
本程序是基于C/S(服务器/客户端)构架的,程序包含一个服务器端的应用程序和一个客户端的应用程序。首先,在服务器上运行服
务器端的应用程序,该程序一运行就开始服务器监听。然后,在客户机上就可以打开客户端的应用程序。程序打开后可以与服务器端应用程
序进行连接,即进行客户端请求。在连接确认后,客户端用户可以和其他的客户端用户进行聊天。客户端人数没有限制,同时还支持“悄悄
话”聊天模式,支持聊天记录。所以这是一个学习套接字编程的相当不错的例子。而且,程序中为了处理每个客户端的信息还用到了多线程
机制。在每个客户端与服务器端连接成功后,它们之间就建立一个线程。这样运用了多线程之后,客户端之间就不会相互影响,即使其中一
个出了错误也不会影响到另一个。
下面,我就向大家具体介绍该实例:
服务器端程序:
1. 打开VS.net,新建一个C#的模板为“Windows 应用程序”的项目,不妨命名为“ChatServer”。
2. 布置界面。只需在界面上添加一个ListBox控件即可,该控件主要用于显示客户端的用户的一些信息的。图象如下:
3. 服务器端程序的代码编写。
对于服务器端,主要的作用是监听客户端的连接请求并确认其请求。程序一开始便打开一个StartListening()线程。
private void StartListening()
{
listener = new TcpListener(listenport);
listener.Start();
while (true)
{
try
{
Socket s = listener.AcceptSocket();
clientsocket = s;
clientservice = new Thread(new ThreadStart(ServiceClient));
clientservice.Start();
}
catch(Exception e)
{
Console.WriteLine(e.ToString() );
}
}
}
该线程是一直处于运行状态的。当服务器端接收到一个来自客户端的连接请求后,它就打开一个ServiceClient()线程来服务客户端
。当一个连接被建立后,每个客户端就被赋予一个属于它自己的套接字。同时,一个Client类的对象被建立。该对象包含了客户端的一些相
关信息,该信息被保存在一个数组列表中。 Client类如下:
using System;
using System.Threading;
namespace ChatServer
{
using System.Net.Sockets;
using System.Net;
///
/// Client 的摘要说明。
///
public class Client
{
private Thread clthread;
private EndPoint endpoint;
private string name;
private Socket sock;
public Client(string _name, EndPoint _endpoint, Thread _thread, Socket _sock)
{
// TODO: 在此处添加构造函数逻辑
clthread = _thread;
endpoint = _endpoint;
name = _name;
sock = _sock;
}
public override string ToString()
{
return endpoint.ToString()+ " : " + name;
}
public Thread CLThread
{
get{return clthread;}
set{clthread = value;}
}
public EndPoint Host
{
get{return endpoint;}
set{endpoint = value;}
}
public string Name
{
get{return name;}
set{name = value;}
}
public Socket Sock
{
get{return sock;}
set{sock = value;}
}
}
}
程序的主体部分应是ServiceClient()函数。该函数是一个独立的线程,其主要部分是一个while循环。在循环体内,程序处理各种
客户端命令。服务器端接收来自客户端的以ASCII码给出的字符串,其中包含了一个“|”形式的分隔符。字符串中“|”以前的部分就是具
体的命令,包括CONN、CHAT、PRIV、GONE四种类型。CONN命令建立一个新的客户端连接,将现有的用户列表发送给新用户并告知其他用户有
一个新用户加入。CHAT命令将新的信息发送给所有用户。PRIV命令将悄悄话发送给某个用户。GONE命令从用户列表中除去一个已离开的用户
并告知其他的用户某某已经离开了。同时,GONE命令可以设置布尔型的变量keepalive为false从而结束与客户端连接的线程。
ServiceClient()函数如下:
private void ServiceClient()
{
Socket client = clientsocket;
bool keepalive = true;
while (keepalive)
{
Byte[] buffer = new Byte[1024];
client.Receive(buffer);
string clientcommand = System.Text.Encoding.ASCII.GetString(buffer);
string[] tokens = clientcommand.Split(new Char[]{'|'});
Console.WriteLine(clientcommand);
if (tokens[0] == "CONN")
{
for(int n=0; n
{
Client cl = (Client)clients[n];
SendToClient(cl, "JOIN|" + tokens[1]);
}
EndPoint ep = client.RemoteEndPoint;
Client c = new Client(tokens[1], ep, clientservice, client);
clients.Add(c);
string message = "LIST|" + GetChatterList() +"\r\n";
SendToClient(c, message);
lbClients.Items.Add(c);
}
if (tokens[0] == "CHAT")
{
for(int n=0; n
{
Client cl = (Client)clients[n];
SendToClient(cl, clientcommand);
}
}
if (tokens[0] == "PRIV")
{
string destclient = tokens[3];
for(int n=0; n
{
Client cl = (Client)clients[n];
if(cl.Name.CompareTo(tokens[3]) == 0)
SendToClient(cl, clientcommand);
if(cl.Name.CompareTo(tokens[1]) == 0)
SendToClient(cl, clientcommand);
}
}
if (tokens[0] == "GONE")
{
int remove = 0;
bool found = false;
int c = clients.Count;
for(int n=0; n
{
Client cl = (Client)clients[n];
SendToClient(cl, clientcommand);
if(cl.Name.CompareTo(tokens[1]) == 0)
{
remove = n;
found = true;
lbClients.Items.Remove(cl);
}
}
if(found)
clients.RemoveAt(remove);
client.Close();
keepalive = false;
}
}
}
这样,服务器端程序就基本完成了。(其他略次要的代码可以参见源代码中的Form1.cs文件)程序运行图示如下:
客户端程序:
1. 打开VS.net,新建一个C#的模板为“Windows 应用程序”的项目,不妨命名为“ChatClient”。
2. 布置界面。往界面上添加一个ListBox控件(用于显示用户列表),一个RichTextBox控件(用于显示聊天消息以及系统消息),
一个TextBox控件(用于发送消息),一个CheckBox控件(确定是否为悄悄话),一个StatusBar控件以及四个Button控件(分别为“连接”
、“断开连接”、“开始记录”、“发送”)。各个控件的属性设置可以参见源代码中的具体设置,这里从略。界面设计好后的图象如下:
3. 客户端程序的代码编写。
当客户端试图和服务器端进行连接时,一个连接必须建立而且得向服务器端进行注册。 EstablishConnection()函数运用一个
TcpClient来和服务器端取得连接,同时创建一个NetworkStream来发送消息。还有,端口号和服务器端的是保持一致的,均为5555。
EstablishConnection()函数如下:
private void EstablishConnection()
{
statusBar1.Text = "正在连接到服务器";
try
{
clientsocket = new TcpClient(serveraddress,serverport);
ns = clientsocket.GetStream();
sr = new StreamReader(ns);
connected = true;
}
catch (Exception)
{
MessageBox.Show("不能连接到服务器!","错误",
MessageBoxButtons.OK, MessageBoxIcon.Exclamation);
statusBar1.Text = "已断开连接";
}
}
在和服务器端连接成功后,程序就用RegisterWithServer()函数向服务器端发送一个CONN命令。该命令先是发送该用户的名称,然
后从服务器端获得其他所有用户的列表,所有用户列表是在ListBox控件中显示的。该函数如下:
private void RegisterWithServer()
{
try
{
string command = "CONN|" + ChatOut.Text;
Byte[] outbytes = System.Text.Encoding.ASCII.GetBytes(command.ToCharArray());
ns.Write(outbytes,0,outbytes.Length);
string serverresponse = sr.ReadLine();
serverresponse.Trim();
string[] tokens = serverresponse.Split(new Char[]{'|'});
if(tokens[0] == "LIST")
{
statusBar1.Text = "已连接";
btnDisconnect.Enabled = true;
}
for(int n=1; n
lbChatters.Items.Add(tokens[n].Trim(new char[]{'\r','\n'}));
this.Text = clientname + ":已连接到服务器";
}
catch (Exception)
{
MessageBox.Show("注册时发生错误!","错误",
MessageBoxButtons.OK, MessageBoxIcon.Exclamation);
}
}
在此之后,当然就是用户之间的聊天了,由ReceiveChat()函数来完成。该函数是一个独立的线程,它处理所有用户获得的消息和用
户发送的消息。它主要处理了CHAT、PRIV、JOIN、GONE、QU99v等命令,处理的方法和服务器端的类似。具体函数实现如下:
private void ReceiveChat()
{
bool keepalive = true;
while (keepalive)
{
try
{
Byte[] buffer = new Byte[2048];
ns.Read(buffer,0,buffer.Length);
string chatter = System.Text.Encoding.ASCII.GetString(buffer);
string[] tokens = chatter.Split(new Char[]{'|'});
if (tokens[0] == "CHAT")
{
rtbChatIn.AppendText(tokens[1]);
if(logging)
logwriter.WriteLine(tokens[1]);
}
if (tokens[0] == "PRIV")
{
rtbChatIn.AppendText("Private from ");
rtbChatIn.AppendText(tokens[1].Trim() );
rtbChatIn.AppendText(tokens[2] + "\r\n");
if(logging)
{
logwriter.Write("Private from ");
logwriter.Write(tokens[1].Trim() );
logwriter.WriteLine(tokens[2] + "\r\n");
}
}
if (tokens[0] == "JOIN")
{
rtbChatIn.AppendText(tokens[1].Trim() );
rtbChatIn.AppendText(" has joined the Chat\r\n");
if(logging)
{
logwriter.WriteLine(tokens[1]+" has joined the Chat");
}
string newguy = tokens[1].Trim(new char[]{'\r','\n'});
lbChatters.Items.Add(newguy);
}
if (tokens[0] == "GONE")
{
rtbChatIn.AppendText(tokens[1].Trim() );
rtbChatIn.AppendText(" has left the Chat\r\n");
if(logging)
{
logwriter.WriteLine(tokens[1]+" has left the Chat");
}
lbChatters.Items.Remove(tokens[1].Trim(new char[]{'\r','\n'}));
}
if (tokens[0] == "QU99v")
{
ns.Close();
clientsocket.Close();
keepalive = false;
statusBar1.Text = "服务器端已停止";
connected= false;
btnSend.Enabled = false;
btnDisconnect.Enabled = false;
}
}
catch(Exception){}
}
}
通过以上的一些函数,客户端程序之间就可以进行自由地聊天了,各个用户之间还可以互相发送悄悄话。所以程序已经实现了聊天室
的基本功能了,不过最后各个用户还要正常地退出,那就要用到QuitChat()函数了。该函数的具体实现如下:
private void QuitChat()
{
if(connected)
{
try
{
string command = "GONE|" + clientname;
Byte[] outbytes = System.Text.Encoding.ASCII.GetBytes(command.ToCharArray());
ns.Write(outbytes,0,outbytes.Length);
clientsocket.Close();
}
catch(Exception)
{
}
}
if(logging)
logwriter.Close();
if(receive != null && receive.IsAlive)
receive.Abort();
this.Text = "客户端";
}
到此为止,客户端程序的主要部分都已经介绍完毕。还有一些按钮控件的消息处理函数可以参见源代码。同时,程序中还有一个聊天
记录功能,该功能和现在流行的聊天软件的记录功能类似。不过限于篇幅,在这里就不一一介绍了,有兴趣的读者可以研究一下本文后面的
源代码。
这样,客户端程序就完成了。程序运行图示如下:
总结:
本文向大家初步介绍了套接字的基本概念和实现套接字编程的基本原理,还通过一个很好的实例向大家展示了在C#下进行套接字编程
的实现方法和一些编程技巧。从中,我们不难发现运用C#进行套接字编程乃至网络编程有许多优越之处。实例程序实现的思路清晰明了而且
通俗易懂,是一个相当不错的例子,希望各位能好好研读。同时还希望大家能进一步完善该程序,使之功能更强大、界面更友好。
========
C#异步套接字编程
本文比较适合初步接触C#网络编程的人,有兴趣的话可以进来看看哦。
C#套接字编程需要引用的命名空间包括:
System.Net.Sockets.Socket
System.Net
想要实现异步通讯,就必需实现异步服务器和异步客户端,两者各司其职,当建立连接后就可以进行网络数据的收发了。
在异步服务器端
1、创建套接字
2、绑定
3、监听
4、接受连接请求
在异步客户端
1、创建套接字
2、连接服务器
好了,开始进入今天的正题:
建立两个WinForm工程:一个作为服务器工程取名为TcpServer,另一个作为客户端工程取名为TcpClient。
TcpServer 窗体设计如下:
C#异步套接字编程
后台代码为:
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Data;
using System.Drawing;
using System.Text;
using System.Windows.Forms;
using System.Net.Sockets;
using System.Net;
namespace TcpServer
{
public partial class Form1 : Form
{
public Form1()
{
InitializeComponent();
}
public Socket sckServer; //服务器端套接字
public static Socket sckSend; //发送数据套接字
byte[] ReceiveBuf = new byte[256]; //接收缓冲区
byte[] SendBuf = new byte[256]; //发送缓冲区
/// <summary>
/// 启动服务
/// </summary>
/// <param name="sender"></param>
/// <param name="e"></param>
private void btnStartServer_Click(object sender, EventArgs e)
{
//获取Ip地址端口信息
IPHostEntry local = Dns.GetHostByName(Dns.GetHostName());
IPAddress myIp = IPAddress.Parse(local.AddressList[0].ToString());
IPEndPoint iep = new IPEndPoint(myIp, 8001);
//创建服务器套接字
sckServer = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
//绑定
sckServer.Bind(iep);
//监听
sckServer.Listen(5);
//接受连接请求
sckServer.BeginAccept(new AsyncCallback(AcceptCallback), sckServer);
}
/// <summary>
/// 接受连接请求回调函数
/// </summary>
/// <param name="ar"></param>
private void AcceptCallback(IAsyncResult ar)
{
Socket sock = (Socket)ar.AsyncState;
Socket sckAccept = sock.EndAccept(ar);
sckSend = sckAccept;
//清空接收数据缓冲区
Array.Clear(ReceiveBuf, 0, 256);
//接收数据
sckSend.BeginReceive(ReceiveBuf, 0, 256, SocketFlags.None, new AsyncCallback(ReceiveCallback),sckSend);
//接受连接请求
sckServer.BeginAccept(new AsyncCallback(AcceptCallback), sckServer);
}
/// <summary>
/// 接收数据回调函数
/// </summary>
/// <param name="ar"></param>
private void ReceiveCallback(IAsyncResult ar)
{
Socket sckReceive = (Socket)ar.AsyncState;
int revLength = sckReceive.EndReceive(ar);
//把接收到的数据转成字符串显示到界面
string strReceive = System.Text.Encoding.UTF8.GetString(ReceiveBuf, 0, 256);
txtReceive.Text = strReceive;
//再次接收数据
sckReceive.BeginReceive(ReceiveBuf, 0, 256, SocketFlags.None, new AsyncCallback(ReceiveCallback), sckReceive);
}
/// <summary>
/// 发送数据
/// </summary>
/// <param name="sender"></param>
/// <param name="e"></param>
private void btnSend_Click(object sender, EventArgs e)
{
SendBuf = System.Text.Encoding.UTF8.GetBytes(txtSend.Text);
sckSend.BeginSend(SendBuf, 0, SendBuf.Length, SocketFlags.None, new AsyncCallback(SendCallback), sckSend);
}
/// <summary>
/// 发送数据回调函数
/// </summary>
/// <param name="ar"></param>
private void SendCallback(IAsyncResult ar)
{
Socket sckSend = (Socket)ar.AsyncState;
int sendLength = sckSend.EndSend(ar);
}
}
}
TcpClient 窗体设计如下:
C#异步套接字编程
后台代码为:
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Data;
using System.Drawing;
using System.Text;
using System.Windows.Forms;
using System.Net.Sockets;
using System.Net;
namespace TcpClient
{
public partial class Form1 : Form
{
public Form1()
{
InitializeComponent();
}
public Socket sckClient; //客户端套接字
byte[] ReceiveBuf = new byte[256]; //接收缓冲区
byte[] SendBuf = new byte[256]; //发送缓冲区
/// <summary>
/// 连接服务器
/// </summary>
/// <param name="sender"></param>
/// <param name="e"></param>
private void btnConnect_Click(object sender, EventArgs e)
{
//获取Ip地址端口信息
IPHostEntry local = Dns.GetHostByName(Dns.GetHostName());
IPAddress myIp = IPAddress.Parse(local.AddressList[0].ToString());
IPEndPoint iep = new IPEndPoint(myIp, 8001);
//创建客户端套接字
sckClient = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
//连接服务器
sckClient.BeginConnect(iep, new AsyncCallback(ConnectCallback), sckClient);
}
/// <summary>
/// 连接服务器回调函数
/// </summary>
/// <param name="ar"></param>
private void ConnectCallback(IAsyncResult ar)
{
Socket sckConnect = (Socket)ar.AsyncState;
sckConnect.EndConnect(ar);
//清空接收数据缓冲区
Array.Clear(ReceiveBuf, 0, 256);
//接收数据
sckClient.BeginReceive(ReceiveBuf, 0, 256, SocketFlags.None, new AsyncCallback(ReceiveCallback), sckClient);
//连接成功提示
MessageBox.Show("连接成功!");
}
/// <summary>
/// 接收数据回调函数
/// </summary>
/// <param name="ar"></param>
private void ReceiveCallback(IAsyncResult ar)
{
Socket sckReceive = (Socket)ar.AsyncState;
int revLength = sckReceive.EndReceive(ar);
//把接收到的数据转成字符串显示到界面
string strReceive = System.Text.Encoding.Default.GetString(ReceiveBuf, 0, 256);
txtReceive.Text = strReceive;
//再次接收数据
sckClient.BeginReceive(ReceiveBuf, 0, 256, SocketFlags.None, new AsyncCallback(ReceiveCallback), sckClient);
}
/// <summary>
/// 发送数据
/// </summary>
/// <param name="sender"></param>
/// <param name="e"></param>
private void btnSend_Click(object sender, EventArgs e)
{
SendBuf = System.Text.Encoding.UTF8.GetBytes(txtSend.Text);
sckClient.BeginSend(SendBuf, 0, SendBuf.Length, SocketFlags.None, new AsyncCallback(SendCallback), sckClient);
}
/// <summary>
/// 发送数据回调函数
/// </summary>
/// <param name="ar"></param>
private void SendCallback(IAsyncResult ar)
{
Socket sckSend = (Socket)ar.AsyncState;
int sendLen = sckSend.EndSend(ar);
}
}
}
好了,现在就可以进行测试了。
首先,运行服务器程序,点击<启动>按钮,就可以开启服务器了。
然后,运行客户端程序,点击<连接>按钮,就能够连接服务器了。这时通讯就算建立起来了,赶快试试效果吧!
在服务器的发送区输入“123456”,点击<发送>按钮,数据就会发送到客户端的接收区。
在客户端的发送区输入“hello word”,点击<发送>按钮,字符串就会发送到服务器的接收区。
========
C# 实现的多线程异步Socket数据包接收器框架
http://blog.csdn.net/jubao_liang/article/details/4005438几天前在博问中看到一个C# Socket问题,就想到笔者2004年做的一个省级交通流量接收服务器项目,当时的基本求如下:
接收自动观测设备通过无线网卡、Internet和Socket上报的交通量数据包
全年365*24运行的自动观测设备5分钟上报一次观测数据,每笔记录约2K大小
规划全省将有100个左右的自动观测设备(截止2008年10月还只有30个)
当时,VS2003才发布年多,笔者也是接触C#不久。于是Google了国内国外网,希望找点应用C#解决Socket通信问题的思路和代码。最
后,找 到了两篇帮助最大的文章:一篇是国人写的Socket接收器框架,应用了独立的客户端Socket会话(Session)概念,给笔者提供了一
个接收服务 器的总体框架思路;另一篇是美国人写的,提出了多线程、分段接收数据包的技术方案,描述了多线程、异步Socket的许多实
现细节,该文坚定了笔者采用多 线程和异步方式处理Socket接收器的技术路线。
具体实现和测试时笔者还发现,在Internet环境下的Socket应用中,需要系统有极强的容错能力:没有办法控制异常,就必须允许它
们存在(附加源 代码中可以看到,try{}catch{}语句较多)。对此,笔者设计了一个专门的检查和清理线程,完成无效或超时会话的清除
和资源释放工作。
依稀记得,国内框架作者的名称空间有ibm,认为是IBM公司职员,通过邮件后才知道其人在深圳。笔者向他请教了几个问题,相互探
讨了几个技术关键点。可 惜,现在再去找,已经查不到原文和邮件了。只好借此机会,将本文献给这两个素未谋面的技术高人和同行,也
盼望拙文或源码能给读者一点有用的启发和帮助。
1、主要技术思路
整个系统由三个核心线程组成,并由.NET线程池统一管理:
侦听客户端连接请求线程:ListenClientRequest(),循环侦听客户端 连接请求。如果有,检测该客户端IP,看是否是同一观测设备,然后
建立一个客户端TSession对象,并通过Socket异步调用方法 BeginReceive()接收数据包、EndReceive()处理数据包
数据包处理线程:HandleDatagrams(),循环检测数据包队列_datagramQueue,完成数据包解析、判断类型、存储等工作
客户端状态检测线程:CheckClientState(),循环检查客户端会话表_sessionTable,判断会话对象是否有效,设置超时会话关闭标志,清
楚无效会话对象及释放其资源
2、主要类简介
系统主要由3个类组成:
TDatagramReceiver(数据包接收服务器):系统的核心进程类,建立Socket连接、处理与存储数据包、清理系统资源,该类提供全部的
public属性和方法
TSession(客户端会话):由每个客户端的Socket对象组成,有自己的数据缓冲区,清理线程根据该对象的最近会话时间判断是否超时
TDatagram(数据包类):判断数据包类别、解析数据包
3、关键函数和代码
下面简介核心类TDatagramReceiver的关键实现代码。
3.1 系统启动
系统启动方法StartReceiver()首先清理资源、创建数据库连接、初始化若干计数值,然后创建服务器端侦听Socket对象,最后调用
静态方法ThreadPool.QueueUserWorkItem()在线程池中创建3个核心处理线程。
复制代码
/// <summary>
/// 启动接收器
/// </summary>
public bool StartReceiver()
{
try
{
_stopReceiver = true;
this.Close();
if (!this.ConnectDatabase()) return false;
_clientCount = 0;
_datagramQueueCount = 0;
_datagramCount = 0;
_errorDatagramCount = 0;
_exceptionCount = 0;
_sessionTable = new Hashtable(_maxAllowClientCount);
_datagramQueue = new Queue<TDatagram>(_maxAllowDatagramQueueCount);
_stopReceiver = false; // 循环中均要该标志
if (!this.CreateReceiverSocket()) //建立服务器端 Socket 对象
{
return false;
}
// 侦听客户端连接请求线程, 使用委托推断, 不建 CallBack 对象
if (!ThreadPool.QueueUserWorkItem(ListenClientRequest))
{
return false;
}
// 处理数据包队列线程
if (!ThreadPool.QueueUserWorkItem(HandleDatagrams))
{
return false;
}
// 检查客户会话状态, 长时间未通信则清除该对象
if (!ThreadPool.QueueUserWorkItem(CheckClientState))
{
return false;
}
_stopConnectRequest = false; // 启动接收器,则自动允许连接
}
catch
{
this.OnReceiverException();
_stopReceiver = true;
}
return !_stopReceiver;
}
复制代码
下面是创建侦听Socket对象的方法代码。
复制代码
/// <summary>
/// 创建接收服务器的 Socket, 并侦听客户端连接请求
/// </summary>
private bool CreateReceiverSocket()
{
try
{
_receiverSocket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
_receiverSocket.Bind(new IPEndPoint(IPAddress.Any, _tcpSocketPort)); // 绑定端口
_receiverSocket.Listen(_maxAllowListenQueueLength); // 开始监听
return true;
}
catch
{
this.OnReceiverException();
return false;
}
}
复制代码
3.2 侦听客户端连接请求
服务器端循环等待客户端连接请求。一旦有请求,先判断客户端连接数是否超限,接着检测该客户端IP地址,一切正常后建立
TSession对象,并调用异步方法接收客户端Socket数据包。
代码中,Socket读到数据时的回调AsyncCallback委托方法EndReceiveData()完成数据接收工作,正常情况下启动另一个异步
BeginReceive()调用。
.NET中,每个异步方法都有自己的独立线程,异步处理其实也基于多线程机制的。下面代码中的异步套异步调用,既占用较大的系统
资源,也给处理带来意想不到的结果,更是出现异常时难以控制和处理的关键所在。
复制代码
/// <summary>
/// 循环侦听客户端请求,由于要用线程池,故带一个参数
/// </summary>
private void ListenClientRequest(object state)
{
Socket client = null;
while (!_stopReceiver)
{
if (_stopConnectRequest) // 停止客户端连接请求
{
if (_receiverSocket != null)
{
try
{
_receiverSocket.Close(); // 强制关闭接收器
}
catch
{
this.OnReceiverException();
}
finally
{
// 必须为 null,否则 disposed 对象仍然存在,将引发下面的错误
_receiverSocket = null;
}
}
continue;
}
else
{
if (_receiverSocket == null)
{
if (!this.CreateReceiverSocket())
{
continue;
}
}
}
try
{
if (_receiverSocket.Poll(_loopWaitTime, SelectMode.SelectRead))
{
// 频繁关闭、启动时,这里容易产生错误(提示套接字只能有一个)
client = _receiverSocket.Accept();
if (client != null && client.Connected)
{
if (this._clientCount >= this._maxAllowClientCount)
{
this.OnReceiverException();
try
{
client.Shutdown(SocketShutdown.Both);
client.Close();
}
catch { }
}
else if (CheckSameClientIP(client)) // 已存在该 IP 地址
{
try
{
client.Shutdown(SocketShutdown.Both);
client.Close();
}
catch { }
}
else
{
TSession session = new TSession(client);
session.LoginTime = DateTime.Now;
lock (_sessionTable)
{
int preSessionID = session.ID;
while (true)
{
if (_sessionTable.ContainsKey(session.ID)) // 有可能重复该编号
{
session.ID = 100000 + preSessionID;
}
else
{
break;
}
}
_sessionTable.Add(session.ID, session); // 登记该会话客户端
Interlocked.Increment(ref _clientCount);
}
this.OnClientRequest();
try // 客户端连续连接或连接后立即断开,易在该处产生错误,系统忽略之
{
// 开始接受来自该客户端的数据
session.ClientSocket.BeginReceive(session.ReceiveBuffer, 0,
session.ReceiveBufferLength, SocketFlags.None, EndReceiveData, session);
}
catch
{
session.DisconnectType = TDisconnectType.Exception;
session.State = TSessionState.NoReply;
}
}
}
else if (client != null) // 非空,但没有连接(connected is false)
{
try
{
client.Shutdown(SocketShutdown.Both);
client.Close();
}
catch { }
}
}
}
catch
{
this.OnReceiverException();
if (client != null)
{
try
{
client.Shutdown(SocketShutdown.Both);
client.Close();
}
catch { }
}
}
// 该处可以适当暂停若干毫秒
}
// 该处可以适当暂停若干毫秒
}
复制代码
3.3 处理数据包
该线程循环查看数据包队列,完成数据包的解析与存储等工作。具体实现时,如果队列中没有数据包,可以考虑等待若干毫秒,提高
CPU利用率。
复制代码
private void HandleDatagrams(object state)
{
while (!_stopReceiver)
{
this.HandleOneDatagram(); // 处理一个数据包
if (!_stopReceiver)
{
// 如果连接关闭,则重新建立,可容许几个连接错误出现
if (_sqlConnection.State == ConnectionState.Closed)
{
this.OnReceiverWork();
try
{
_sqlConnection.Open();
}
catch
{
this.OnReceiverException();
}
}
}
}
}
/// <summary>
/// 处理一个包数据,包括:验证、存储
/// </summary>
private void HandleOneDatagram()
{
TDatagram datagram = null;
lock (_datagramQueue)
{
if (_datagramQueue.Count > 0)
{
datagram = _datagramQueue.Dequeue(); // 取队列数据
Interlocked.Decrement(ref _datagramQueueCount);
}
}
if (datagram == null) return;
datagram.Clear();
datagram = null; // 释放对象
}
复制代码
3.4 检查与清理会话
本线程负责处理建立连接后的客户端会话TSession或Socket对象的关闭与资源清理工作,其它方法中出现异常等情况,尽可能标记相
关TSession对象的属性NoReply=true,表示该会话已经无效、需要清理。
检查会话队列并清理资源分3步:第一步,Shutdown()客户端Socket,此时可能立即触发某些Socket的异步方法 EndReceive();第
二步,Close()客户端Socket,释放占用资源;第三步,从会话表中清除该会话对象。其中,第一步完成后,某个 TSession也许不会立即到
第二步,因为可能需要处理其异步结束方法。
需要指出, 由于涉及多线程处理,需要频繁加解锁操作,清理工作前先建立一个会话队列列副本sessionTable2,检查与清理该队副
本列列的TSession对象。
复制代码
/// <summary>
/// 检查客户端状态(扫描方式,若长时间无数据,则断开)
/// </summary>
private void CheckClientState(object state)
{
while (!_stopReceiver)
{
DateTime thisTime = DateTime.Now;
// 建立一个副本 ,然后对副本进行操作
Hashtable sessionTable2 = new Hashtable();
lock (_sessionTable)
{
foreach (TSession session in _sessionTable.Values)
{
if (session != null)
{
sessionTable2.Add(session.ID, session);
}
}
}
foreach (TSession session in sessionTable2.Values) // 对副本进行操作
{
Monitor.Enter(session);
try
{
if (session.State == TSessionState.NoReply) // 分三步清除一个 Session
{
session.State = TSessionState.Closing;
if (session.ClientSocket != null)
{
try
{
// 第一步:shutdown
session.ClientSocket.Shutdown(SocketShutdown.Both);
}
catch { }
}
}
else if (session.State == TSessionState.Closing)
{
session.State = TSessionState.Closed;
if (session.ClientSocket != null)
{
try
{
// 第二步: Close
session.ClientSocket.Close();
}
catch { }
}
}
else if (session.State == TSessionState.Closed)
{
lock (_sessionTable)
{
// 第三步:remove from table
_sessionTable.Remove(session.ID);
Interlocked.Decrement(ref _clientCount);
}
this.OnClientRequest();
session.Clear(); // 清空缓冲区
}
else if (session.State == TSessionState.Normal) // 正常的会话
{
TimeSpan ts = thisTime.Subtract(session.LastDataReceivedTime);
if (Math.Abs(ts.TotalSeconds) > _maxSocketDataTimeout) // 超时,则准备断开连接
{
session.DisconnectType = TDisconnectType.Timeout;
session.State = TSessionState.NoReply; // 标记为将关闭、准备断开
}
}
}
finally
{
Monitor.Exit(session);
}
} // end foreach
sessionTable2.Clear();
} // end while
}
复制代码
4 、结语
基于多线程处理的系统代价是比较大的,需要经常调用加/解锁方法lock()或Monitor.Enter(),需要经常创建处理线程等。从实际运
行效果 看,笔者的实现方案有较好的稳定性:2005年4月到5月间,在一个普通PC机器上连续运行30多天不出一点故障。同时,笔者采用了
时序区间判重等算法, 有效地提高了系统处理与响应速度。测试表明,在普通的PC机器(P4 2.0)上,可以做到0.5秒处理一个数据包,如
果优化代码和服务器,还有较大的性能提升空间。
上面的代码是笔者实现的省级公路交通流量数据服务中心(DSC)项目中的接收服务器框架部分,整个系统还包括:数据转发交通部的
转发服务器、数据远程查询客户端、综合报表数据处理系统、数据在线发布系统、系统运行监控系统等。
实际的接收服务器类及其辅助类超过3K行,整个系统则超过了60K。因为是早期实现的程序,难免有代码粗糙、方法欠妥的感觉,只有
留待下个版本完善扩充 了。由于与甲方有保密合同和版权保护等,不可能公开全部源代码,删减也有不当之处,读者发现时请不吝指正。
下面是带详细注释的代码下载URL。
下载框架源码
附注:笔者补充了有关数据包界限、间断、重叠等内容,请参考指正。
标签: Socket
C# 实现的多线程异步Socket数据包接收器框架(补记)
国庆假日的最后一天,用近9个小时写完了C# 实现的多线程异步Socket数据包接收器框架(包括删减代码的时间)。饭后散步回来再
看,好家伙,有300多个Page Views了,超过笔者在codeproject上首日前几个小时的PV速度了。呵呵,如果发表在笔者原博客网上,估计就
是自己反反复复修改记录的数十个PV了!终究是彼网牛人高手太多。
散步时仔细想想该文,发觉有三个Socket通信中关键与著名的问题没有讲到或没有讲清楚:
数据包界限符问题。根据原项目中交通部标准,在连续观测站中数据包中,使用<>两个字符表示有效数据包开始和结束。实际项目有各自的
具体技术规范
数据包不连续问题。在TCP/IP等通信中,由于时延等原因,一个数据包被Socket做两次或多次接收,此时在接收第一个包后,必须保存到
TSession的DatagramBuffer中,在以后一并处理
包并发与重叠问题。由于客户端发送过快或设备故障等原因,一次接收到一个半、两个或多个包文。此时,也需要处理、一个半、两个或多
个包
先补充异步BeginReceive()回调函数EndReceiveData()中的数据包分合函数ResolveBuffer()。
复制代码
/// <summary>
/// 1) 报文界限字符为<>,其它为合法字符,
/// 2) 按报文头、界限标志抽取报文,可能合并包文
/// 3) 如果一次收完数据,此时 DatagramBuffer 为空
/// 4) 否则转存到包文缓冲区 session.DatagramBuffer
/// </summary>
private void ResolveBuffer(TSession session, int receivedSize)
{
// 上次留下的报文缓冲区非空(注意:必然含有开始字符 <,空时不含 <)
bool hasBeginChar = (session.DatagramBufferLength > 0);
int packPos = 0; // ReceiveBuffer 缓冲区中包的开始位置
int packLen = 0; // 已经解析的接收缓冲区大小
byte dataByte = 0; // 缓冲区字节
int subIndex = 0; // 缓冲区下标
while (subIndex < receivedSize)
{
// 接收缓冲区数据,要与报文缓冲区 session.DatagramBuffer 同时考虑
dataByte = session.ReceiveBuffer[subIndex];
if (dataByte == TDatagram.BeginChar) // 是数据包的开始字符<,则前面的包文均要放弃
{
// <前面有非空串(包括报文缓冲区),则前面是错包文,防止 AAA<A,1,A> 两个报文一次读现象
if (packLen > 0)
{
Interlocked.Increment(ref _datagramCount); // 前面有非空字符
Interlocked.Increment(ref _errorDatagramCount); // 一个错误包
this.OnDatagramError();
}
session.ClearDatagramBuffer(); // 清空会话缓冲区,开始一个新包
packPos = subIndex; // 新包起点,即<所在位置
packLen = 1; // 新包的长度(即<)
hasBeginChar = true; // 新包有开始字符
}
else if (dataByte == TDatagram.EndChar) // 数据包的结束字符 >
{
if (hasBeginChar) // 两个缓冲区中有开始字符<
{
++packLen; // 长度包括结束字符>
// >前面的为正确格式的包,则分析该包,并准备加入包队列
AnalyzeOneDatagram(session, packPos, packLen);
packPos = subIndex + 1; // 新包起点。注意:subIndex 在循环最后处 + 1
packLen = 0; // 新包长度
}
else // >前面没有开始字符,则认为结束字符>为一般字符,待后续的错误包处理
{
++packLen; // hasBeginChar = false;
}
}
else // 非界限字符<>,就是是一般字符,长度 + 1,待解析包处理
{
++packLen;
}
++subIndex; // 增加下标号
} // end while
if (packLen > 0) // 剩下的待处理串,分两种情况
{
// 剩下包文,已经包含首字符且不超长,转存到包文缓冲区中,待下次处理
if (hasBeginChar && packLen + session.DatagramBufferLength <= _maxDatagramSize)
{
session.CopyToDatagramBuffer(packPos, packLen);
}
else // 不含首字符,或超长
{
Interlocked.Increment(ref _datagramCount);
Interlocked.Increment(ref _errorDatagramCount);
this.OnDatagramError();
session.ClearDatagramBuffer(); // 丢弃全部数据
}
}
}
复制代码
分析包文AnalyzeOneDatagram()函数代码补充如下:
复制代码
/// <summary>
/// 具有<>格式的数据包加入到队列中
/// </summary>
private void AnalyzeOneDatagram(TSession session, int packPos, int packLen)
{
if (packLen + session.DatagramBufferLength > _maxDatagramSize) // 超过长度限制
{
Interlocked.Increment(ref _datagramCount);
Interlocked.Increment(ref _errorDatagramCount);
this.OnDatagramError();
}
else // 一个首尾字符相符的包,此时需要判断其类型
{
Interlocked.Increment(ref _datagramCount);
TDatagram datagram = new TDatagram();
if (!datagram.CheckDatagramKind()) // 包格式错误(只能是短期BG、或长期SG包)
{
Interlocked.Increment(ref _datagramCount);
Interlocked.Increment(ref _errorDatagramCount);
this.OnDatagramError();
datagram = null; // 丢弃当前包
}
else // 实时包、定期包,先解析数据,判断正误,并发回确认包
{
datagram.ResolveDatagram();
if (true) // 正确的包才入包队列
{
Interlocked.Increment(ref _datagramQueueCount);
lock (_datagramQueue)
{
_datagramQueue.Enqueue(datagram); // 数据包入队列
}
}
else
{
Interlocked.Increment(ref _errorDatagramCount);
this.OnDatagramError();
}
}
}
session.ClearDatagramBuffer(); // 清包文缓冲区
}
复制代码
TSession的拷贝转存数据包文的方法CopyToDatagramBuffer()代码如下:
复制代码
/// <summary>
/// 拷贝接收缓冲区的数据到数据缓冲区(即多次读一个包文)
/// </summary>
public void CopyToDatagramBuffer(int startPos, int packLen)
{
int datagramLen = 0;
if (DatagramBuffer != null) datagramLen = DatagramBuffer.Length;
// 调整长度(DataBuffer 为 null 不会出错)
Array.Resize(ref DatagramBuffer, datagramLen + packLen);
// 拷贝到数据就缓冲区
Array.Copy(ReceiveBuffer, startPos, DatagramBuffer, datagramLen, packLen);
}
复制代码
代码中注释比较详细了,下面指出其思路:
使用TSession会话对象的字节数组ReceiveBuffer保存BeginReceiver()接收到的数据,使用 字节数组DatagramBuffer保存一次接收后分解
或合并的剩下的包文。本项目中,由于是5分钟一个包,正常情况下不需要用到 DatagramBuffer数组
处理ReceiveBuffer中的字节数据包时,先考虑DatagramBuffer是否有开始字符<。如果有,则当前包文是前个包文的补充,否则前个包文是
错误的。正确的包文可能存在于两个缓冲区中,见分析函数AnalyzeOneDatagram()
分析完接收数据包后,剩下的转存到DatagramBuffer中,见函数CopyToDatagramBuffer()
设计时考虑的另一个重要问题就是处理速度。如果自动观测站达到100个,此时5*60=300秒钟就有100个包,即每3秒种一个包,不存在
处理速度慢问 题。但是,真正耗时的是判断包是否重复!特别地,当设备故障时存在混乱上传数据包现象,此时将存在大量的重复包。笔
者采用了所谓的区间判重算法,较好地解 决了判重速度问题,使得系统具有很好的可伸缩性(分析算法的论文被EI核心版收录,呵呵,意
外收获)。事实上,前年的交通部接收服务器还不具备该项功能, 可能是太费时间了。
还有,就是在.NET Framework的托管CLR下,系统本身的响应速度如何?当时的确没有把握,认为只要稳定性和速度满足要求就行了。
三年半运行情况表明,系统有良好的处理速度、很好的稳定性、满足了部省要求。
========
.NET(C#):使用UPnP来穿透NAT使内网接口对外网可见
在写完Object 672后,软件的一个致命问题暴露出来,如果服务器和客户端都在内网环境下,即双方都通过NAT来接触外网,那么此时客户端是无法直接和服务器交流的。
解决方案可以是:
1:把服务器部署在不存在NAT的公网环境下。
2:使用常见的NAT穿透方法比如UDP打洞,或者STUN协议,但是这些方法都需要另一个已知的部署在公网环境下的服务器。
3:就是这篇文章主要讨论的方案,即不需要部署任何公网环境下的服务器,通过路由器支持的UPnP协议来把内网的接口绑定到公网接口上
。
UPnP的一大优势就是不会像UDP打洞那样,内网接口不需要先向外部接口发送UDP包来把绑定的公网接口告诉NAT,而且对于对称NAT,UDP打
洞是无效的。而UPnP一旦设置成功后,内网接口完全以绑定的公网接口暴露在公网中。
演示程序的运行是这样的:
image
具体过程:
1. 输出用户Host Name和内网IP地址。
2. 通过UPnP把内网IP地址,内部端口号绑定到一个外部端口号上。
3. 通过HTTP从外部网站获取公网IP地址。
4. 在内网中创建TCP Socket服务器。
5. 建立另一个TCP Socket客户端,然后尝试连接上面获取的公网IP和UPnP绑定的外部端口。
6. 如果一切没有问题的话,此时会成功连接到服务器,并收到回应!
在.NET环境下使用Windows的UPnP组件需要现在工程中引用:NATUPnP 1.0 Type Library,这是一个COM类库。
下面开始逐句分析源代码,源代码均拟用户已加入下列命名空间:
using System.Net;
using System.Net.Sockets;
using System.Text.RegularExpressions; //提取IP时的正则
using System.Threading.Tasks; //Task
using System.IO; //读取服务器信息用到StreamReader
using NATUPNPLib; //Windows UPnP COM组件
首先输出本机(也就是内网接口信息),这个很简单了:
//获取Host Name
var name =Dns.GetHostName();
Console.WriteLine("用户:"+ name);
//从当前Host Name解析IP地址,筛选IPv4地址是本机的内网IP地址。
var ipv4 =Dns.GetHostEntry(name).AddressList.Where(i => i.AddressFamily ==AddressFamily.InterNetwork).FirstOrDefault();
Console.WriteLine("内网IP:"+ ipv4);
接 下来就是设置UPnP了,首先需要初始化UPnPNAT类型(他是一个接口,只不过通过CoClass特性把执行导向UPnPNATClass类型),接 着通
过UPnPNAT的StaticPortMappingCollection来添加或者删除UPnP绑定。注意在没有路由器或者路由器的UPnP不开 启的情况下,
StaticPortMappingCollection属性可能会返回null。
代码如下:
Console.WriteLine("设置UPnP");
//UPnP绑定信息
var eport =8733;
var iport =8733;
var description ="Mgen测试";
//创建COM类型
var upnpnat =newUPnPNAT();
var mappings = upnpnat.StaticPortMappingCollection;
//错误判断
if (mappings ==null)
{
Console.WriteLine("没有检测到路由器,或者路由器不支持UPnP功能。");
return;
}
//添加之前的ipv4变量(内网IP),内部端口,和外部端口
mappings.Add(eport, "TCP", iport, ipv4.ToString(), true, description);
Console.WriteLine("外部端口:{0}", eport);
Console.WriteLine("内部端口:{0}", iport);
如果成功后,你应该可以在路由器的UPnP选项中看到这些数据:
image
设置好UPnP后,开始获取外网IP地址,可以通过这个网址(http://checkip.dyndns.org/)。此时只需要发送一个HTTP GET请求,然后把返
回的HTML中的IP地址提取出来就可以了,我们用正则来提取IP地址。
代码如下:
//外网IP变量
string eip;
//正则
var regex =@"\b\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\b";
using (var webclient =newWebClient())
{
var rawRes = webclient.DownloadString("http://checkip.dyndns.org/");
eip =Regex.Match(rawRes, regex).Value;
}
Console.WriteLine("外网IP:"+ eip);
OK,这个时候(如果一切顺利的话),一切准备工作都做好了。我们有了:内网IP,内部端口,外网IP,外部端口。那么就可以做一个TCP
连接做测试了。
直接建立一个TCP服务端,代表在NAT下的服务器,注意端口号要绑定到UPnP设置时的内部端口。
代码:
//在NAT下的服务器
var socket =newSocket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
//绑定内网IP和内部端口
socket.Bind(newIPEndPoint(ipv4, iport));
socket.Listen(1);
//在另一个线程中运行客户端Socket
Task.Run(() =>
{
Task.Delay(1000);
ClientSocket(eip, eport);
});
//成功连接
var client = socket.Accept();
//服务器向客户端发送信息
client.Send(Encoding.Unicode.GetBytes("=== 欢迎来到Mgen的服务器!==="+Environment.NewLine));
Console.ReadKey(false);
上 面的ClientSocket方法就是客户端的Socket连接执行,注意TCP协议是不保留数据边界的,因此服务器在发送消息时,后面加了个换行符
(Environment.NewLine),然后在客户端接受数据时,使用Socket –> NetworkStream –> StreamReader的嵌套组合,最后由
StreamReader的ReadLine读取数据,这样确保会读到最后的换行符。
ClientSocket方法的执行代码:
//ip参数和port参数是公网的IP地址,和UPnP中的外部端口
staticvoid ClientSocket(string ip, int port)
{
try
{
Console.WriteLine("建立客户端TCP连接");
var socket =newSocket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
socket.Connect(newIPEndPoint(IPAddress.Parse(ip), port));
using (var ns =newNetworkStream(socket))
using (var sr =newStreamReader(ns, Encoding.Unicode))
{
Console.WriteLine("收到来自服务器的回应:");
Console.WriteLine(sr.ReadLine());
}
}
catch (Exception ex)
{
Console.WriteLine(ex.Message);
}
}
OK。
源代码下载 下载页面 注意:链接是微软SkyDrive页面,下载时请用浏览器直接下载,用某些下载工具可能无法下载 源代
码环境:Microsoft Visual Studio Express 2012 for Windows Desktop 注意:源代码不包含引用的外部类库文件
========