C# TCP应用编程二 同步TCP应用编程
不论是多么复杂的TCP 应用程序,双方通信的最基本前提就是客户端要先和服务器端进行TCP 连接,然后才可以在此基础上相互收发数据。由于服务器需要对多个客户端同时服务,因此程序相对复杂一些。在服务器端,程序员需要编写程序不断的监听客户端是否有连接请求,并通过套接字区分是哪个客户;而客户端与服务器连接则比较简单,只需要指定连接的是哪个服务器即可。一旦双方建立了连接并创建了对应的套接字,就可以相互收发数据了。在程序中,发送和接收数据的方法都是一样的,区别仅是方向不同。
在同步TCP 应用编程中,发送、接收和监听语句均采用阻塞方式工作。使用同步TCP编写服务器端程序的一般步骤为:
1) 创建一个包含采用的网络类型、数据传输类型和协议类型的本地套接字对象,并将其与服务器的IP 地址和端口号绑定。这个过程可以通过Socket 类或者TcpListener 类完成。
2) 在指定的端口进行监听,以便接受客户端连接请求。
3) 一旦接受了客户端的连接请求,就根据客户端发送的连接信息创建与该客户端对应的Socket 对象或者TcpClient 对象。
4) 根据创建的Socket 对象或者TcpClient 对象,分别与每个连接的客户进行数据传输。
5) 根据传送信息情况确定是否关闭与对方的连接。
使用同步TCP 编写客户端程序的一般步骤为:
1) 创建一个包含传输过程中采用的网络类型、数据传输类型和协议类型的Socket 对象或者TcpClient 对象。
2) 使用Connect 方法与远程服务器建立连接。
3) 与服务器进行数据传输。
4) 完成工作后,向服务器发送关闭信息,并关闭与服务器的连接。
为了让读者大概了解套接字编程和封装后的TcpClient 及TcpListener 的区别,下面我们分别对三者在程序中实现的关键代码做一个简单介绍。
1 使用套接字发送和接收数据
在网络中,数据是以字节流的形式进行传输的。服务器与客户端双方建立连接后,程序中需要先将要发送的数据转换为字节数组,然后使用Socket 对象的Send 方法发送数据,或者使用Receive 方法接收数据。注意,要发送的字节数组并不是直接发送到了远程主机,而是发送到了本机的TCP 发送缓冲区中;同样道理,接收数据也是如此,即程序中是从TCP 接收缓冲区接收数据。可以使用Socket 类的SendBufferSize 属性获取或者设置发送缓冲区的大小,使用ReceiveBufferSize 属性获取或者设置接收缓冲区的大小,也可以使用其默认大小。至于系统什么时候将缓冲区数据通过网络发送到远程主机,受到哪些因素的影响,就不需要在程序中考虑了。
1.1 服务器端编程关键代码
在服务器端程序中,使用套接字收发数据的关键代码为:
using System.Net; using System.Net.Sockets; …… IPAdress ip = IPAdress.Prase("服务器IP 地址"); IPEndPoint iep = new IPEndPoint(ip, 可用端口号); Socket socket = new Socket(AdressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); socket.Bind(iep); socket.Listen(最大客户端连接数); Socket clientSocket = socket.Accept(); …… //通过clientSocket 向该客户发送数据 string message = "发送的数据"; byte[] sendbytes = System.Text.Encoding.UTF8.GetBytes(message); int successSendBytes = clientSocket.Send(sendbytes, sendbytes.Length, SocketFlags.None); …… //通过clientSocket 接收该客户发来的数据 byte[] receivebytes = new byte[1024]; int successReceiveBytes = clientSocket.Receive(receivebytes);
由于TCP 协议是面向连接的,因此在发送数据前,程序首先应该将套接字与本机IP 地址和端口号绑定,并使之处于监听状态,然后通过Accept 方法监听是否有客户端连接请求。使用套接字是为了指明使用哪种协议;和本机绑定是为了在指定的端口进行监听,以便识别客户端连接信息;调用Accept 方法的目的是为了得到对方的IP 地址、端口号等套接字需要的信息。因为只有得到对方的IP 地址和端口号等相关信息后,才能与对方进行通信。当程序执行到Accept 方法时,会处于阻塞状态,直到接收到客户端到服务器端的连接请求才继续执行下一条语句。服务器一旦接受了该客户端的连接,Accept 方法就返回一个与该客户端通信的新的套接字,套接字中包含了对方的IP 地址和端口号,然后就可以用返回的套接字和该客户进行通信了。
Send 方法的整型返回值表示成功发送的字节数。正如本节开始所说的那样,该方法并不是把要发送的数据立即传送到网络上,而是传送到了TCP 发送缓冲区中。但是,在阻塞方式下,如果由于网络原因导致原来TCP 发送缓冲区中的数据还没有来得及发送到网络上,接收方就无法继续接收发送给它的所有字节数,因此该方法返回实际上成功向TCP 发送缓冲区发送了多少字节。
即使不是因为网络原因,也不能保证数据一定能一次性全部传送到了TCP 发送缓冲区。这是因为TCP 发送缓冲区一次能接收的数据取决于其自身的大小,也就是说,Send 方法要发送的数据如果超过了TCP 发送缓冲区的有效值,那么调用一次Send 方法就不能将数据全部成功发送到缓存中。所以,实际编写程序时,程序中应该通过一个循环进行发送,并检测成功发送的字节数,直到数据全部成功发送完毕为止。当然,如果Send 方法中发送的数据小于TCP发送缓冲区的有效值,调用一次Send 方法就可能全部发送成功。
与发送相反,Receive 方法则是从TCP 接收缓冲区接收数据,Receive 方法的整型返回值表示实际接收到的字节数,但是如果远程客户端关闭了套接字连接,而且此时有效数据已经被完全接收,那么Receive 方法的返回值将会是0 字节。
但有一点需要注意,如果TCP 接收缓冲区内没有有效的数据可读时,在阻塞模式下,Receive 方法将会被阻塞;但是在非阻塞模式下,Receive 方法将会立即结束并抛出套接字异常。要避免这种情况,我们可以使用Available 属性来预先检测数据是否有效,如果Available 属性值不为0,那么就可以重新尝试接收操作。
1.2 客户端编程关键代码
对于客户端程序,只需要通过服务器的IP 地址与端口号与服务器建立连接,一旦连接成功,就可以通过套接字与服务器相互传输数据,关键代码为:
IPAddress ip=IPAddress.Parse("服务器IP 地址"); IPEndPoint iep=new IPEndPoint(ip,服务器监听端口号); Socket serverSocket=new Socket(AddressFamily.InterNetwork, SocketType.Stream,ProtocolType.Tcp); serverSocket.Connect(iep); …… //通过serverSocket 向服务器发送数据 string message = "发送的数据"; byte[] sendbytes = System.Text.Encoding.UTF8.GetBytes(message); int successSendBytes = serverSocket.Send(sendbytes , sendbytes.Length , SocketFlags.None ); …… //通过serverSocket 接收服务器发来的数据 byte[] receivebytes = new byte [1024]; int successReceiveBytes = serverSocket.Receive(receivebytes);
可见,对客户端程序来说,收发数据的方法和服务器端使用的收发数据的方法相似,区别只是创建的套接字不同。
2 使用NetworkStream对象发送和接收数据
NetworkStream 对象专门用于对网络流数据进行处理。创建了NetworkStream 对象后,就可以直接使用该对象接收和发送数据。例如:
NetworkStream networkStream = new NetworkStream(clientSocket); …… //发送数据 string message = "发送的数据"; byte[] sendbytes = System.Text.Encoding.UTF8.GetBytes(message); networkStream.Write(sendbytes,0, sendbytes.Length ); …… //接收数据 byte[] readbytes = new byte[1024]; int i = networkStream.Read(readbytes, 0, readbytes.Length);
与套接字的Send 方法不同,NetworkStream 对象的Write 方法的返回值为void,之所以不返回实际发送的字节数,是因为Write 方法能保证字节数组中的数据全部发送到TCP 发送缓冲区中,避免了使用Socket 类的Send 方法发送数据时所遇到的调用一次不一定能全部发送成功的问题,从而在一定程度上简化了编程工作量。但是所有的这一切操作必须在NetworkStream对象的Writeable 属性值有效时才行,因此在使用NetworkStream 对象的Write 方法前应该检测NetworkStream 对象的Writeable 属性是否为True。
如果发送的全部是单行文本信息,创建NetworkStream 对象后,使用StreamReader 类和StreamWriter 类的ReadLine 和WriteLine 方法更简单,而且也不需要编程进行字符串和数组之间的转换。
与Write 方法相对应,调用NetworkStream 类的Read 方法前应确保NetworkStream 对象的CanRead 属性值有效。在此前提下,该方法一次将所有的有效数据读入到接收缓冲变量中,并返回成功读取的字节数。
注意,Read 方法之所以也有一个整型的返回值,是因为有可能TCP 接收缓冲区还没有接收到对方发送过来的指定长度的数据。也就是说,接收到的数据可能没有指定的那么多。在后面的内容中,我们将进一步学习解决这个问题的方法。
在Read 方法中,有一点与Socket 类的Receive 方法类似,即如果远程主机关闭了套接字连接,并且此时有效数据已经被完全接收,那么Read 方法的返回值将会是0字节。
3 TcpClient与TcpListener类
在System.Net.Sockets 命名空间下,TcpClient 类与TcpListener 类是两个专门用于TCP 协议编程的类。这两个类封装了底层的套接字,并分别提供了对Socket 进行封装后的同步和异步操作的方法,降低了TCP 应用编程的难度。
TcpClient 类用于连接、发送和接收数据,TcpListener 类则用于监听是否有传入的连接请求。
3.1 TcpClient类
TcpClient 类归类在System.Net 命名空间下。利用TcpClient 类提供的方法,可以通过网络进行连接、发送和接收网络数据流。该类的构造函数有四种重载形式:
1) TcpClient()
该构造函数创建一个默认的TcpClient 对象,该对象自动选择客户端尚未使用的IP 地址和端口号。创建该对象后,即可用Connect 方法与服务器端进行连接。例如:
TcpClient tcpClient=new TcpClient(); tcpClient.Connect("www.abcd.com", 51888);
2) TcpClient(AddressFamily family)
该构造函数创建的TcpClient 对象也能自动选择客户端尚未使用的IP 地址和端口号,但是使用AddressFamily 枚举指定了使用哪种网络协议。创建该对象后,即可用Connect 方法与服务器端进行连接。例如:
TcpClient tcpClient = new TcpClient(AddressFamily.InterNetwork); tcpClient.Connect("www.abcd.com", 51888);
3) TcpClient(IPEndPoint iep)
iep 是IPEndPoint 类型的对象,iep 指定了客户端的IP 地址与端口号。当客户端的主机有一个以上的IP 地址时,可使用此构造函数选择要使用的客户端主机IP 地址。例如:
IPAddress[] address = Dns.GetHostAddresses(Dns.GetHostName()); IPEndPoint iep = new IPEndPoint(address[0], 51888); TcpClient tcpClient = new TcpClient(iep); tcpClient.Connect("www.abcd.com", 51888);
4) TcpClient(string hostname,int port)
这是使用最方便的一种构造函数。该构造函数可直接指定服务器端域名和端口号,而且不需使用connect 方法。客户端主机的IP 地址和端口号则自动选择。例如:
TcpClient tcpClient=new TcpClient("www.abcd.com", 51888);
表1 和表2 分别列出了TcpClient 类的常用属性和方法。
表1 TcpClient类的常用属性
属性 | 含义 |
Client | 获取或设置基础套接字 |
LingerState | 获取或设置套接字保持连接的时间 |
NoDelay | 获取或设置一个值,该值在发送或接收缓冲区未满时禁用延迟 |
ReceiveBufferSize | 获取或设置Tcp接收缓冲区的大小 |
ReceiveTimeout | 获取或设置套接字接收数据的超时时间 |
SendBufferSize | 获取或设置Tcp发送缓冲区的大小 |
SendTimeout | 获取或设置套接字发送数据的超时时间 |
表2 TcpClient类的常用方法
方法 | 含义 |
Close | 释放TcpClient实例,而不关闭基础连接 |
Connect | 用指定的主机名和端口号将客户端连接到TCP主机 |
BeginConnect | 开始一个对远程主机连接的异步请求 |
EndConnect | 异步接受传入的连接尝试 |
GetStream | 获取能够发送和接收数据的NetworkStream对象 |
3.2 TcpListener类
TcpListener 类用于监听和接收传入的连接请求。该类的构造函数有:
1) TcpListener(IPEndPoint iep)
其中iep 是IPEndPoint 类型的对象,iep 包含了服务器端的IP 地址与端口号。该构造函数通过IPEndPoint 类型的对象在指定的IP 地址与端口监听客户端连接请求。
2) TcpListener(IPAddress localAddr, int port)
建立一个TcpListener 对象,在参数中直接指定本机IP 地址和端口,并通过指定的本机IP地址和端口号监听传入的连接请求。
构造了TcpListener 对象后,就可以监听客户端的连接请求了。与TcpClient 相似,TcpListener也分别提供了同步和异步方法,在同步工作方式下,对应有AcceptTcpClient 方法、AcceptSocket方法、Start 方法和Stop 方法。
AcceptSocket 方法用于在同步阻塞方式下获取并返回一个用来接收和发送数据的套接字对象。该套接字包含了本地和远程主机的IP 地址与端口号,然后通过调用Socket 对象的Send和Receive 方法和远程主机进行通信。
AcceptTcpClient 方法用于在同步阻塞方式下获取并返回一个可以用来接收和发送数据的封装了Socket 的TcpClient 对象。
Start 方法用于启动监听,构造函数为:
public void Start(int backlog)
整型参数backlog 为请求队列的最大长度,即最多允许的客户端连接个数。Start 方法被调用后,把自己的LocalEndPoint 和底层Socket 对象绑定起来,并自动调用Socket 对象的Listen方法开始监听来自客户端的请求。如果接受了一个客户端请求,Start 方法会自动把该请求插入请求队列,然后继续监听下一个请求,直到调用Stop 方法停止监听。当TcpListener 接受的请求超过请求队列的最大长度或小于0 时,等待接受连接请求的远程主机将会抛出异常。
Stop 方法用于停止监听请求,构造函数为:
public void Stop()
程序执行Stop 方法后,会立即停止监听客户端连接请求,并关闭底层的Socket 对象。等待队列中的请求将会丢失,等待接受连接请求的远程主机会抛出套接字异常。
4 解决TCP协议的无消息边界问题
虽然采用TCP 协议通信时,接收方能够按照发送方发送的顺序接收数据,但是在网络传输中,可能会出现发送方一次发送的消息与接收方一次接收的消息不一致的现象。例如:发送方第一次发送的字符串数据为“12345”,第二次发送的字符串数据为“abcde”;正常情况下,接收方接收的字符串应该是第一次接收:“12345”,第二次接收:“abcde”。
但是,当收发信息速度非常快时,接收方也可能一次接收到的内容就是“12345abcde”,即两次或者多次发送的内容一起接收。
还有一种最极端的情况,就是接收方可能会经过多次才能接收到发送方发送的消息。例如第一次接收到“1234”,第二次为“45ab”,第三次为“cde”。
这主要是因为TCP 协议是字节流形式的、无消息边界的协议,由于受网络传输中的不确定因素的影响,因此不能保证单个Send 方法发送的数据被单个Receive 方法读取。
如果需要解析发送方发送的命令,为了保证接收方不出现解析错误,编程时必须要考虑消息边界问题,否则就可能会出现丢失命令等错误结果。例如对于两次发送的消息一次全部接收的情况,虽然接收的内容并没有少,但是如果在程序中认为每次接收的都是一条命令,就会丢失另一条命令,从而引起逻辑上的错误。就像网络象棋程序,丢失了一步,整个逻辑关系就全乱套了。
实际应用中,解决TCP 协议消息边界问题的方法有三种:
1)第一种方法是发送固定长度的消息。该方法适用于消息长度固定的场合。
具体实现时,可以通过System.IO 命名空间下的BinaryReader 对象每次向网络流发送一个固定长度的数据,例如每次发送一个int 类型的32 位整数。BinaryReader 和BinaryWriter 对象提供了多种重载方法,发送和接收具有固定长度类型的数据非常方便。
2)第二种方法是将消息长度与消息一起发送。例如本机在每次发送的消息前面用4 个字节表明本次消息的长度,然后将包含消息长度的消息发送到远程主机;远程主机接收到消息后,首先从消息的头4 个字节获取消息长度,然后根据消息长度值依次循环接收发送方发送的消息数据。这种方法可以适用于任何场合。
BinaryReader 对象和BinaryWriter 对象同样是实现这种方法的最方便的途径。BinaryWriter 对象提供了很多重载的Write 方法,可以适用于任何场合。例如向网络流写入字符串时,该方法会自动计算出字符串占用的字节数,并使用4 个字节作为字符串前缀将其附加到字符串的前面;接收方使用ReadString 方法接收字符串时,它会首先读取字符串前缀,然后自动根据字符串前缀指定的长度读取字符串。
如果是二进制的流数据,比如使用TCP 协议通过网络传递二进制文件,需要在程序中通过代码实现计算长度的功能。本书网络数据加密与解密一章中,通过网络传递加密数据的例子中解决边界问题使用的就是这种方法。
3)第三种方法是使用特殊标记分隔消息。例如用回车换行(\r\n)作为分隔符。这种方法主要用于消息中不包含特殊标记的场合。
对于字符串处理,实现这种方法最方便的途径是通过StreamWriter 对象和StreamReader对象。发送方每次使用StreamWriter 对象的WriteLine 方法将发送的字符串写入网络流中,接收方每次只需要用StreamReader 对象的ReadLine 方法将用回车换行作为标记的字符串从网络流中读出即可。本章的例子均使用这种方法。
三种处理消息边界的方法各有优缺点,也不能说哪种方式好哪种方式不好。编程时应该根据实际情况选择一种合适的方法。