windows 网络编程学习-面向连接的编程方式
在上一篇中我们介绍了Winsock的初始化方法,本文将介绍使用Winsock进行面向连接的一般编程方式。文中会给出C++和对应的C#代码。
Error控制
网络编程中对错误进行控制是非常重要的,因为你不知道什么时候会出现网络错误(如物理掉线、拥塞等,事实上它们经常出现,不用担心多数情况下的异常可以忽略不计,仍然可以在该套接字上进行通讯)。如果调用一个Winsock函数发生了错误,可以通过WSAGetLastError函数来获得已预定义的错误常量值。
int WSAGetLastError(void);
函数返回的是SOCKET_ERROR,在C#里有对应SocketError枚举值。
SocketError
面向连接的协议
面向连接的协议编程可分为服务器端和客户端。服务器端的编程步骤一般为:1、初始化套接字;2、绑定监听端口;3、监听;4、接收客户连接Accept;5、数据通讯。客户端的步骤为:1、创建套接字;2、建立连接connect;3、数据通讯;
服务器端
服务器端其实就是运行在服务器上的一个进程,它将等待客户机的连接。只有绑定到一个已知的名字上服务器端进程才能监听客户端发来的连接请求,在TCP/IP中这个名字就是本地的IP地址+端口号。
地址
在TCP/IP协议中计算机都分配了一个32位的IPv4地址(IPv6暂不讨论),除了IP地址外,程序间通讯还需要指定端口号。在Winsock中,应用通过SOCKADDR_IN结构来指定IP地址和端口,其格式如下:
1 struct sockaddr_in
2 {
3 short sin_family;
4 u_short sin_port;
5 struct in_addr sin_addr;
6 char sin_zero[8];
7 };
其中sin_family表示所使用的协议族,这里设为AF_INET,也就是IP地址家族。sin_port是指所使用的端口,一般选择1024~65535之间。如果使用bind函数绑定了一个已被占用的端口,系统返回WSAEADDRINUSE错误。sin_addr字段把IP地址保存为一个4字节的数,它是无符号长整数类型,这个地址可以是一个本地IP也可以是一个远程IP。最后一个参数sin_zero,用于填充,使 SOCKADDR_IN 结构和 SOCKADDR长度一样。在C#对应的表示方法可以使用IPEndPort类。IP地址一般是用“互联网标准点分表示法”来表示的(如:192.168.1.1),可以使用inet_addr函数将一个点式IP地址转换为32位的无符号长整数。
1 unsigned long inet_addr(const char FAR *CP);
在C#中可以使用IPAddress.Parse(string)方法获得同样的效果。另外还有一些特殊地址表示方法:
- INADDR_ANY:允许应用监听主机计算机上面的每个网络接口上的活动。
- INADDR_BROADCAST:在IP网络中发生UDP广播数据报。
这两者在C#中对应 IPAddress.Any和IPAddress.Broadcast。
由于计算机处理多字节时和互联网联网标准指定的多字节值有可能不同,所以需要将它们进行转换。
从主机字节顺序转换为网络字节顺序,常用的两个API函数有:
1 u_long htonl(u_long hostlong);
2
3 u_short htons(u_short hostoshort);
htonl 将地址转换为网络字节顺序,htons将端口转换为网络自己顺序。
把网络字节转换为主机字节顺序函数:
u_long ntohl(u_long netlong);
u_short ntohs(u_short netshort);
bind
bind可以将指定的套接字和一个已知的地址(本地接口的IP地址+端口号)绑定到一起。
1 int bind(SOCKET s,const struct sockaddr FAR* name,int namelen);
- 第一个参数S为需要绑定的套接字;
- 第二个参数是一个缓存区,在实际使用时将填充对应的地址缓冲区;
- 第三个参数代表由协议决定的地址的长度;
一旦出错Bind就会返回一个SOCKET_ERROR,通常情况为绑定了一个已被占用的端口。
SOCKET s;
struct sockaddr_in tcpaddr;
int port=5550;
s=socket(AF_INET,SOCK_STREAM,IPPRPTP_TCP);
tcpaddr.sin_family=AF_INET;
tcpaddr.sin_port=htons(port);
tcpaddr.sin_addr.s_addr=htonl(INADDR_ANY);
bind(s,(SOCKADDR*)&tcpaddr,sizeof(tcpaddr));
示例创建了一个TCP协议的流套接字,随后将它绑定在了5550的默认IP接口上。让我们看看在C#下相同的示例如何编写
示例
两种不同语言的代码编写关键都是:先创建一个套接字和一个地址结构(或类实例),然后在调用bind函数进行绑定。
使用Reflector查看C#中的Socket.bind方法的实现:
Socket.Bind
在里面进行一系列的异常判断后,会调用一个私有方法DoBind
Socket.Bind
在DoBind方法里调用了OSSOCK.bind。
[DllImport("ws2_32.dll", SetLastError=true)]
internal static extern SocketError bind(
[In] SafeCloseSocket socketHandle,
[In] byte[] socketAddress,
[In] int socketAddressSize);
通过DLLImport的方式,C#调用了ws2_32动态连接库,ws2_32.dll是Windows Sockets应用程序接口。
Listen
bind函数只是将一个套接字和一个指定的地址关在一起,要想让套接字进入等候连接的状态需要调用API的listen函数。
int listen(SOCKET s,int backlog);
其中第二个参数backlog指定了正在等待连接的最大队列长度,因为网络中可能出现几个客户端的连接请求同时到达的情况。根据操作系统的不同,backlog的最大值也将不同。
listen最常见的就是WSAEINVAL错误,该错误表明在调用listen时忘记调用bind函数。
C#中的listen方法是在一个Socket实例里调用的,查看一下其方法的实现
Socket.Listen
同样是在OSSOCK中调用了API接口的listen函数。
[DllImport("ws2_32.dll", SetLastError=true)]
internal static extern SocketError listen(
[In] SafeCloseSocket socketHandle,
[In] int backlog);
accept和WSAAccept
accept和WSAAccept用于接受客户端的连接。accept函数的格式如下:
SOCKET accept(
SOCKET s,
struct sockaddr FAR* addr,
int FAR* addrlen);
- 第一个参数是出于监听模式的套接字;
- 第二个参数是一个有效的SOCKADDR_IN结构的地址;
- 第三个是SOCKADDR结构的长度。
accept函数返回后,addr结构中会包含客户端的地址信息。此外,accept会返回一个新的套接字描述符,它对应已经接收的客户机的连接,对于该客户端的后续操作,都应使用这个新的套接字,
而原来的套接字将继续负责接收其他客户端的连接请求,而且它仍处于监听模式。
在Winsock中还引入了一个名为WSAAccept的函数,它在接受连接前调用一个回调函数.并根据该函数的返回值来决定是否接受客户端的连接。
SOCKET WSAAccept(
SOCKET s,
struct sockaddr FAR * addr,
LPINT addrlen,
LPCONDITIONPROC lpfnCondition,
DWORD dwCallbackData);
前三个参数与accept函数相同,第二个参数lpfnCondition参数是一个指向函数的指针,这个函数便是上面所说的回调函数,该函数决定是否接受客户的连接请求。dwCallbackData返回给应用程序的回调数据。我们来看看回调函数的定义:
int CALLBACK ConditionFunc(
LPWSABUF lpCallerId,
LPWSABUF lpCallerData,
LPQOS lpSQOS,
LPQOS lpGQOS,
LPWSABUF lpCalleeId,
LPWSABUF lpCalleeData,
GROUP FAR *g,
DWORD dwCallbackDAta);
- lpCallerId: 指定建立连接的协议 包含建立连接的那个客户机的IP地址。
- lpCallerData:包含了随连接一道由客户机发出的任何连接数据。
- lpSQOS,lpGQOS:对客户机请求的任何一个服务质量参数进行指定。
- lpCalleeId:结构中包含与客户机需要与之连接的本地地址。
将传递给回调函数的参数处理完后,必须指定是接受、拒绝或延后客户端的连接请求。如果接受应返回CF_ACCEPT;如果拒绝函数应返回CF_REJECT;延迟则返回CF_DEFER。大多数情况下最基层的网络堆栈在回调函数被调用的那一刻,就已经接受了连接,如果返回的是CF_REJECT值,基层堆栈会将连接简单的关闭掉。
下面我们来看看C#下的Accept。根据使用的编程模式的不同C#下有Accept():Socket、AcceptAsync(SocketAsyncEventArgs):Boolean和BeginAccept(AsyncCallback):IAsyncResult三种不同的方法,其中后面两个方法是C#中的是异步模式,AcceptAsync是增强型异步模式,.net 3.5下它是基于完成端口实现的。
Accept():Socket在C#内的实现:
Accept
方法里对各种异常进行判断后,调用SafeCloseSocket.Accept方法。
[DllImport("ws2_32.dll", SetLastError=true,
ExactSpelling=true)]
internal static extern
SafeCloseSocket.InnerSafeCloseSocket accept(
[In] IntPtr socketHandle,
[Out] byte[] socketAddress,
[In, Out] ref int socketAddressSize);再看看BeginAccept的实现:
BeginAccept
最终调用的是(AcceptAsyn的最终调用的函数也是它):
BeginAccept
客户端
相对于服务器端,客户端的编程要简单许多。使用SOCKET或WSASocket创建套接字后,在通过connect或WSAConnect初始化一个连接接即可。当然在这之前你必须知道服务器的地址。
connect 函数和WSAConnect函数
客户端通过connect函数或WSAConnect函数来和服务器端建立连接。
int connect(
SOCKET s,
const struct sockaddr FAR* name,
int namelen
);
其中S是要建立连接的套接字,name是地址结构;namelen则是名字参数的长度。
int WSAConnect(
SOCKET s,
const struct sockaddr FAR* name,
int namelen,
LPWSABUF lpcallerData,
LPWSABUF lpCalleeData,
LPQOS lpSQQS,
LPQOS lpGQOS
);
WSAConnect 是 Winsock 2 版本中的连接函数。前3个函数和connect一样。
- lpCallerData:指向用户数据的指针,该数据在建立连接时将传送到远端。
- lpCalleeData:指向用户数据的指针,该数据在建立连接时将从远端传送回本机。
需要注意的是:对于大多数网络协议来说(如TCP)它们并不提供在建立连接时,传递连接数据的支持,所以IpCallerData其实在TCP/IP中的意义不大。
我们来看C#中的Sockect类里的Connect方法。
Socket.Connect
在DoConnect方法中可以看到如下代码:
UnsafeNclNativeMethods.OSSOCK.WSAConnect(
this.m_Handle.DangerousGetHandle(),
socketAddress.m_Buffer,
socketAddress.m_Size,
IntPtr.Zero, IntPtr.Zero, IntPtr.Zero, IntPtr.Zero
方法的定义为:
[DllImport("ws2_32.dll", SetLastError=true)]
internal static extern SocketError WSAConnect(
[In] IntPtr socketHandle,
[In] byte[] socketAddress,
[In] int socketAddressSize,
[In] IntPtr inBuffer,
[In] IntPtr outBuffer,
[In] IntPtr sQOS,
[In] IntPtr gQOS);
可以看到后面的四个参数都传递了 IntPtr.Zero。
数据传输
建立连接后便可以进行数据的收发了。发送消息可以使用send和WSASend这两个API函数。第二个函数是Winsock2中专有的。接收消息可以使用recv和WSARecv这两个函数。
send和WSASend函数
使用send函数在已建立连接上的套接字进行数据发送。其原型为:
int send(
SOCKET s,
const char FAR * buf,
int len,
int flags
);
其中SOCKET是已建立连接的套接字,将在这个套接字上发生数据。第二个参数是要发送的字符的缓冲区。第三个参数是缓冲区的字符数即长度。Flags可以为以下几个值:0、MSG_DONTROUTE或MSG_OOP, MSG_DONTROUTE表示不要将其发出的包路由出去。MSG_OOB表示数据应该被带外发送。
WSASend的定义如下:
代码int WSASend(
SOCKET s,
LPWSABUF lpBuffers,
DWORD dwBufferCount,
LPDWORD lpNumberOfBytesSent,
DWORD dwFlags,
LPWSAOVERLAPPED lpOverlapped,
LPWSAOVERLAPPED_COMPLETION_ROUTINE lpCompletionROUTINE
);
第二个参数纸箱一个或多个WSABUF结构(其是它本身就是一个带长度的字符缓冲区)的指针(它可以是一个独立结构,也可以是一组结构)。第三个指明要投递的WSABUF的个数。在一个已建立连接的套接字上利用多缓冲发送数据时,顺序是从第一个到最后一个WSABUF结构的方式发送的。同时发生多个缓冲区与“分散集中I/O模式”有关。lpNumberOfBytesSent为总的发送数。最后两个参数: lpOverlapped 和lpCompletionROUTINE主要用于重叠I/O。重叠I/O是Winsock支持的重要的异步I/O模式之一。WSASend函数会用lpNumberOfBytesSent返回写入了的字节数。
现在我们来看看C#中的 Send方法的实现。在同步模式下Socket类里的 Send方法实现是这样的:
Socket.Send
在方法中可以看到以下代码:
UnsafeNclNativeMethods.OSSOCK.send(this.m_Handle.DangerousGetHandle(), null, 0, socketFlags);OSSOCK方法的定义为:
[DllImport("ws2_32.dll", SetLastError=true)]
internal static extern unsafe int send([In] IntPtr socketHandle, [In] byte* pinnedBuffer, [In] int len, [In] SocketFlags socketFlags);
对比可以看到这是API中的Send方法。
在c#的Socket类中还有异步模式的发生方法。
Socket.BeginSend
其中它调用了DoBeginSend方法。
Socket.DoBeginSend
函数中有一句代码为:
socketError = UnsafeNclNativeMethods.OSSOCK.WSASend(this.m_Handle, ref asyncResult.m_SingleBuffer, 1, out num, socketFlags,asyncResult.OverlappedHandle, IntPtr.Zero);它的定义为:
[DllImport("ws2_32.dll", SetLastError=true)]
internal static extern SocketError WSASend(
[In] SafeCloseSocket socketHandle,
[In] ref WSABuffer buffer,
[In] int bufferCount,
out int bytesTransferred,
[In] SocketFlags socketFlags,
[In] IntPtr overlapped,
[In] IntPtr completionRoutine);正好是API中的 WSASend函数。
recv和WSARecv函数
使用recv函数进行数据的接收。
int recv(SOCKET s, char FAR* buf,int len,int flags);
第一个参数表示要接收数据的套接字,第二个参数是数据的字符缓冲区,而第三个是准备要接受的字节数。flags可以下值:0、MSG_PEEK或MSG_OOB。0表示无特殊行为,MSG_PEEK会从接收缓冲区接收数据,但是系统却不从缓冲中删除它删除。接收数据时最好是将所有数据复制到自己的缓冲区中。
WSARecv函数在recv函数的基础上添加了一下新的特性。最明显的就是重叠 I/O部分了。
int WSARecv(
SOCKET s,
LPWSABUF lpBuffers,
DWORD dwBufferCount,
LPDWORD lpNumberOfBytesRecvd,
LPDWORD lpFlags,
LpWSAOVERLAPPED lpOverlapped,
LPWSAOVERLAPPED_COMPLETION_ROUTINE
lpCompletionROUTINE
);
lpBUffers是一个WSABUF(一个带长度的,字符缓冲区结构)结构组成的数组。lpNumberOfBytesReceived参数调用函数后所收到的字节数。lpOverlapped和lpCompletionROUTINE参数用于重叠I/O操作。
现在我们来看看C#中的 Receive方法的实现。
在同步模式下Socket类里的 Receive方法实现是这样的:
Socket.Receive
可以看到方法中有这么一句代码:
UnsafeNclNativeMethods.OSSOCK.recv(
this.m_Handle.DangerousGetHandle(),
null, 0,
socketFlags);OSSOCK.recv的定义为:
[DllImport("ws2_32.dll", SetLastError=true)]
internal static extern unsafe int recv(
[In] IntPtr socketHandle,
[In] byte* pinnedBuffer,
[In] int len,
[In] SocketFlags socketFlags);另外在C#的异步模式里是以 BeginReceive方式接收的数据,其方法实现为:
Socket.BeginReceive
代码在DoBeginReceive中调用了:
socketError = UnsafeNclNativeMethods.OSSOCK.WSARecv(
this.m_Handle,
asyncResult.m_WSABuffers,
asyncResult.m_WSABuffers.Length, out num,
ref socketFlags,
asyncResult.OverlappedHandle,
IntPtr.Zero);OSSOCK.WSARECV的定义为:
[DllImport("ws2_32.dll", SetLastError=true)]
internal static extern SocketError WSARecv(
[In] SafeCloseSocket socketHandle,
[In, Out] WSABuffer[] buffers,
[In] int bufferCount,
out int bytesTransferred,
[In, Out] ref SocketFlags socketFlags,
[In] IntPtr overlapped,
[In] IntPtr completionRoutine);正好对应API中的WSARecv函数。
断开连接
一旦完成了任务,就必须关掉连接,释放关联的套接字句柄。在面向连接的套接字过程中,要安全断开连接需要先调用shoutdown函数,然后再closesocket函数。其中shoutdown 函数用于“从容关闭”连接。
int shoutdown(
SOCKET s,
int how);
how 可以为:SD_RECEIVE、SD_SEND 或SD_BOTH。其中SE_RECEIVE表示不允许在调用接收函数。SD_SEND表示不允许在调用发送函数。SD_BOTH则表示取消连接和接收。
C#中 Socket类有对应的Shoutdown方法 而方法的参数 SocketShutdown枚举正好对应how的值。
Socket.Shoutdown
它是调用了OSSOCK中的shoutdown方法。
[DllImport("ws2_32.dll", SetLastError=true)]
internal static extern SocketError shutdown(
[In] SafeCloseSocket socketHandle,
[In] int how);
closesocket用于关闭套接字。它的定义如下:
int closesocket(SOCKET s);
closesocket的调用会释放套接字描述符。