《WINDOWS 环境下32位汇编语言程序设计》第16章 WinSock接口和网络编程(2)
16.3 TCP应用程序的设计
虽然TcpEcho例子的目的只是让读者实践一下各函数的使用方法,但这个例子示范的程序结构却是TCP服务器端最常用的架构,其特征是用一个独立的线程进行监听和接收连接,然后为每个客户端创建一个工作线程。这种架构的好处是工作流程很清晰,与客户端相关的数据(如登录信息等,这类数据在别的系统中一般称为Session或者会话)放在线程过程的局部变量中,不必集中维护,这样就回避了在全局变量中维护Session列表时涉及的多线程数据同步问题。
当然,这种架构的缺点就是客户端数量到达一定程度时,由于创建的工作线程过多,浪费在线程切换上的时间比较多,造成性能上的下降。基本上,同时在线的客户端数量应该控制在几百个以下,如果同时在线的客户端数量远大于几百个,最合适的方法是使用完成端口(I/O Completion Port,IOCP)模型。当然,在客户端数量没有达到这个规模时,IOCP模型的复杂性带来的缺点反而会彻底抵消掉带来的好处。对IOCP有兴趣的读者可以自行查看相关的资料。
除了网络游戏或者大型网站的服务器端,由于大部分的网络服务程序同时在线客户端数量都不会超过百个,所以TcpEcho采用的程序结构是非常流行的。读者只需沿用程序的架构并将工作线程进行功能上的扩展,就可以实现复杂的通信服务。
本节讨论的内容就是如何设计通信的流程,以便完成复杂而完善的网络服务功能,另外还要讨论的是设计中的注意事项,很少有资料提及这些细节,但是要设计无差错并且“强壮”的商业化网络服务程序,这些细节都是必须要考虑到的。
本节中会用一个TCP聊天室的例子来演示相关的内容,在最后的小节中还会将聊天室客户端改成以非阻塞模式工作,以便讲解非阻塞模式下的程序结构。
16.3.1 通信协议和工作线程的设计
1.设计TCP链路的通信协议
很少有网络服务程序完成的功能像TcpEcho例子这么简单,大部分的TCP程序是采用应答方式的,客户端会向服务器端发送不同的命令请求,服务器端根据请求做不同的操作,并把结果返回给客户端。例如,一个远程控制程序的服务器端会提供列目录、拷贝文件、运行文件、删除文件、关闭计算机等各种功能,客户端发送的数据中会包含命令代码,服务器端收到以后根据代码做对应的操作,并将结果返回。
这种工作模式下,客户端需要将命令代码和相关数据组成一个个数据包发送到服务器端,但是各命令的数据包长度是不同的,比如,运行文件时还要提供运行的文件名,而关机操作就只需要一个命令码,既然客户端是以不同长度的命令数据包为单位进行发送的,那么服务器端如何正确地以命令数据包为单位进行接收呢?
读者可能会说,那还不简单,根据阻塞模式下recv函数的特征,将缓冲区设置为足够大,并在recv函数的接收字节数参数中指定足够大的值,这时函数并不会将缓冲区收满才返回,而是客户端发过来多少数据,就会收到多少数据,既然客户端一次发一个命令数据包,服务器端不就可以每次收到一个完整的数据包吗?
可是现实却不是如此,前面介绍过,TCP协议提供的是流服务,其含义就是协议不对数据包进行边界保护,发送端的WinSock接口会根据缓冲区和数据包的实际情况,自由地对数据包进行组合和分割发送,也就是说,客户端连续发送多个数据包的时候,多个数据包可能会“粘连”,单个数据包也可能被“割裂”。服务器端用大缓冲区去收,并不能保证收到的是一个完整的数据包。最终结果既可能是多个数据包,也可能是半个数据包,几次接收以后,后面的数据包就错位了。
作者曾经分析了网上下载到的很多个实现聊天功能的例子代码,发现绝大多数例子使用的都是这种错误的接收方法,这种方法在网络非常通畅、通信双方的负载很低的情况下是正确的,所以用于演示目的不会发现什么异常,一旦投入到网络情况复杂、服务器负荷很重的情况下使用,就会出现串包现象。很明显,要书写任何情况下都是无错的程序,不考虑数据包的粘连和分割现象是不行的。
解决的办法就是对数据包进行合适的封装,也就是为链路上传递的数据设计合适的通信协议。下面以本节中TCP聊天室例子的通信协议为例说明设计的方法,协议的定义文件是Chapter16\Chat-TCP目录下的_Message.inc文件,内容如下:
;--------------------------------------------------------------------
; 命令代码定义
;--------------------------------------------------------------------
CMD_LOGIN equ 01h ; 客户端 ->服务器端,登录
CMD_LOGIN_RESP equ 81h ; 服务器端 -> 客户端,登录回应
CMD_MSG_UP equ 02h ; 客户端 -> 服务器端,聊天语句
CMD_MSG_DOWN equ 82h ; 服务器端 -> 客户端,聊天语句
CMD_CHECK_LINK equ 83h ; 服务器端 -> 客户端,链路检测
;---------------------------------------------------------------------
; 数据包定义
;---------------------------------------------------------------------
; 数据包头部,所有的数据包都以MSG_HEAD开头
;---------------------------------------------------------------------
MSG_HEAD structdwCmdId dw ? ;命令IDdwLength dd ? ;整个数据包长度=数据包头部+数据包体
MSG_HEAD ends
;---------------------------------------------------------------------
; 登录数据包(客户端->服务器端)
;---------------------------------------------------------------------
MSG_LOGIN structszUserName db 12 dup (?) ;用户登录IDszPassword db 12 dup (?) ;登录密码
MSG_LOGIN ends
;---------------------------------------------------------------------
; 登录回应数据包(服务器端->客户端)
;---------------------------------------------------------------------
MSG_LOGIN_RESP structdbResult db ? ;登录结果:0=成功,1=用户名或密码错
MSG_LOGIN_RESP ends
;---------------------------------------------------------------------
; 聊天语句(客户端->服务器端):不等长数据包
;---------------------------------------------------------------------
MSG_UP structdwLength dd ? ;后面内容字段的长度szContent db 256 dup (?) ;内容,不等长,长度由dwLength指定
MSG_UP ends
;---------------------------------------------------------------------
; 聊天语句(服务器端->客户端):不等长数据包
;---------------------------------------------------------------------
MSG_DOWN structszSender db 12 dup (?) ;消息发送者dwLength dd ? ;后面内容字段的长度szContent db 256 dup (?) ;内容,不等长,长度由dwLength指定
MSG_DOWN ends
;---------------------------------------------------------------------
MSG_STRUCT structMsgHead MSG_HEAD <>unionLogin MSG_LOGIN <>LoginResp MSG_LOGIN_RESP <>MsgUp MSG_UP <>MsgDown MSG_DOWN <>ends
MSG_STRUCT ends
;---------------------------------------------------------------------
文件的开头部分定义了命令代码,这一点并没有什么特殊之处,聊天室例子程序支持的命令代码有:客户端登录、服务器端对登录的回应、客户端发送聊天语句,以及服务器端转发其他客户端的聊天语句等。
后面就是对数据包的定义了,正确的设计方法是将数据包的组成分成两部分——数据包头和数据包体,读者接下来马上就可以发现这种设计的奥妙。
数据包头一般包含两个字段:命令代码字段和数据包长度字段,例子中的MSG_HEAD结构就是数据包头,里面有dwCmdId和dwLength字段。数据包体的定义则根据命令的不同而不同,比如,登录数据包体MSG_LOGIN结构中包含登录的用户名和密码,登录回应数据包MSG_LOGIN_RESP结构中只有表示登录是否成功的状态字段。文件的最后用MSG_STRUCT结构定义完整的数据包,这个结构表现了完整数据包的组成方式——结构的第一项是一个MSG_HEAD结构,第二项是一个包含各种数据包体定义的集合。
这样定义有什么好处呢?好处就是便于接收方从数据流中解出正确的数据包。数据包头的长度是确定的,接收方每次接收的时候首先收全一个数据包头部(假如一次调用recv得到的数据少于数据包头的长度,则循环recv直到收全一个数据包头),然后从数据包头中的dwLength字段中得到整个数据包的确切长度,将这个长度减去包头的长度后,剩下的就是需要接收的数据包体长度,然后就可以循环调用recv函数,等收全整个数据包后就可以处理了。
TCP聊天室例子目录下还有一个_SocketRoute.asm文件,其中的_RecvPacket子程序就是用上面描述的方法接收一个完整的数据包的,请读者看看这个文件的内容:
;--------------------------------------------------------------------
; 在规定的时间内等待数据到达
; 输入:dwTime = 需要等待的时间(微秒)
;--------------------------------------------------------------------
_WaitData proc _hSocket,_dwTimelocal @stFdSet:fd_set,@stTimeval:timevalmov @stFdSet.fd_count,1push _hSocketpop @stFdSet.fd_arraypush _dwTimepop @stTimeval.tv_usecmov @stTimeval.tv_sec,0invoke select,0,addr @stFdSet,NULL,NULL,addr @stTimevalret
_WaitData endp
;--------------------------------------------------------------------
; 接收规定字节的数据,如果缓冲区中的数据不够则等待
; 返回:eax = TRUE,连接中断或发生错误
; eax = FALSE,成功
;---------------------------------------------------------------------
_RecvData proc _hSocket,_lpData,_dwSizelocal @dwStartTimemov esi,_lpDatamov ebx,_dwSizeinvoke GetTickCountmov @dwStartTime,eax
;---------------------------------------------------------------------@@:invoke GetTickCount ;查看是否超时sub eax,@dwStartTimecmp eax,10 * 1000jge _Err
;---------------------------------------------------------------------invoke _WaitData,_hSocket,100*1000 ;等待数据100mscmp eax,SOCKET_ERRORjz _Error eax,eaxjz @Binvoke recv,_hSocket,esi,ebx,0.if (eax == SOCKET_ERROR) || ! eax
_Err:xor eax,eaxinc eaxret.endif.if eax < ebxadd esi,eaxsub ebx,eaxjmp @B.endifxor eax,eaxret
_RecvData endp
;--------------------------------------------------------------------
; 接收一个符合规范的数据包
; 返回:eax = TRUE(失败)
;--------------------------------------------------------------------
_RecvPacket proc _hSocket,_lpBuffer,_dwSizelocal @dwReturnpushadmov @dwReturn,TRUEmov esi,_lpBufferassume esi:ptr MSG_STRUCT
;--------------------------------------------------------------------
; 接收数据包头部并检测数据是否正常
;--------------------------------------------------------------------invoke _RecvData,_hSocket,esi,sizeof MSG_HEADor eax,eaxjnz _Retmov ecx,[esi].MsgHead.dwLengthcmp ecx,sizeof MSG_HEADjb _Retcmp ecx,_dwSizeja _Ret
;-------------------------------------------------------------------
; 接收余下的数据
;-------------------------------------------------------------------sub ecx,sizeof MSG_HEADadd esi,sizeof MSG_HEAD.if ecxinvoke _RecvData,_hSocket,esi,ecx.elsexor eax,eax.endifmov @dwReturn,eax
_Ret:popadassume esi:nothingmov eax,@dwReturnret
_RecvPacket endp
在_RecvPacket子程序中,程序首先调用_RecvData子程序接收一个数据包头,然后对数据包头进行适当的校验,假如里面的dwLength字段内容小于一个包头的长度,或者大于缓冲区的长度,那么说明数据包是不合法的,程序将出错返回;如果通过了校验,那么继续调用_RecvData子程序接收数据包的剩余部分,这部分长度等于dwLength减去MSG_HEAD的长度。
_RecvData子程序循环调用recv函数来收全指定字节数的数据,由于发送方因为各种原因只发送部分数据时(比如,连上来的是不合法的程序,并没有按照规定的协议发送整个数据包),会造成程序长时间阻塞在recv函数中,所以在调用recv函数前,要先使用select函数检测是否有数据到达。子程序中每次还用GetTickCount函数获取时间值并检测接收数据已经花费的总时间,如果超过了规定的时间则作为错误处理。
由于这两个子程序实现的功能很常用,所以例子中将它们单独放到_SocketRoute.asm源文件中,以便同时在服务器端和客户端中使用。读者在编写其他网络应用程序的时候,也可以将其包含在源代码中直接调用,这时只需要修改涉及数据包头定义的几句代码即可。
当然,在实际的应用中设计的通信协议不一定就是和上面的例子一模一样的,这里有几个变通设计的例子。
假如服务器端和客户端都已经明确约定各个命令的数据包长度,那么包头的定义里面就可以没有dwLength字段,因为接收方根据包头里面的命令码也可以查出包体的长度是多少,但是,明确地用一个dwLength字段指定整个数据包的长度有很多好处。
第一个好处是有利于兼容性,假设服务器端的协议升级导致某个命令数据包的长度改变了,这样老版本的客户端连上来时发过来的数据包长度就会不符,服务器端凭命令码判断而收取的数据包长度是不对的,后面的数据包就会错位,而总是根据dwLength字段的长度收数据包则在任何时刻都不会发生数据包错位的现象;第二个好处是定义dwLength字段有利于简单的纠错,因为这个字段的值小于包头的长度或者大到超过缓冲区长度都表示数据包有误;还有一个好处是便于定义不定长的数据包,以节省网络带宽,例如,数据包里包含一个文件名时,只需发送文件名字符串的有效字符就可以了,没必要非得发完固定的MAX_PATH长度。
数据包头里面也可以增加其他一些字段,如命令的流水号、数据包的校验和、数据包头的特征标识等。例如,定义数据包头的第一个字段是个单字节的特征标识,这个字段的内容必须是0FFh才是合法的,那么接收方收到第一个字节不是0FFh的数据包,就可以得知数据包有误。
不管怎样对通信协议的设计进行变通,请读者记住一个要点,那就是数据包的结构中必须有一个固定长度的包头,便于接收方首先接收,其次包头中必须包含整个数据包的长度信息(或者从包头里面的其他数据可以推断出整个数据包的长度),这样接收方才能从数据流中正确分解出完整的数据包。
当然,如果读者要编写的不是基于命令应答的应用,就无须以本节描述的方法定义通信协议。比如,基于数据流的应用就根本不存在分解数据包的问题,典型的基于数据流的应用是从网上下载一个文件,接收方只要连续接收并存盘即可。
2.链路异常检测
在TCP应用程序的设计中,一个很重要但又经常被忽略的问题是对链路异常情况的检测和处理,什么是链路异常呢?读者可以先用前面的TcpEcho例子程序做一个实验:
准备两台连网的计算机(注意,不要通过直连的对绞线相连,两台计算机中间要至少经过一个HUB),在计算机A上运行TcpEcho程序,然后在计算机B上用telnet xx.xx.xx.xx 9999连接到A计算机,这时TcpEcho的对话框上可以看到在线客户端数量为1。现在模拟网络中断或者一方的计算机崩溃的情况,将计算机B的网线拔掉(如果读者不心疼的话,也可以直接拔计算机B的电源线!),可以发现,TcpEcho对话框上显示的在线客户端数量还是1。
理论上,连接已经中断了,那么等多久TcpEcho程序才能检测到这一情况呢?很不幸,如果读者有足够的耐心等待的话,就可以发现等上几天几夜,在线客户端数量还是1。也就是说,这个无效的工作线程一直在占用资源。由于网络故障和客户端死机的情况是不可能完全避免的,如果服务器端程序是个长年在线服务的程序,一段时间后,这种“僵尸”线程就会越来越多,直到消耗完所有的资源,造成程序崩溃为止。
程序无法检测网络断线的原因与TCP协议的工作原理有关,TCP连接正常断开的时候,主动断开的一方会向另一方发送含有断开连接标志的数据包,接收方收到数据包后,将连接的状态更新为断开,任何对套接字的操作就会失败,如果这时有recv函数或者select函数在对套接字进行阻塞等待,函数就会马上返回,返回值是SOCKET_ERROR。所以连接正常断开的时候,工作线程是可以检测到的。
但是出现网络故障或者一方的计算机崩溃时,另一方是收不到含有断开连接标志的数据包的,系统只会认为对方一直没有数据过来,这种情况要延续到程序需要主动发送数据包为止,如果主动发送的数据包得不到对方的应答,在经过几次尝试全部超时以后,系统就会意识到连接已经断开。
更确切地说,连接异常中断后的首次send调用是会返回成功的,因为这时仅仅表示数据成功地放入了发送缓冲区,WinSock接口还没开始在网络上发送数据包,所以还没法检测到连接已中断,在经过几分钟后,由这个send调用引发的数据包发送动作一直得不到对方的回应,WinSock才将连接的状态置为断开,以后对套接字的操作才会全部失败。
所以发现这种链路异常的唯一办法是主动发送数据,但很多服务器端程序仅仅处理客户端的请求后才回送数据,从来不会主动向客户端发送数据,上面的TcpEcho例子就是如此,所以程序根本没有机会检测到链路异常。
要解决这种问题,必须在通信协议的设计中就考虑到这一点,常用的方法是通信双方都记录链路的最后一次活动时间(比如,收到过数据或者发送过数据),一旦空闲的时间到一定的秒数,就由一方主动向另一方发一个数据包。由于这个数据包仅仅是为了检测链路而发,一般将其称为“链路检测包”,链路检测包没必要定义包体,只需要一个包头即可。例如,前面的例子中定义了一个链路检测命令编码CMD_CHECK_LINK equ 83h,但并没有定义一个链路检测包体的结构。
一旦链路异常,发送方在发送链路检测包后不久就可以检测到;对于接收方,如果在规定的空闲时间内收到链路检测包,那么只需更新链路的最后一次活动时间,并不需要做额外的操作,如果在规定的时间内没有收到,就可以认为链路异常中断了。一般来说,接收方的时间要定义得稍微宽余一些,比如,规定发送方在链路空闲30秒后发送链路检测包,那么接收方可以在链路空闲60秒后才认为连接异常,否则因为定时的误差,数据包已经在路上了,接收方却认为时间已到而退出,就起不到应有的作用了。在具体的使用中,由服务器端还是由客户端发送链路检测包并没有规定,只需从编程方便的角度考虑就可以了。
3.多线程下的数据收发
在实际编程中,经常要遇到在不同的线程中操作同一个套接字的问题,比如,设计阻塞模式工作的客户端的时候,需要循环调用select函数来检测是否由数据到达,由于在主线程中实现循环很不方便,所以往往单独创建一个线程,在线程中循环调用select函数和recv函数来接收数据。另一方面,由于发送数据的动作都是由用户在界面上操作引发的,所以send函数在主线程中调用更方便。读者在下面的TCP聊天室客户端例子中就可以看到,接收操作是在单独的接收线程中完成的,而发送操作是在主线程中通过响应“发送”按钮的动作实现的。
那么跨线程对同一个套接字进行收发操作究竟有没有什么限制呢?这要从WinSock接口对recv和send函数的处理方式来判断。
WinSock接口对发送缓冲区和接收缓冲区的操作有排队锁定机制,也就是说,当多个线程同时调用send函数对同一个套接字进行发送操作时,只有一个线程能锁定发送缓冲区,其余线程处于等待状态,当活动线程的send函数调用完毕后,再轮到其他线程的send函数。所以同时在多个线程中调用send函数,这些函数发送的数据在网络上也不会交织在一起。
同样,多个线程同时调用recv函数对同一个套接字进行接收操作时,也只有一个线程能锁定接收缓冲区,其余线程处于等待状态,也就是说,即使多个线程同时调用recv函数,同一份数据也不会被多个线程重复接收。
仔细体会WinSock接口的处理方式,结合前面介绍的数据包头加数据包体的封装方式,再结合处理数据包的_RecvPacket子程序的工作流程,就可以分析出在多线程中对同一个套接字进行收发的可能性。
首先分析一下发送数据包的情况。
在阻塞模式下,send函数总是将指定大小的数据包发送完才返回,由于多个send函数发送的数据不会交织,所以只要每次调用send函数发送的数据包都是一个完整的数据包的话,就不必担心数据包之间的数据会互相交织。但是对一个数据包分开发送的话(比如,先调用一次send函数发送数据包头,再调用一次send函数发送数据包体)就可能出错,因为线程A发送的数据包头发送完毕后,可能接下去发送的是线程B的数据包头,然后才轮到线程A的数据包体,这样接收方收到的数据就是错误的。
而在非阻塞模式下,即使一次指定发送一个完整的数据包,函数也可能只发送部分数据,程序需要判断还有多少数据没有发送,并循环调用send函数将整个数据包发完。这样,循环调用send的过程中可能会被其他线程的send操作插入,造成不同数据包的数据交织在一起。所以非阻塞模式下不能有多个线程同时对一个套接字进行发送操作。如果一定要在多个线程中同时发送,那么需要采用临界区等对象进行线程同步,以保证发送一个完整的数据包的期间不会被其他线程打断。
接收数据包时,不管是阻塞模式还是非阻塞模式下,recv函数总是不能保证一次收全一个数据包,而且_RecvPacket子程序的实现中,也必然是分多次调用recv函数完成的(至少两次,一次接收数据包头,一次接收数据包体),这就意味着一个线程分多次收取一个数据包的时候,中间的部分数据可能被其他线程收走,造成数据错误。所以两种模式下,都不能有多个线程同时去收数据包,如果一定要在多个线程中接收数据包,也需要采用临界区等对象进行线程同步,以保证接收一个完整的数据包的期间不会被其他线程打断。
如图16.6所示,一般来说,TCP连接上传递的数据包的应答情况有三种,最简单的情况如图中左边部分所示,服务器端和客户端根据自己的需要在任意时刻发送数据包,对方收到数据包以后不需要回复。如图中客户端分别发送了C1、C2和C3共三个数据包,而服务器端发送了S1和S2两个数据包,这些数据包之间并没有关联。这样,收发操作既可以在一个线程中实现,也可以在多个线程中实现。比如,主线程负责发送数据包,另一个线程只负责接收数据包。
图16.6 网络应用程序的常见通信方式
图16.6中中间部分所示的是单方向的应答式通信方式——客户端发送命令(如C1、C2数据包),服务器收到数据包后进行处理,并返回结果(C1_RESP和C2_RESP),客户端发完一个命令数据包后,必须等到对应的回应信息后,才能继续发送后一个命令。而服务器端不主动向客户端发送数据包。在这种情况下,由于客户端中发送命令的代码后面紧跟着接收的代码(服务器端的顺序相反),一般将收发操作放在同一个线程中实现。
图16.6中右边部分所示的是双方互为应答的通信方式——在单方向应答式通信的基础上,服务器端也可能主动向客户端发送命令请求,客户端处理后返回结果数据包,这样双方的程序在逻辑上有两部分,一个部分是等待对方的命令,进行处理并返回;另一个部分是发送命令,等待对方的处理并返回。这两个部分必须放在同一个线程中来实现。原因就是,两个部分都有接收数据包的操作,而我们知道,在多个线程中同时接收数据包可能会产生错误。而且,即使分两个线程,然后对_RecvPacket子程序用临界区对象进行同步也是不行的,如图16.6的右边所示,假设在线程A(主动发送命令的线程)中发送了C2数据包,这时程序预期等待的是C2_RESP数据包,但实际接收的可能是本该由线程B(等待命令并处理的线程)接收并处理的S1数据包,虽然可以保证收到的是完整的数据包,但是程序的处理流程就全部乱套了。
在实际的使用中,往往在同一个线程中用下面的代码结构处理互为应答式的通信:
...
;-------------------------------------------------------------------------
; 处理接收的命令并返回处理结果的模块,输入参数为接收的命令数据包
;-------------------------------------------------------------------------
_ProcRecvCmd proc.if 命令码 == C1处理并用send发送C1_RESP数据包.elseif 命令码 == C2处理并用send发送C1_RESP数据包.elseif ....endifret
_ProcRecvCmd endp
;-------------------------------------------------------------------------
; 主动发送命令并接收处理结果的模块
;-------------------------------------------------------------------------
_SendCmd proc.if 命令队列中没有命令需要发送ret.endif从队列中取需要发送的命令.if 命令码 == S1用send发送S1数据包@@:用_RecvPacket接收回复的数据包.if 回复数据包 != S1_RESPinvoke _ProcRecvCmdjmp @B.endif.elseif 命令码 == S2用send发送S2数据包@@:用_RecvPacket接收回复的数据包.if 回复数据包 != S2_RESPinvoke _ProcRecvCmdjmp @B.endif.elseif ....endifret
_SendCmd endp
;-------------------------------------------------------------------------
; 工作线程
;-------------------------------------------------------------------------....while TRUEinvoke _SendCmdinvoke _CheckLine;发送链路检测包(具体程序省略)调用select函数等待100ms,查看是否有数据到达.if 有数据到达调用_RecvPacket接收整个数据包invoke _ProcRecvCmd.endif.endw
在这种架构下,需要建立一个发送命令队列,所有线程产生的发送命令请求统一放到发送队列中,然后在工作线程中调用_SendCmd子程序(发送模块)去检测发送命令队列,如果队列不为空,则进行发送操作并等待对方的回应数据包。_SendCmd子程序的关键是,如果在发送了某个命令后,接收到的不是预期的回应数据包,就表示这个数据包是对方主动发送的命令,这时程序先调用_ProcRecvCmd子程序对这个命令进行处理并回复,然后继续等待预期的回应数据包,直到等待规定的回应数据包后才退出。
在循环中,发送模块处理完毕后,程序用select函数检测是否有数据包到达,如果有的话,则接收一个数据包,然后调用_ProcRecvCmd子程序进行处理。由于前面的_SendCmd子程序中对每个发送的命令都会先接收对应的回应数据包后再退出,所以可以保证这里收到的数据包肯定对方发送的命令数据包。命令处理完毕后,再进行下一轮循环。
当然,循环中间还有很多的异常处理代码,例子中只列出了对链路检测包的示范,实际应用中,在每次的发送和接收语句后面,都应该检测函数的返回值,如果出错的话则退出循环并关闭套接字,然后终止线程。
16.3.2 TCP聊天室例子——服务器端
好了,读者现在应该对TCP应用程序的设计思路和注意事项有了相当的了解,说得多不如做得多,现在我们用一个TCP聊天室的例子来实践一下基于数据包应答的网络应用程序。详细的源代码位于光盘的Chapter16\Chat-TCP目录下,其中服务器端程序用到了以下文件:
● Server.rc——服务器端程序的资源脚本文件。
● Server.asm——服务器端程序的汇编源代码。
● _Message.inc——通信协议的定义文件,同时供服务器端和客户端包含使用。
● _SocketRoute.asm——阻塞模式下通用的子程序,在源代码中包含使用。
● _MsgQueue.asm——实现消息队列的几个子程序,在源代码中包含使用。
在这些文件中,_Message.inc文件和_SocketRoute.asm文件的作用在上一个节中就已经详细分析过了,_MsgQueue.asm文件则是用于维护一个消息队列的,为什么程序中还要用到消息队列呢?
这是因为:在大部分的聊天室例子中,当服务器端收到某个客户端的聊天语句时,会用一个循环将这条语句转发给所有在线的其他客户端,这样,如果要转发的客户端中有些网速很慢,所有客户端的聊天速度都会被拖慢,因为程序必须等转发的循环结束才能接收下一条聊天语句;另外,为了将语句转发给所有在线的其他客户端,程序必须维护一个在线客户端列表,但是随着客户端的频繁登录退出,对列表进行增删操作是件很麻烦的事情。
为了解决这些矛盾,程序设计了一个消息队列,当服务器端收到某个客户端的聊天语句后,不向其他在线的客户端主动转发,而是将聊天语句插入消息队列,消息队列是先进先出(FIFO)类型的,当队列满的时候,最早的一条消息被挤出队列,队列中的消息按顺序被定义了从小到大的消息编号。
某个工作线程将消息放入队列后,其他客户端的工作线程在接收聊天语句的空闲时间中,主动获取队列中的消息并进行发送。这样,程序就不必维护一个在线客户端列表供循环发送使用。各个工作线程从消息队列中获取了一条消息并发送后,记录下已经发送的消息编号,下一次就从这个编号的后一条消息开始获取,如果队列中没有更高编号的消息,意味着所有的聊天语句都已经转发过了;如果要获取的编号比队列中最小的消息编号都要小,意味着网速太慢导致要查询的消息已经被挤出了队列,程序继续从队列中的现有的最小编号消息开始转发。
通过这种方式,系统的工作会以正常的节奏进行,速度过慢的客户端不会影响其他客户端的工作,而是会丢失一些聊天语句,但这样更符合正常的使用习惯。
_MsgQueue.asm文件中包含的两个子程序就用于实现消息队列:
其中的_InsertMsgQueue子程序在队列中加入一条消息,如果队列已经满了,则将整个队列前移一个位置,相当于最早的消息被覆盖,然后在队列尾部空出的位置加入新消息;如果队列未满,则在队列的最后加入新消息。_GetMsgFromQueue子程序则从队列获取指定编号的消息,如果指定编号的消息已经被清除出消息队列,则自动返回编号最小的一条消息;如果队列中的所有消息的编号都比指定编号小(意味着所有消息都被获取过了),那么不返回任何消息。
由于这两个子程序的实现很简单,为了突出重点,这里就不再列出并分析这两个子程序的具体实现了,如果读者有兴趣分析完整的代码,请查看光盘中的_MsgQueue.asm文件。
服务器端工作线程的具体实现方式如下:
...
;--------------------------------------------------------------------
; 对话框界面的实现以及监听线程的代码同TcpEcho例子,这里不再具体列出
; 以下仅列出工作线程的具体代码,详细代码请参考光盘中的Server.asm文件
;--------------------------------------------------------------------
;
;--------------------------------------------------------------------
; 客户端会话信息结构
;--------------------------------------------------------------------
SESSION structszUserName db 12 dup (?) ; 用户名dwMessageId dd ? ; 已经下发的消息编号dwLastTime dd ? ; 链路最近一次活动的时间
SESSION ends.const
szSysInfo db '系统消息',0
szUserLogin db ' 进入了聊天室!',0
szUserLogout db ' 退出了聊天室!',0...
;-------------------------------------------------------------------
; 代码段
;-------------------------------------------------------------------.code
include _Message.inc
include _SocketRoute.asm
include _MsgQueue.asmassume esi:ptr MSG_STRUCT,edi:ptr SESSION
;------------------------------------------------------------------
; 循环取消息队列中的聊天语句并发送到客户端,直到全部消息发送完毕
;------------------------------------------------------------------
_SendMsgQueue proc uses esi edi _hSocket,_lpBuffer,_lpSessionlocal @stMsg:MSG_STRUCTmov esi,_lpBuffermov edi,_lpSession.while ! (dwFlag & F_STOP)mov ecx,[edi].dwMessageIdinc ecxinvoke _GetMsgFromQueue,ecx,\addr [esi].MsgDown.szSender,\addr [esi].MsgDown.szContent.break .if ! eaxmov [edi].dwMessageId,eaxinvoke lstrlen,addr [esi].MsgDown.szContentinc eaxmov [esi].MsgDown.dwLength,eaxadd eax,sizeof MSG_HEAD+MSG_DOWN.szContentmov [esi].MsgHead.dwLength,eaxmov [esi].MsgHead.dwCmdId,CMD_MSG_DOWNinvoke send,_hSocket,esi,\[esi].MsgHead.dwLength,0.break .if eax == SOCKET_ERRORinvoke GetTickCountmov [edi].dwLastTime,eax
;------------------------------------------------------------------------invoke _WaitData,_hSocket,0.break .if eax == SOCKET_ERROR.if eaxxor eax,eax.break.endif
;------------------------------------------------------------------------.endwret
_SendMsgQueue endp
;------------------------------------------------------------------------
; 检测链路的最后一次活动时间
;------------------------------------------------------------------------
_LinkCheck proc uses esi edi _hSocket,_lpBuffer,_lpSession
;------------------------------------------------------------------------
; 查看是否需要检测链路(30秒内没有数据通信则发送链路检测包)
;------------------------------------------------------------------------invoke GetTickCountpush eaxsub eax,[edi].dwLastTimecmp eax,30 * 1000pop eaxjb _Ret@@:mov [edi].dwLastTime,eaxmov [esi].MsgHead.dwCmdId,CMD_CHECK_LINKmov [esi].MsgHead.dwLength,sizeof MSG_HEADinvoke send,_hSocket,esi,[esi].MsgHead.dwLength,0cmp eax,SOCKET_ERRORjnz _Retret
_Ret:xor eax,eaxret
_LinkCheck endp
;------------------------------------------------------------------------
; 工作线程:每个客户端登录的连接将产生一个线程
;------------------------------------------------------------------------
_ServiceThread proc _hSocketlocal @stSession:SESSION,@szBuffer[512]:bytepushadinc dwThreadCounterinvoke SetDlgItemInt,hWinMain,\IDC_COUNT,dwThreadCounter,FALSElea esi,@szBufferlea edi,@stSessioninvoke RtlZeroMemory,edi,sizeof @stSessionmov eax,dwSequencemov [edi].dwMessageId,eax
;-----------------------------------------------------------------------
; 用户名和密码检测,为了简化程序,现在可以使用任意用户名和密码
;-----------------------------------------------------------------------invoke _RecvPacket,_hSocket,esi,sizeof @szBufferor eax,eaxjnz _Ret.if [esi].MsgHead.dwCmdId != CMD_LOGINjmp _Ret.elseinvoke lstrcpy,addr [edi].szUserName,\addr [esi].Login.szUserNamemov [esi].LoginResp.dbResult,0.endifmov [esi].MsgHead.dwCmdId,CMD_LOGIN_RESPmov [esi].MsgHead.dwLength,\sizeof MSG_HEAD+sizeof MSG_LOGIN_RESPinvoke send,_hSocket,esi,[esi].MsgHead.dwLength,0cmp eax,SOCKET_ERRORjz _Retcmp [esi].LoginResp.dbResult,0jnz _Ret
;--------------------------------------------------------------------------
; 广播:xxx进入了聊天室
;--------------------------------------------------------------------------invoke lstrcpy,esi,addr [edi].szUserNameinvoke lstrcat,esi,addr szUserLogininvoke _InsertMsgQueue,addr szSysInfo,esiinvoke GetTickCountmov [edi].dwLastTime,eax
;-------------------------------------------------------------------------
; 循环处理消息
;-------------------------------------------------------------------------.while ! (dwFlag & F_STOP)invoke _SendMsgQueue,_hSocket,esi,edi.break .if eaxinvoke _LinkCheck,_hSocket,esi,edi.break .if eax.break .if dwFlag & F_STOP
;------------------------------------------------------------------------
; 使用select函数等待 200ms,如果没有接收到数据包则循环
;------------------------------------------------------------------------invoke _WaitData,_hSocket,200 * 1000.break .if eax == SOCKET_ERROR.if eaxinvoke _RecvPacket,_hSocket,esi,sizeof @szBuffer.break .if eaxinvoke GetTickCountmov [edi].dwLastTime,eax.if [esi].MsgHead.dwCmdId == CMD_MSG_UPinvoke _InsertMsgQueue,\addr [edi].szUserName,\addr [esi].MsgUp.szContent.endif.endif.endw
;------------------------------------------------------------------------
; 广播:xxx退出了聊天室
;------------------------------------------------------------------------invoke lstrcpy,esi,addr [edi].szUserNameinvoke lstrcat,esi,addr szUserLogoutinvoke _InsertMsgQueue,addr szSysInfo,addr @szBuffer
;-----------------------------------------------------------------------
; 关闭socket
;-----------------------------------------------------------------------
_Ret:invoke closesocket,_hSocketdec dwThreadCounterinvoke SetDlgItemInt,hWinMain,IDC_COUNT,\dwThreadCounter,FALSEpopadret
_ServiceThread endpassume esi:nothing,edi:nothing...
首先,程序定义了一个SESSION结构,用来保存每个会话(Session)的数据。所谓会话,就是服务器端为每个连接上来的客户端分别保存的各种信息,比如,工作状态、套接字句柄、登录用户名等各连接之间互相独立的数据。在本例中,SESSION结构中定义了三个字段:szUserName为客户端登录时使用的用户名;dwMessageId表示已经向客户端转发的最后一条消息编号;dwLastTime表示链路空闲的时间,用于定时发送链路检测包。
每产生一个客户端连接的时候都需要分配一个结构用于保存会话数据,直到客户端断开的时候才被释放,如果集中管理会话数据的话,不管是采用预留n个SESSION结构的空间,还是动态申请空间的方法,都会涉及空间的申请、查找和释放的问题,实现起来比较麻烦。
对于为每个客户端连接产生一个工作线程的架构来说,将SESSION结构放在工作线程的局部变量中分散管理是最方便的,这样在线程开始的时候,SESSION结构自动被分配,线程结束的时候会被自动释放,本例中采用的就是这种方式。
在工作线程中,完成对SESSION结构的初始化工作后,马上要处理的是客户端的登录操作,客户端会首先发送一个Login数据包,包头中的命令代码是CMD_LOGIN,服务器端必须检测收到的第一个数据包是否是登录数据包,如果不是的话即关闭连接。
假如成功地收到了Login数据包,服务器端将给客户端回复一个包含CMD_LOGIN_RESP命令的LoginResp数据包。在本例中程序做了简化,任何的密码都能通过登录,而且程序也不对同样的用户名进行重复登录的检测,在实际的应用中,程序可以在这个阶段查询保存在数据库或者文件中的用户名和密码进行对比,如果登录信息错误,则在回复的LoginResp包中填写错误信息通知客户端,并主动断开连接。登录成功后,程序将Login数据包中的用户名保存到SESSION结构中,以便以后使用。
由于在客户端进入收发聊天语句的循环前必须登录,例子中将登录数据包的应答工作放在主循环前完成,这样程序的结构更加合理和容易理解。登录验证通过后,程序向消息队列中插入“某某已登录”的消息,然后进入聊天语句的收发循环。
循环采用的架构正是16.3.1节末尾介绍的,回过头来复习一下这个架构:
.while TRUEinvoke 主动发送数据包的模块invoke 链路超时检测模块调用select函数等待100ms,查看是否有数据到达.if 有数据到达调用_RecvPacket接收整个数据包invoke命令处理模块.endif
.endw
在例子中,主动发送数据包的模块对应的是_SendMsgQueue子程序,子程序中循环从消息队列中获取消息并发送到客户端,直到队列中不再有新的消息为止。考虑到发送速度比较慢的时候,队列中会一直有消息等待发送而导致程序一直在_SendMsgQueue子程序中打转,没有机会退到外层循环中接收并处理数据包,所以每次发送数据包后用select函数(在_WaitData子程序中)检测一下,假如有数据到达则优先退出并处理到达的数据包。
链路超时检测模块则由_LinkCheck子程序实现,子程序中对SESSION中保存的dwLastTime变量进行检测,一旦计数值和当前时间的差值超过30秒,则尝试发送一个命令编号为CMD_CHECK_LINK的链路检测包。
由于例子中的命令处理模块比较简单,所以没有写成一个单独的子程序,而是直接写在了循环中,处理方法就是将收到的聊天数据包用_InsertMsgQueue子程序添加到消息队列中,以供其他工作线程获取并转发。
读者还可以注意到,不管是在主动发送数据包的模块还是接收数据包的模块中,凡是成功发送或者接收数据后,程序总是用GetTickCount函数获取当前时间计数并更新SESSION结构中的dwLastTime字段,这样连接足够忙碌的时候,_LinkCheck子程序中的时间差就永远不会超过30秒,也就不必发送链路检测包。只有连接过于空闲的时候,才会每隔30秒发送一次链路检测包。
程序中还在每个发送和接收函数后进行错误检测,一旦客户端主动断开或者连接异常中断,循环就能马上退出,退出后程序在消息队列中插入一条“某某退出聊天室”的语句后,关闭套接字并终止线程。
需要一提的是,程序对不定长数据包的处理,从_Message.inc文件中可以看到,上行和下行的聊天语句数据包MSG_UP和MSG_DOWN结构中都有szContent字段,这个字段是按照最长256字节定义的,但实际聊天的时候很少有这么长的语句,如果每次发送数据包的时候都按照最长长度发送,对网络带宽的浪费是很严重的,所以程序中对这两个数据包的发送都采用了不定长的方式。
invoke lstrlen,addr [esi].MsgDown.szContent
inc eax
mov [esi].MsgDown.dwLength,eax
add eax,sizeof MSG_HEAD+MSG_DOWN.szContent
mov [esi].MsgHead.dwLength,eax
mov [esi].MsgHead.dwCmdId,CMD_MSG_DOWN
invoke send,_hSocket,esi,[esi].MsgHead.dwLength,0
具体的算法如上面的语句所示,程序首先使用lstrlen函数计算字符串的长度,加上1就是算上字符串结束符0的长度。在MASM的语法中,“结构名.字段名”代表该字段在结构中的偏移量,所以sizeof MSG_HEAD+MSG_DOWN.szContent的值就是数据包头的长度加上结构中szContent之前的所有字段的长度,这个值加上szContent字段中字符串的总长度,就是整个数据包的长度。
如果读者有兴趣尝试一下对程序进行扩展的话,可以思考一下下面的问题(当然,真正开始行动必须等看完客户端的代码后再开始,因为网络程序的客户端和服务器端总是需要互相配合工作的)。
(1)如何在下行的聊天消息中加上用户发言的时间?
(2)如何限制用户灌水,即短时间内发布大量的重复语句?
(3)如何加上对用户重复登录的限制?
(4)如何实现悄悄话的功能?
对于这些问题的解决方法,这里有些简单的建议,希望能够以此扩展读者的思路。
第一个问题的实现是最简单的,首先在消息队列和下行的MSG_DOWN结构中分别加上发言时间就可以了,其次工作线程收到聊天语句后,需要获取当前时间,并将其和其他数据一起放入消息队列。
解决第二个问题要复杂一点,需要对SESSION结构进行扩展,增加记录上次发言时间的字段,以便判断客户端发言的时间间隔;另外,要增加MSG_UP_RESP数据包的定义,服务器端在处理CMD_MSG_UP命令时,首先判断发言时间,一旦判断发言过快,则向客户端发送带错误代码的MSG_UP_RESP数据包;如果合法的话,则在_InsertMsgQueue子程序执行后向客户端发送带成功代码的MSG_UP_RESP数据包。客户端根据该数据包对用户进行相应的提示(如收到错误代码,则提示“您的发言太快了,请休息片刻”)。
要解决第三个问题,服务器端必须维护一个全局的登录用户列表,客户端登录的时候,首先查找列表,如果用户名不在列表中,则添加到列表并允许登录;否则在LOGIN_RESP数据包中通知客户端该用户已经登录;客户端断开连接的时候,在列表中删除对应的用户。在编程的时候需要考虑多线程的同步问题。
第四个问题的解决应该建立在解决第三个问题的基础上,首先定义SEND_ONLINE_USER数据包,在客户端登录后,服务器端即主动向客户端发送该数据包,数据包中包含在线用户列表数据,客户端收到后即可以用一个下拉框显示在界面上;其次是客户端需要跟踪“某某用户登录”,以及“某某用户退出”的消息,以便更新在线用户下拉框;再次,MSG_UP消息中需要增加发送对象字段,用于指定向全部用户发送还是向某个用户发送,服务器端将该信息一并放入消息队列;最后,每个工作线程取消息的时候要判断消息中的发送对象,如果是发往其他用户的“悄悄话”,则不进行转发。
16.3.3 TCP聊天室例子——客户端
现在来看看和服务器端配套的客户端例子。
在光盘的Chapter16\Chat-TCP目录下可以找到对话框的资源脚本文件Client.rc和汇编源代码Client1.asm和Client2.asm文件,其中Client1.asm是以阻塞模式工作的客户端代码,Client2.asm是以非阻塞模式工作的。两个程序的界面和实现的功能是一样的,使用的资源脚本文件都是Client.rc文件。本节中将分析以阻塞模式工作的客户端代码。
客户端运行的界面如图16.7所示。
图16.7 TCP聊天室例子的客户端和服务器端界面
客户端对应的资源脚本文件Client.rc内容如下:
//>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
#include <c:/masm32/include/resource.h>
//>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
#define ICO_MAIN 1000
#define DLG_MAIN 2000
#define IDC_SERVER 2001
#define IDC_USER 2002
#define IDC_PASS 2003
#define IDC_LOGIN 2004
#define IDC_LOGOUT 2005
#define IDC_INFO 2006
#define IDC_TEXT 2007
//>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
ICO_MAIN icon "Main.ico"
//>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
DLG_MAIN DIALOG 94, 81, 245, 168
STYLE DS_MODALFRAME | WS_POPUP | WS_VISIBLE | WS_CAPTION | WS_SYSMENU
CAPTION "TCP聊天-客户端"
FONT 9, "宋体"
{
LTEXT "服务器IP地址", -1, 6, 7, 53, 8
EDITTEXT IDC_SERVER, 63, 5, 116, 12
LTEXT "用户名", -1, 6, 22, 28, 8
EDITTEXT IDC_USER, 35, 20, 59, 12
LTEXT "密码", -1, 99, 22, 19, 8
EDITTEXT IDC_PASS, 120, 20, 59, 12
PUSHBUTTON "登录(&L)", IDC_LOGIN, 185, 4, 56, 14, WS_DISABLED | WS_TABSTOP
PUSHBUTTON "注销(&X)", IDC_LOGOUT, 185, 19, 56, 14, WS_DISABLED | WS_TABSTOP
LISTBOX IDC_INFO, 4, 38, 237, 110, LBS_STANDARD
LTEXT "输入", -1, 6, 153, 19, 8
EDITTEXT IDC_TEXT, 28, 151, 150, 12, ES_AUTOHSCROLL | WS_DISABLED| WS_BORDER | WS_TABSTOP
DEFPUSHBUTTON "发送(&S)", IDOK, 185, 150, 56, 14, BS_DEFPUSHBUTTON| WS_DISABLED | WS_TABSTOP
}
以阻塞模式工作的客户端汇编源代码Client1.asm的内容如下:
; Client1.asm
; 使用 TCP 协议的聊天室例子程序 —— 客户端
; 本例子使用阻塞模式socket
; --------------------------------------------------------------------
; 使用 nmake 或下列命令进行编译和链接:
; ml /c /coff Client1.asm
; rc Client.rc
; Link /subsystem:windows Client1.obj Client.res
.386
.model flat,stdcall
option casemap:none ;include 数据
;----------------------------------------------------------------
include c:/masm32/include/windows.inc
include c:/masm32/include/user32.inc
includelib c:/masm32/lib/user32.lib
include c:/masm32/include/kernel32.inc
includelib c:/masm32/lib/kernel32.lib
include c:/masm32/include/wsock32.inc
includelib c:/masm32/lib/wsock32.lib
include _Message.inc ;equ 数据
;----------------------------------------------------------------
ICO_MAIN equ 1000
DLG_MAIN equ 2000
IDC_SERVER equ 2001
IDC_USER equ 2002
IDC_PASS equ 2003
IDC_LOGIN equ 2004
IDC_LOGOUT equ 2005
IDC_INFO equ 2006
IDC_TEXT equ 2007
TCP_PORT equ 9999;数据段
;------------------------------------------------------------------
.data?
hInstance dword ?
hWinMain dword ?
hSocket dword ?
dwLastTime dword ?
szServer byte 16 dup(?)
szUserName byte 12 dup(?)
szPassword byte 12 dup(?)
szText byte 256 dup(?).const
szErrIP byte '无效的服务器IP地址!',0
szErrConnect byte '无法连接到服务器!',0
szErrLogin byte '无法登录到服务器,请检查用户名密码!',0
szSpar byte ' : ',0;代码段
;-------------------------------------------------------------------
.code
include _SocketRoute.asm
;通讯线程
;--------------------------------------------------------------------
_WorkThread proc _lParam local @stSin:sockaddr_in, @stMsg:MSG_STRUCT local @szBuffer[512]:byte pushad invoke GetDlgItem, hWinMain, IDC_SERVER invoke EnableWindow, eax, FALSE invoke GetDlgItem, hWinMain, IDC_USER invoke EnableWindow, eax, FALSE invoke GetDlgItem, hWinMain, IDC_PASS invoke EnableWindow, eax, FALSE invoke GetDlgItem, hWinMain, IDC_LOGIN invoke EnableWindow, eax, FALSE ;创建 socketinvoke RtlZeroMemory, addr @stSin, sizeof @stSin invoke inet_addr, addr szServer .if eax == INADDR_NONE invoke MessageBox, hWinMain, addr szErrIP, NULL, MB_OK or MB_ICONSTOP jmp _Ret .endif mov @stSin.sin_addr, eax mov @stSin.sin_family, AF_INET invoke htons, TCP_PORT mov @stSin.sin_port, ax invoke socket, AF_INET, SOCK_STREAM, 0mov hSocket, eax ;连接到服务器invoke connect, hSocket, addr @stSin, sizeof @stSin .if eax == SOCKET_ERROR invoke MessageBox, hWinMain, addr szErrConnect, NULL, MB_OK or MB_ICONSTOP jmp _Ret .endif ;登录到服务器invoke lstrcpy, addr @stMsg.Login.szUserName, addr szUserName invoke lstrcpy, addr @stMsg.Login.szPassword, addr szPassword mov @stMsg.MsgHead.dwLength, sizeof MSG_HEAD+sizeof MSG_LOGIN mov @stMsg.MsgHead.dwCmdId, CMD_LOGIN invoke send, hSocket, addr @stMsg, @stMsg.MsgHead.dwLength, 0cmp eax, SOCKET_ERROR jz @F invoke _RecvPacket, hSocket, addr @stMsg, sizeof @stMsg or eax, eax jnz @F cmp @stMsg.MsgHead.dwCmdId, CMD_LOGIN_RESP jnz @F .if @stMsg.LoginResp.dbResult @@:invoke MessageBox, hWinMain, addr szErrLogin, NULL, MB_OK or MB_ICONSTOP jmp _Ret .endif invoke GetDlgItem, hWinMain, IDC_LOGOUT invoke EnableWindow, eax, TRUE invoke GetDlgItem, hWinMain, IDC_TEXT invoke EnableWindow, eax, TRUE invoke GetTickCount mov dwLastTime, eax ;循环接收消息.while hSocket invoke GetTickCount sub eax, dwLastTime .break .if eax >= 60*1000invoke _WaitData, hSocket, 200*1000.break .if eax == SOCKET_ERROR .if eax invoke _RecvPacket, hSocket, addr @stMsg, sizeof @stMsg .break .if eax .if @stMsg.MsgHead.dwCmdId == CMD_MSG_DOWN invoke lstrcpy, addr @szBuffer, addr @stMsg.MsgDown.szSender invoke lstrcat, addr @szBuffer, addr szSpar invoke lstrcat, addr @szBuffer, addr @stMsg.MsgDown.szContent invoke SendDlgItemMessage, hWinMain, IDC_INFO, LB_INSERTSTRING, 0, addr @szBuffer .endif invoke GetTickCount mov dwLastTime, eax .endif .endw invoke GetDlgItem, hWinMain, IDOK invoke EnableWindow, eax, FALSE invoke GetDlgItem, hWinMain, IDC_TEXT invoke EnableWindow, eax, FALSE invoke GetDlgItem, hWinMain, IDC_LOGOUT invoke EnableWindow, eax, FALSE _Ret:.if hSocket invoke closesocket, hSocket xor eax, eax mov hSocket, eax .endif invoke GetDlgItem, hWinMain, IDC_SERVER invoke EnableWindow, eax, TRUE invoke GetDlgItem, hWinMain, IDC_USER invoke EnableWindow, eax, TRUE invoke GetDlgItem, hWinMain, IDC_PASS invoke EnableWindow, eax, TRUE invoke GetDlgItem, hWinMain, IDC_LOGIN invoke EnableWindow, eax, TRUE popad ret
_WorkThread endp ; 主窗口程序
_ProcDlgMain proc uses ebx edi esi hWnd, wMsg, wParam, lParam local @stWsa:WSADATA, @stMsg:MSG_STRUCT mov eax, wMsg .if eax == WM_COMMAND mov eax, wParam ;全部输入IP地址,用户名和密码后则激活"登录"按钮.if (ax == IDC_SERVER) || (ax == IDC_USER) || (ax == IDC_PASS)invoke GetDlgItemText, hWinMain, IDC_SERVER, addr szServer, sizeof szServer invoke GetDlgItemText, hWinMain, IDC_USER, addr szUserName, sizeof szUserName invoke GetDlgItemText, hWinMain, IDC_PASS, addr szPassword, sizeof szPassword invoke GetDlgItem, hWinMain, IDC_LOGIN .if szServer && szUserName && szPassword && !hSocket invoke EnableWindow, eax, TRUE .else invoke EnableWindow, eax, FALSE .endif ;登录成功后,输入聊天语句后才激活"发送"按钮.elseif ax == IDC_TEXT invoke GetDlgItemText, hWinMain, IDC_TEXT, addr szText, sizeof szText invoke GetDlgItem, hWinMain, IDOK .if szText && hSocket invoke EnableWindow, eax, TRUE .else invoke EnableWindow, eax, FALSE .endif .elseif ax == IDC_LOGIN push ecx invoke CreateThread, NULL, 0, offset _WorkThread, 0, NULL, esp pop ecx invoke CloseHandle, eax .elseif ax == IDC_LOGOUT @@:.if hSocket invoke closesocket, hSocket xor eax, eax mov hSocket, eax .endif .elseif ax == IDOK invoke lstrcpy, addr @stMsg.MsgUp.szContent, addr szText invoke lstrlen, addr @stMsg.MsgUp.szContent inc eax mov @stMsg.MsgUp.dwLength, eax add eax, sizeof MSG_HEAD+MSG_UP.szContent mov @stMsg.MsgHead.dwLength, eax mov @stMsg.MsgHead.dwCmdId, CMD_MSG_UP invoke send, hSocket, addr @stMsg, @stMsg.MsgHead.dwLength, 0cmp eax, SOCKET_ERROR jz @B invoke GetTickCount mov dwLastTime, eax invoke SetDlgItemText, hWinMain, IDC_TEXT, NULL invoke GetDlgItem, hWinMain, IDC_TEXT invoke SetFocus, eax .endif .elseif eax == WM_CLOSE .if !hSocket invoke WSACleanup invoke EndDialog, hWinMain, NULL .endif .elseif eax == WM_INITDIALOG push hWnd pop hWinMain invoke LoadIcon, hInstance, ICO_MAIN invoke SendMessage, hWnd, WM_SETICON, ICON_BIG, eax invoke WSAStartup, 101h, addr @stWsa invoke SendDlgItemMessage, hWinMain, IDC_SERVER, EM_SETLIMITTEXT, 15, 0invoke SendDlgItemMessage, hWinMain, IDC_USER, EM_SETLIMITTEXT, 11, 0invoke SendDlgItemMessage, hWinMain, IDC_PASS, EM_SETLIMITTEXT, 11, 0invoke SendDlgItemMessage, hWinMain, IDC_TEXT, EM_SETLIMITTEXT, 250, 0.else mov eax, FALSE ret .endif mov eax, TRUE ret
_ProcDlgMain endp ; 程序开始
main proc invoke GetModuleHandle, NULL mov hInstance, eax invoke DialogBoxParam, eax, DLG_MAIN, NULL, offset _ProcDlgMain, 0invoke ExitProcess, 0
main endp
end main
从通信的角度来看,客户端并不复杂,接收操作是在一个单独的线程中完成的,发送操作是在主线程中响应“发送”按钮的代码中完成的。程序的大部分代码用于在通信流程中对各按钮和控件的状态进行修改。
在运行后,初始状态下用户可以输入服务器IP地址、用户名和密码,这时其他的所有按钮都是灰化的,只有全部输入这三个参数后,“登录”按钮才被激活。三个文本框的检测代码在对WM_COMMAND消息的响应中实现。
输入IP地址、用户名和密码后,再按下“登录”按钮,程序将创建一个单独的线程来连接到服务器并与服务器进行通信。线程过程是_WorkThread,请读者注意,其中对按钮状态的更新代码,线程中首先将IP地址、用户名、密码输入框和“登录”按钮全部灰化,以防止“登录”按钮被多次按下导致创建多个工作线程。
接下来在线程中用socket函数创建套接字、填写sockaddr_in结构并用connect函数连接到服务器,一旦连接成功,程序首先向服务器端发送一个Login数据包,数据包中包含了用户输入的用户名和密码信息,然后用_RecvPacket子程序接收返回的LoginResp数据包,假如数据包中的dbResult字段为0,表示登录成功(这个字段的含义是我们自己在通信协议中定义的,不是吗?),这时程序将“注销”按钮和聊天语句输入框激活,以便用户可以输入聊天语句或者从服务器注销。
在连接和登录的过程中如果出错,包括IP地址无法解析、无法连接到服务器或者服务器返回的LoginResp数据包中dbResult字段的代码不正确,程序将显示对应的出错提示消息框并终止线程,线程过程在退出前,IP地址、用户名、密码输入框将被重新激活,以便用户重新进行连接和登录的操作。
成功登录到服务器后,工作线程即进入接收、处理数据包的循环,如果接收到MsgDown数据包,则将数据包中的发送者和内容字段组合成一个字符串显示在列表框中。
在16.3.1节中曾经分析过,由于阻塞模式下在不同线程中发送数据包不会有问题,所以用户输入聊天语句并按下“发送”按钮的时候,程序直接在窗口消息的处理代码中进行发送操作,如果发送失败,则表示连接中断,程序转到“注销”按钮的对应代码中去处理。
发送失败或者用户按下“注销”按钮的时候,按钮的处理代码中将套接字关闭,这样工作线程中的select函数或者recv函数的执行就会失败,工作线程将退出循环并终止线程。
工作线程中还加入了对链路异常中断的检测,程序定义了一个dwLastTime变量,每次成功接收或者发送数据包后,都会将dwLastTime变量更新为当前时间计数。每次循环中,都会将当前时间和dwLastTime变量的差值进行检查,在连接正常并且空闲的时候,服务器端每隔30秒会发过来一个链路检测包,所以正常情况下差值不会超过30秒,一旦检测到差值大于60秒,那么表示连接异常中断了,循环即退出。
16.3.4 以非阻塞方式工作的TCP聊天室客户端
到现在为止,前面的例子都是关于阻塞模式的,本节将介绍如何在Windows下以非阻塞方式对套接字进行收发操作,并将TCP聊天室例子中的客户端改为以非阻塞模式工作。
1.WinSock接口非阻塞模式的工作方式
在前面的内容中已经详细介绍过各个函数在非阻塞模式下的表现方式,非阻塞模式的一个显著特点就是,任何WinSock函数在被调用后肯定是马上返回的,不会等待操作完成才返回。如果函数是因为阻塞而出错返回,那么错误代码是WSAEWOULDBLOCK。
在UNIX下,阻塞方式和非阻塞方式的区别仅限于此,但如果仅仅是这点区别的话,只要将函数的出错代码做一下判断并稍微调整一下各函数的使用方式即可,没必要专门以一节的篇幅来说明。实际上,WinSock接口对非阻塞方式的工作模式做了很大的扩展,最主要的特征是,函数因阻塞而马上返回后,接口会在后台继续操作,一旦操作完成,会以窗口消息的方式通知窗口过程。因为这种特征,Windows系统非阻塞模式下的网络应用程序结构将和阻塞模式的完全不同,阻塞模式下的程序架构是过程驱动的(即按顺序执行),而非阻塞模式下的程序架构是消息驱动的。
当一个套接字被创建的时候,它默认工作在阻塞模式下,用WSAAsyncSelect函数可以将套接字设置为非阻塞模式,并且打开套接字的消息通知机制,通知消息可以被绑定到某个窗口句柄中,这样程序就不必不停地去查询套接字的操作是否已经完成,只要在窗口过程中等收到通知消息后再进行处理即可。WSA带头的函数是WinSock接口扩展的函数,在UNIX系统下并不存在。
WSAAsyncSelect函数的用法是:
invoke WSAAsyncSelect,s,hWnd,wMsg,lEvent
s参数指定需要设置的套接字句柄,hWnd指定一个窗口句柄,套接字的通知消息将被发送到与其对应的窗口过程中。
通知消息的消息编号可以由程序自己定义,当不同的动作完成以后,不同的通知码将被包含在消息的参数中传递给指定的窗口过程,wMsg参数用来定义通知消息的编号,读者可以在WM_USER以上数值中任取一个。
最后的参数lEvent指定哪些通知码需要发送,它可以被指定为几个通知码的组合,常用的通知码如下:
● FD_READ——套接字收到对端发送过来的数据包,表明这时可以去读套接字。
● FD_WRITE——当短时间内向一个套接字发送太多数据造成缓冲区满以后,发送函数会返回出错信息,当缓冲区再次有空的时候,WinSock接口通过这个通知码通知应用程序,表示可以继续发送数据了。但是缓冲区未溢出的情况下,数据被发送完毕的时候并不会发送这个通知码。
● FD_ACCEPT——监听中的套接字检测到有连接进入(适用于TCP套接字)。
● FD_CONNECT——如果用一个套接字去连接对方主机,当连接动作完成以后将收到这个通知码(适用于TCP套接字)。
● FD_CLOSE——检测到套接字对应的连接被关闭(适用于TCP套接字)。
在使用中并不需要指定全部这些通知码。例如,UDP套接字没有连接和断开的过程,所以FD_CONNECT,FD_CLOSE和FD_ACCEPT等通知码是没有意义的,而TCP套接字不用于监听的话,FD_ACCEPT也是没有意义的,所以要根据套接字的类型选用合适的通知码。
窗口过程收到通知消息后,消息的wParam参数是触发消息的套接字句柄(可能有多个套接字绑定到同一个窗口中,这时可以用wParam参数加以区分),lParam参数的低16位就是通知码,如果函数执行成功,lParam的高16位为0,执行失败的话,高16位是出错代码(相当于阻塞模式下调用了WSAGetLastError后得到的出错代码)。
WinSock程序在非阻塞模式下的架构如图16.8所示,一般来说WSAStartup函数被安排在窗口的初始化消息中(图中的①),当程序需要退出时,程序调用WSACleanup函数卸载WinSock库(图中的⑧)。
图16.8 非阻塞模式下网络程序的常见结构
当程序向服务器端发起连接时(比如,用户按下了“登录”按钮,图中的②),程序使用socket函数创建套接字,然后使用WSAAsyncSelect函数将通知消息以自定义的编号(图中以WM_SOCKET举例)绑定到窗口过程中,接下来用connect函数去连接服务器。
一般来说,非阻塞模式下的连接代码如下:
invoke connect,hSocket,addr @stSin,sizeof @stSin
.if eax == SOCKET_ERRORinvoke WSAGetLastError.if eax != WSAEWOULDBLOCK;真正的出错,关闭套接字并退出.endif
.endif
connect函数会马上返回,那么什么时候才知道连接的动作已经完成了呢?那就要等待包含FD_CONNECT通知码的WM_SOCKET消息了(图中的③),当收到通知消息时,lParam参数的高16位包含了出错信息,如果检测到高16位等于0表示连接成功,可以开始收发数据了(图中的④);否则表示没有连接成功,程序应该关闭套接字并显示出错信息(图中的⑤)。
FD_CONNECT通知码的处理代码举例如下:
mov eax,wMsg
.if eax == WM_SOCKETmov eax,lParam.if ax == FD_CONNECTshr eax,16 ;取lParam的高16位.if !ax;连接成功,可以进行收发数据了.else;连接失败,显示出错信息;并且关闭套接字.endif.elseif eax == ...
当从FD_CONNECT通知码的参数判断连接成功以后,就可以开始收发数据了,但是什么时候有数据可供接收呢,是不是需要随时去读套接字?答案是并不需要,如果有数据传过来,WinSock接口会发送包含FD_READ通知码的窗口消息,程序可以在消息的处理代码中读取数据(图中的A所示)。
有一点要注意的是,如果收到FD_READ通知码后程序没有去读取数据,这时系统又收到了新的数据的话,那么系统并不会再次发送通知,而是必须在调用了recv函数以后才有可能再次收到FD_READ通知。但是,如果调用recv后没有读完接收缓冲区中的所有数据,系统会再发送一个FD_READ通知,表示缓冲区中仍有数据存在。
程序任何时候都可以调用send函数来发送数据,但是一旦得到WSAEWOULDBLOCK错误后,程序就应该暂停发送,因为这时表示发送缓冲区已满,即使不停尝试重发的话也会得到同样的错误。
那么什么时候可以再发送呢?答案是等到FD_WRITE通知后就可以了,程序可以设置一个标志和FD_WRITE通知配合起来控制流量,标志一开始被设置为“可以发送”状态,如果使用send函数发送数据时发现返回WSAEWOULDBLOCK错误,程序暂停发送并将标志设置为“暂停发送”(图中的B所示),等收到FD_WRITE通知后(图中的C),程序可以将标志恢复为“可以发送”状态并继续发送数据。
断开连接的方式有两种,一种是在本地使用closesocket函数关闭套接字(图中的⑥),另一种是当连接被对方关闭时,系统会通过FD_CLOSE通知码来通知应用程序(图中的⑦),这时程序也应该将套接字关闭,因为这时套接字已经不能用来收发数据了。
2.非阻塞模式工作的聊天室客户端
当TCP链路上收发的数据是数据流方式的话,非阻塞模式的程序架构还是非常方便的,但是链路上传输的是经过封装的数据包的时候,用上面的架构实现起来就比较麻烦了。下面以具体的例子来说明如何编写非阻塞模式下的TCP聊天室客户端。
例子程序的界面和Client1例子相同,使用的资源文件也是Client.rc文件,完整的汇编源代码见Chapter16\Chat-TCP\Client2.asm文件,其中和对话框界面有关的代码和Client1.asm例子是一样的,这里仅列出和通信相关的部分代码:
; Client2.asm
; 使用 TCP 协议的聊天室例子程序 —— 客户端
; 本例子使用非阻塞模式socket
; -------------------------------------------------------------------
; 使用 nmake 或下列命令进行编译和链接:
; ml /c /coff Client2.asm
; rc Client.rc
; Link /subsystem:windows Client2.obj Client.res
.386
.model flat,stdcall
option casemap:none ;include 数据
include c:/masm32/include/windows.inc
include c:/masm32/include/user32.inc
includelib c:/masm32/lib/user32.lib
include c:/masm32/include/kernel32.inc
includelib c:/masm32/lib/kernel32.lib
include c:/masm32/include/wsock32.inc
includelib c:/masm32/lib/wsock32.lib
include _Message.inc ;equ 数据
ICO_MAIN equ 1000
DLG_MAIN equ 2000
IDC_SERVER equ 2001
IDC_USER equ 2002
IDC_PASS equ 2003
IDC_LOGIN equ 2004
IDC_LOGOUT equ 2005
IDC_INFO equ 2006
IDC_TEXT equ 2007
TCP_PORT equ 9999
WM_SOCKET equ WM_USER+100;数据段
.data?
hInstance dword ?
hWinMain dword ?
hSocket dword ?
szServer byte 16 dup(?)
szUserName byte 12 dup(?)
szPassword byte 12 dup(?)
szText byte 256 dup(?)szSendMsg MSG_STRUCT 10 dup(<>)
szRecvMsg MSG_STRUCT 10 dup(<>)
dwSendBufSize dword ?
dwRecvBufSize dword ?
dbStep byte ?.const
szErrIP byte '无效的服务器IP地址!',0
szErrConnect byte '无法连接到服务器!',0
szErrLogin byte '无法登录到服务器,请检查用户名密码!',0
szSpar byte ' : ',0;代码段
.code
; 断开连接
_DisConnect proc invoke GetDlgItem, hWinMain, IDC_TEXT invoke EnableWindow, eax, FALSE invoke GetDlgItem, hWinMain, IDC_LOGOUT invoke EnableWindow, eax, FALSE .if hSocket invoke closesocket, hSocket xor eax, eax mov hSocket, eax .endif invoke GetDlgItem, hWinMain, IDC_SERVER invoke EnableWindow, eax, TRUE invoke GetDlgItem, hWinMain, IDC_USER invoke EnableWindow, eax, TRUE invoke GetDlgItem, hWinMain, IDC_PASS invoke EnableWindow, eax, TRUE invoke GetDlgItem, hWinMain, IDC_LOGIN invoke EnableWindow, eax, TRUE ret
_DisConnect endp ; 连接到服务器
_Connect proc local @stSin:sockaddr_in, @stMsg:MSG_STRUCT local @szBuffer[512]:byte pushad invoke GetDlgItem, hWinMain, IDC_SERVER invoke EnableWindow, eax, FALSE invoke GetDlgItem, hWinMain, IDC_USER invoke EnableWindow, eax, FALSE invoke GetDlgItem, hWinMain, IDC_PASS invoke EnableWindow, eax, FALSE invoke GetDlgItem, hWinMain, IDC_LOGIN invoke EnableWindow, eax, FALSE xor eax, eax mov dbStep, al mov dwSendBufSize, eax mov dwRecvBufSize, eax ;创建 socketinvoke RtlZeroMemory, addr @stSin, sizeof @stSin invoke inet_addr, addr szServer .if eax == INADDR_NONE invoke MessageBox, hWinMain, addr szErrIP, NULL, MB_OK or MB_ICONSTOP jmp _Err .endif mov @stSin.sin_addr, eax mov @stSin.sin_family, AF_INET invoke htons, TCP_PORT mov @stSin.sin_port, ax invoke socket, AF_INET, SOCK_STREAM, 0mov hSocket, eax ;将socket设置为非阻塞模式,连接到服务器invoke WSAAsyncSelect, hSocket, hWinMain, WM_SOCKET, FD_CONNECT or FD_READ or FD_CLOSE or FD_WRITE invoke connect, hSocket, addr @stSin, sizeof @stSin .if eax == SOCKET_ERROR invoke WSAGetLastError .if eax != WSAEWOULDBLOCK invoke MessageBox, hWinMain, addr szErrConnect, NULL, MB_OK or MB_ICONSTOP jmp _Err .endif .endif ret
_Err:invoke _DisConnect ret
_Connect endp ; 发送缓冲区中的数据,上次的数据有可能未发送完,故每次发送前,
; 先将发送缓冲区合并
_SendData proc _lpData, _dwSize pushad ;将要发送的内容加到缓冲区的尾部mov esi, _lpData mov ecx, _dwSize .if esi && ecx push ecx mov edi, offset szSendMsg add edi, dwSendBufSize cld rep movsb pop ecx add dwSendBufSize, ecx .endif ;发送缓冲区@@:mov esi, offset szSendMsg mov ebx, dwSendBufSize or ebx, ebx jz _Ret invoke send, hSocket, esi, ebx, 0.if eax == SOCKET_ERROR invoke WSAGetLastError .if eax == WSAEWOULDBLOCK invoke GetDlgItem, hWinMain, IDC_TEXT invoke EnableWindow, eax, FALSE invoke GetDlgItem, hWinMain, IDOK invoke EnableWindow, eax, FALSE .else invoke _DisConnect .endif jmp _Ret .endif sub dwSendBufSize, eax mov ecx, dwSendBufSize mov edi, offset szSendMsg lea esi, [edi+eax].if ecx && (edi != esi)cld rep movsb jmp @B .endif
_Ret:popad ret
_SendData endp ;处理消息
_ProcMessage proc local @szBuffer[512]:byte mov ax, szRecvMsg.MsgHead.dwCmdId .if ax == CMD_LOGIN_RESP .if szRecvMsg.LoginResp.dbResult invoke MessageBox, hWinMain, addr szErrLogin, NULL, MB_OK or MB_ICONSTOP invoke _DisConnect .else mov dbStep, 1invoke GetDlgItem, hWinMain, IDOK invoke EnableWindow, eax, FALSE invoke GetDlgItem, hWinMain, IDC_LOGOUT invoke EnableWindow, eax, TRUE invoke GetDlgItem, hWinMain, IDC_TEXTinvoke EnableWindow, eax, TRUE .endif .elseif ax == CMD_MSG_DOWN .if dbStep < 1 invoke _DisConnect .else invoke lstrcpy, addr @szBuffer, addr szRecvMsg.MsgDown.szSender invoke lstrcat, addr @szBuffer, addr szSpar invoke lstrcat, addr @szBuffer, addr szRecvMsg.MsgDown.szContentinvoke SendDlgItemMessage, hWinMain, IDC_INFO, LB_INSERTSTRING, 0, addr @szBuffer .endif .endif ret
_ProcMessage endp ;接收数据包
_RecvData proc pushad mov esi, offset szRecvMsg mov ecx, dwRecvBufSize add esi, ecx ;如果缓冲区里数据小于数据包头长度:则先接收数据包头部;大于数据包头部,则接收的总长度由数据包头部里的dwLength指定.if ecx < sizeof MSG_HEAD mov eax, sizeof MSG_HEAD .else mov eax, szRecvMsg.MsgHead.dwLength .if eax < MSG_HEAD || eax > MSG_STRUCT mov dwRecvBufSize, 0invoke _DisConnect jmp _Ret .endif .endif sub eax, ecx .if eax invoke recv, hSocket, esi, eax, NULL .if eax == SOCKET_ERROR invoke WSAGetLastError .if eax != WSAEWOULDBLOCK invoke _DisConnect .endif jmp _Ret .endif add dwRecvBufSize, eax .endif mov eax, dwRecvBufSize ;如果整个数据包接收完毕,则进行处理.if eax >= sizeof MSG_HEAD .if eax == szRecvMsg.MsgHead.dwLength invoke _ProcMessage mov dwRecvBufSize, 0 .endif .endif
_Ret:popad ret
_RecvData endp ;主窗口程序
_ProcDlgMain proc uses ebx edi esi hWnd, wMsg, wParam, lParam local @stWsa:WSADATA, @stMsg:MSG_STRUCT mov eax, wMsg .if eax == WM_SOCKET ;处理 Socket 消息mov eax, lParam .if ax == FD_READ invoke _RecvData .elseif ax == FD_WRITE invoke GetDlgItem, hWinMain, IDC_TEXT invoke EnableWindow, eax, TRUE invoke GetDlgItem, hWinMain, IDOK invoke EnableWindow, eax, TRUE invoke _SendData, 0, 0 ;继续发送缓冲区数据.elseif ax == FD_CONNECT shr eax, 16.if ax == NULL ;连接成功则登录invoke lstrcpy, addr @stMsg.Login.szUserName, addr szUserName invoke lstrcpy, addr @stMsg.Login.szPassword, addr szPassword mov @stMsg.MsgHead.dwLength, sizeof MSG_HEAD+sizeof MSG_LOGIN mov @stMsg.MsgHead.dwCmdId, CMD_LOGIN invoke _SendData, addr @stMsg, @stMsg.MsgHead.dwLength .else invoke MessageBox, hWinMain, addr szErrConnect, NULL, MB_OK or MB_ICONSTOP invoke _DisConnect .endif .elseif ax == FD_CLOSE call _DisConnect .endif .elseif eax == WM_COMMAND mov eax, wParam .if (ax == IDC_SERVER) || (ax == IDC_USER) || (ax == IDC_PASS)invoke GetDlgItemText, hWinMain, IDC_SERVER, addr szServer, sizeof szServer invoke GetDlgItemText, hWinMain, IDC_USER, addr szUserName, sizeof szUserName invoke GetDlgItemText, hWinMain, IDC_PASS, addr szPassword, sizeof szPassword invoke GetDlgItem, hWinMain, IDC_LOGIN .if szServer && szUserName && szPassword && !hSocket invoke EnableWindow, eax, TRUE .else invoke EnableWindow, eax, FALSE .endif .elseif ax == IDC_TEXT invoke GetDlgItemText, hWinMain, IDC_TEXT, addr szText, sizeof szText invoke GetDlgItem, hWinMain, IDOK .if szText && hSocket invoke EnableWindow, eax, TRUE .else invoke EnableWindow, eax, FALSE .endif .elseif ax == IDC_LOGIN invoke _Connect .elseif ax == IDC_LOGOUT invoke _DisConnect .elseif ax == IDOK .if szText invoke lstrcpy, addr @stMsg.MsgUp.szContent, addr szText invoke lstrlen, addr @stMsg.MsgUp.szContent inc eax mov @stMsg.MsgUp.dwLength, eax add eax, sizeof MSG_HEAD+MSG_UP.szContent mov @stMsg.MsgHead.dwLength, eax mov @stMsg.MsgHead.dwCmdId, CMD_MSG_UP invoke _SendData, addr @stMsg, @stMsg.MsgHead.dwLength invoke SetDlgItemText, hWinMain, IDC_TEXT, NULL invoke GetDlgItem, hWinMain, IDC_TEXT invoke SetFocus, eax .endif .endif .elseif eax == WM_CLOSE .if !hSocket invoke WSACleanup invoke EndDialog, hWinMain, NULL .endif .elseif eax == WM_INITDIALOG push hWnd pop hWinMain invoke LoadIcon, hInstance, ICO_MAIN invoke SendMessage, hWnd, WM_SETICON, ICON_BIG, eax invoke WSAStartup, 101h, addr @stWsa invoke SendDlgItemMessage, hWinMain, IDC_SERVER, EM_SETLIMITTEXT, 15, 0invoke SendDlgItemMessage, hWinMain, IDC_USER, EM_SETLIMITTEXT, 11, 0invoke SendDlgItemMessage, hWinMain, IDC_PASS, EM_SETLIMITTEXT, 11, 0invoke SendDlgItemMessage, hWinMain, IDC_TEXT, EM_SETLIMITTEXT, 250, 0.else mov eax, FALSE ret .endifmov eax, TRUE ret
_ProcDlgMain endp ;程序开始
main proc invoke GetModuleHandle, NULL mov hInstance, eax invoke DialogBoxParam, eax, DLG_MAIN, NULL, offset _ProcDlgMain, 0invoke ExitProcess, 0
main endp
end main
下面按照连接→发送数据包→接收数据包→断开连接的顺序对代码进行分析。
按下“登录”按钮后,程序在WM_COMMAND消息的处理代码中调用_Connect子程序,子程序中首先将服务器IP地址、用户名、密码文本框和“登录”按钮灰化,然后用socket函数创建套接字,再用WSAAsyncSelect函数将套接字的通知消息绑定到对话框窗口中,注意函数中用到的WM_SOCKET消息并不是预定义的消息编号,而是在程序的开始部分自定义为WM_USER+100的。
接下来就是用connect函数连接到服务器了,非阻塞模式下connect函数肯定返回失败,因为连接的过程不可能马上完成,但必须在检测到出错代码不是WSAEWOULDBLOCK时才表示真正失败,这时程序显示对应的信息并调用_DisConnect子程序,_DisConnect子程序的功能是关闭套接字并且重新激活服务器IP地址、用户名、密码文本框和“登录”按钮,以便进行下一次登录操作。如果调用connect函数得到的出错代码是WSAEWOULDBLOCK,子程序只需直接返回即可。
等connect函数真正执行完毕后,窗口过程会被调用,对应的消息是包含FD_CONNECT通知码的WM_SOCKET消息,在相应的处理代码中,如果发现lParam的高16位不为0,程序同样提示“无法连接到服务器”并调用_DisConnect子程序,lParam的高16位为0则表示连接成功,这时应该向服务器端发送登录数据包了。
但是发送数据包的操作却有点难度,因为非阻塞模式下,send函数实际发送的字节数可能少于请求发送的字节数,而且发送中可能会遇到发送缓冲区满的情况,这时程序不能机械地等待发送缓冲区变空,而必须退出窗口过程,否则可能会影响其他消息的处理,只有等下次收到FD_WRITE通知码后才能继续发送未发送的部分。
为了处理这些情况,程序定义了一个足够大的发送缓冲区szSendMsg(例子中定为10个MSG_STRUCT结构的长度),如果需要发送数据,程序将数据首先移入发送缓冲区并进行发送,在发送的过程中,每次调用send函数后,将缓冲区头部已发送的部分丢弃并把后续的数据前移,如果调用send函数没有得到WSAEWOULDBLOCK错误,则循环将发送缓冲区发完为止,万一中途得到WSAEWOULDBLOCK错误,则灰化聊天语句输入框和“发送”按钮并退出(这是为了防止用户继续发送聊天语句)。退出时可能还有部分数据未发送完毕,没关系,因为数据保存在全局的数据段中,不会随退出而丢失。
一旦窗口过程收到FD_WRITE通知码,那么程序重新激活聊天语句输入框和“发送”按钮,并继续用上述算法重新发送缓冲区中的未完成数据,完成上述发送功能的代码包含在_SendData子程序中,子程序的两个参数为要发送的数据地址和数据长度,如果调用的时候两个参数指定为0,则表示没有新的数据要发送,程序将仅发送缓冲区中的未发送部分,读者可以看到,收到FD_WRITE通知时两个参数指定为0,而在“发送”按钮的响应代码中,两个参数指定为MSG_UP数据包的地址和长度。
接收数据包的时候也需要多次拼接,比如,得到FD_READ通知码后去接收数据时,收到的数据不一定够一个完整的数据包,这时程序不能在WM_SOCKET消息中等待,否则会阻塞对其他窗口消息的处理,而是要将收到的数据保存起来,等下次收到FD_READ通知码后继续读取,并将多次读取的数据拼接成一个完整的数据包后再进行处理。
接收及拼接数据包的代码在_RecvData子程序中完成,程序首先在数据段中定义了一个缓冲区szRecvMsg用于保存数据,每次得到FD_READ通知码后,程序调用_RecvData子程序,子程序的开始部分首先检查缓冲区中的已有数据长度dwRecvBufSize,如果长度小于一个数据包头的长度,则下次调用recv时接收的字节数为sizeof MSG_HEAD-dwRecvBufSize;否则,则从已完整接收的数据包头中取出数据包长度dwLength,并指定recv函数接收dwLength- dwRecvBufSize字节的数据。这种算法是为了首先接收完整的数据包头,从数据包头中得到数据包体的长度后再接收数据包体。
每次调用recv函数后,程序检测缓冲区中的数据长度是否已经是完整的数据包,如果是,则调用_ProcMessage子程序处理数据包,处理完毕后,程序将缓冲区中的已处理数据包丢弃,也就是将已有数据的计数变量dwRecvBufSize设置为0。
现在来关心一下处理数据包的_ProcMessage子程序。总的来说,这个子程序的处理流程和阻塞模式下的处理流程一致,唯一的不同在于多了一个对dbStep变量的控制,这个变量是干什么的呢?
在阻塞模式下,程序是按顺序执行的,假如程序逻辑规定第一步是登录,第二步是发送聊天语句,那么程序在第二步执行时,我们就可以确定第一步已经执行过了。但非阻塞模式则不同,不管是哪一步,程序总是在同样的窗口过程中执行(这就是消息驱动方式的坏处,不是吗?),所以需要定义一个dbStep变量来记录程序的逻辑状态。
与阻塞模式的程序相比,非阻塞模式的缺点之一在于难以维护程序的状态,如果程序中需要同时操作多个套接字,则需要在窗口过程中处理混杂在一起的全局数据,在数据的维护上非常麻烦;缺点之二在于窗口消息的处理方式本质上是串行的,也就是说,其他窗口消息被处理时,就无法处理WM_SOCKET消息,反之亦然。在实际使用的时候具体使用哪种模式,读者可以根据程序的特点进行选择。
最后给读者留一个思考题:程序中没有链路空闲60秒后自动断开的代码,如果要加上这个功能,该如何修改代码呢?
答案是,由于程序中没有一个专门的线程来收发数据包,所以超时检测机制需要在主线程中完成,最适合的办法就是用定时器。程序可以创建一个以10秒钟为周期的定时器,并定义一个dwLastTime变量,在每次成功调用recv和send函数后更新dwLastTime变量,在定时器消息里面判断当前时间和dwLastTime变量的差值大于60秒则调用_DisConnect子程序即可,定时器在连接成功后创建,在连接断开的时候销毁。
Win32 API函数的名称一般由几个单词组成,每个单词的首字母是大写的,但是大部分WinSock函数的命名却是全部小写的,如socket、closesocket、ntohl等,造成这种现象的原因是这些函数名称源于UNIX socket,而UNIX socket中的函数命名全部是小写的。读者也可以看到:WinSock接口中由Windows系统扩展的函数使用的就是标准的Win32 API命名方式,如WSAStartup和WSACleanup等。从这里也可以看出哪些函数是WinSock接口特有的。
16.3.5 其他常用函数
在本章的最后,将介绍一些相对独立的WinSock接口函数,这些函数用于实现一些辅助的功能,在前面的例子中,从简化程序、便于理解的角度考虑,没有把这些函数用上去,但在实际的编程中它们还是很常用的。
1.和主机名相关的函数
在客户端例子代码中,我们在服务器IP地址一栏中输入的是类似于aa.bb.cc.dd类型的IP地址字符串,如果输入类似于www.sina.com.cn之类的主机名就不行了,但是在实际的应用中,我们得到的服务器地址经常是一个主机名,要想程序的兼容性好一点,就必须既能处理IP地址,也能处理主机名。
和主机名相关的函数有gethostbyname、gethostbyaddr和gethostname。其中gethostbyname函数可以将主机名转换成IP地址。在附书光盘的Chapter16目录下有一个Ping.exe程序,在命令行中运行该程序(注意,不要运行操作系统自带的Ping程序),例如Ping www.sina.com.cn的时候,程序的运行结果是:
The host [www.sina.com.cn] has 4 IP addresses:
211.95.77.1 / 211.95.77.4 / 211.95.77.3 / 211.95.77.2
Ping first IP 211.95.77.1 with 32 bytes of data:
Reply from 211.95.77.1: bytes=32 time=230ms TTL=55
Reply from 211.95.77.1: bytes=32 time=231ms TTL=55
可以发现,程序将www.sina.com.cn解析成了4个IP地址,这个从主机名到IP的解析功能就是用gethostbyname函数完成的。该函数的用法是:
invoke gethostbyname,lphostname
函数唯一输入的参数是需要解析的主机名字符串,如果解析失败将返回0;成功的话,因为一个主机名可能对应多个IP地址,所以函数无法直接用eax返回所有的IP地址,函数返回的是一个指针,指向位于WinSock接口内部缓冲区中的一个hostent结构中,这个结构的定义是:
hostent STRUCTh_name DWORD ? ;指针,指向和IP地址对应的主机名h_alias DWORD ? ;指针,指向一个包含别名指针的列表h_addr WORD ? ;返回的IP地址类型h_len WORD ? ;每个地址的长度h_list DWORD ? ;指向一个指针列表
hostent ENDS
目标主机的IP地址由h_list字段来返回,如图16.9所示,gethostbyname函数返回hostent结构指针,hostent结构中的h_list字段本身也是一个指针,它指向一个IP地址的指针列表,当主机名对应多个IP地址的时候,这个指针列表中就存在多个表项,最后一个表项总是NULL,用来指示列表的结束。真正的IP地址数据的存放位置由指针列表中的多个指针指出。
图16.9 从gethostbyname函数的返回值得到IP地址
所以对gethostbyname函数的返回值要经过多次指针的转换后才能得到IP地址数据,比较麻烦,如果要得到所有IP地址的话,处理代码如下:
invoke gethostbyname,addr szHostName
.if eaxmov eax,[eax + hostent.h_list] ;取h_list指针.while dword ptr [eax]mov ecx,[eax] ;取一个IP地址的指针mov ecx,[ecx] ;用指针取出IP地址;现在ecx中就是IP地址,可以进行处理了!add eax,4 ;指向下一个IP地址指针.endw
.endif
如果只需要得到第一个IP地址,那么就不需要循环了:
invoke gethostbyname,addr szHostName
.if eaxmov eax,[eax + hostent.h_list] ;取h_list指针mov eax,[eax]mov eax,[eax]
.endif ;现在eax中就是第一个IP地址
函数返回的IP地址已经是网络字节顺序的,可以直接用在其他函数中。
如果输入的是一个类似于aa.bb.cc.dd类型的IP地址字符串,函数也能将其转换成正确的IP地址,但是gethostbyname函数是利用DNS来解析主机地址的,访问DNS服务器需要一些时间。当输入的是IP地址字符串时,虽然在本地计算一下就可以得到IP地址,但函数还是会机械地去访问DNS服务器,造成不必要的延时。
而用inet_addr函数来转换IP地址字符串的工作是在本地运算的,不会有延时,所以在实际使用中,一般先尝试用inet_addr函数将输入的地址字符串当成IP地址串来转换,如果不成功的话,再当做主机名来解析,这样就不会造成不必要的等待。
具体的代码见下面的_GetHostIp子程序,子程序的输入参数是指向IP地址字符串或者主机名字符串的指针,如果转换成功,则返回IP地址,否则返回0:
_GetHostIp proc uses ecx edx _lpHostNameinvoke inet_addr,_lpHostName ;首先当做IP地址字符串处理.if eax == INADDR_NONE ;如果不成功则当做主机名处理invoke gethostbyname,_lpHostName.if eaxmov eax,[eax + hostent.h_list]mov eax,[eax]mov eax,[eax].endif.endifret
_GetHostIp endp
在前面的Client1例子中,只要将inet_addr函数的调用语句换成对_GetHostIp子程序的调用,那么在服务器地址输入栏中既可以使用IP地址,也可以使用主机名。读者也可以把这个子程序直接用在自己的程序中。
gethostbyaddr函数则用来从将IP地址转换成主机名,函数的用法是:
invoke gethostbyaddr,addr dwIP,length,type
函数的第一个参数是指向IP地址的指针,注意这里的IP地址不是指字符串,而是指按网络字节顺序排列的32位IP地址,第二个参数是前面参数中IP地址数据的长度(当然就是4了),第三个参数是地址的类型,一般指定为AF_INET。
如果转换失败,函数返回0,成功的话函数的返回值也是一个指向hostent结构的指针,结构的第一个字段h_name指向转换后的主机名。gethostbyaddr的具体使用例子如下,这段代码将dwIP中的IP地址转换成主机名放到szHostName中:
dwIP dd ?
szHostName db MAX_PATH dup (?)
...
invoke gethostbyaddr,addr dwIP,4,AF_INET
.if eaxmov eax,[eax] ;得到h_name字段.if eax ;h_name是一个字符串指针invoke lstrcpy,addr szHostName,eax.endif
.endif
最后,使用gethostname函数可以获取本地计算机的主机名:
invoke gethostname,lpbuffer,size
这个函数的使用非常简单,函数将在lpbuffer指定的缓冲区中返回本地计算机的主机名字符串,size参数指定缓冲区的大小。
2.获取套接字两端的地址信息
当一个TCP套接字已经在连接状态的时候,它的对端使用哪个IP地址和端口,本地又是使用哪个地址和端口呢?这两种信息可以通过getpeername和getsockname函数来获取。
getpeername函数的用法是:
invoke getpeername,s,lpsockaddr,size
第一个参数是套接字句柄,lpsockaddr参数指向一个缓冲区,函数会在这里返回一个描述对端使用的IP地址和端口的sockaddr_in结构,size参数指定缓冲区的长度。如果地址信息获取成功,函数返回0;当套接字未在连接状态,或者其他原因导致无法获取地址信息,则函数返回SOCKET_ERROR。
调用getpeername函数不是获取对端IP地址和端口的唯一办法,实际上,如果连接是本机主动发起的,那么发起连接的时候我们就已经知道对端的IP地址和端口,根本不必重新去获取一遍;连接是对方发起的时候,本地程序在调用accept函数的时候也可以得到对方的地址信息,这一点在accept函数的介绍中已经说明过了。
getsockname函数则用于获取本地端使用的IP地址和端口,读者可能会说,这个IP地址就是本机地址呀,还用检测吗?但是当本机有多个IP地址的时候,TCP连接使用的是其中的一个IP地址而不是全部IP地址,所以通过这个函数可以得知连接究竟是建立在哪个IP地址之上的。另外,当本机主动发起连接的时候,我们一般不会主动将套接字bind到一个特定的端口上,而是让系统自动选择端口,这时可以通过getsockname函数得知系统选择的是哪个端口号。
getsockname函数的参数和getpeername一模一样,只不过在缓冲区中返回的是包含本地IP地址和端口的sockaddr_in结构。